Merge pull request #1187 from MODSetter/dev

feat: various fixes and streamlined versioning
This commit is contained in:
Rohan Verma 2026-04-08 17:39:22 -07:00 committed by GitHub
commit f47e7a8200
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 6712 additions and 5022 deletions

View file

@ -52,6 +52,10 @@ jobs:
VERSION=${TAG#beta-} VERSION=${TAG#beta-}
VERSION=${VERSION#v} VERSION=${VERSION#v}
fi fi
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then
echo "::error::Version '$VERSION' is not valid semver (expected X.Y.Z). Fix your tag name."
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
- name: Setup pnpm - name: Setup pnpm

View file

@ -40,11 +40,11 @@ jobs:
- name: Read app version and calculate next Docker build version - name: Read app version and calculate next Docker build version
id: tag_version id: tag_version
run: | run: |
APP_VERSION=$(grep -E '^version = ' surfsense_backend/pyproject.toml | sed 's/version = "\(.*\)"/\1/') APP_VERSION=$(tr -d '[:space:]' < VERSION)
echo "App version from pyproject.toml: $APP_VERSION" echo "App version from VERSION file: $APP_VERSION"
if [ -z "$APP_VERSION" ]; then if [ -z "$APP_VERSION" ]; then
echo "Error: Could not read version from surfsense_backend/pyproject.toml" echo "Error: Could not read version from VERSION file"
exit 1 exit 1
fi fi

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.0.15

82
scripts/bump-version.ps1 Normal file
View file

@ -0,0 +1,82 @@
$ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path "$PSScriptRoot\..").Path
$VersionFile = Join-Path $RepoRoot "VERSION"
if (-not (Test-Path $VersionFile)) {
Write-Error "VERSION file not found at $VersionFile"
exit 1
}
$Version = (Get-Content $VersionFile -Raw).Trim()
if ($Version -notmatch '^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$') {
Write-Error "'$Version' is not valid semver (expected X.Y.Z)"
exit 1
}
Write-Host "Bumping all packages to $Version"
Write-Host "---------------------------------"
function Bump-Json {
param([string]$File)
if (-not (Test-Path $File)) {
Write-Host " SKIP $File (not found)"
return
}
$content = Get-Content $File -Raw
$match = [regex]::Match($content, '"version"\s*:\s*"([^"]*)"')
if (-not $match.Success) {
Write-Host " SKIP $File (no version field found)"
return
}
$old = $match.Groups[1].Value
if ($old -eq $Version) {
Write-Host " OK $File ($old -- already up to date)"
} else {
$content = $content -replace [regex]::Escape("`"version`": `"$old`""), "`"version`": `"$Version`""
Set-Content $File -Value $content -NoNewline
Write-Host " SET $File ($old -> $Version)"
}
}
function Bump-Toml {
param([string]$File)
if (-not (Test-Path $File)) {
Write-Host " SKIP $File (not found)"
return
}
$content = Get-Content $File -Raw
$match = [regex]::Match($content, '(?m)^version\s*=\s*"([^"]*)"')
if (-not $match.Success) {
Write-Host " SKIP $File (no version field found)"
return
}
$old = $match.Groups[1].Value
if ($old -eq $Version) {
Write-Host " OK $File ($old -- already up to date)"
} else {
$content = $content -replace ('(?m)^version\s*=\s*"' + [regex]::Escape($old) + '"'), "version = `"$Version`""
Set-Content $File -Value $content -NoNewline
Write-Host " SET $File ($old -> $Version)"
}
}
Bump-Json (Join-Path $RepoRoot "surfsense_web\package.json")
Bump-Json (Join-Path $RepoRoot "surfsense_browser_extension\package.json")
Bump-Json (Join-Path $RepoRoot "surfsense_desktop\package.json")
Bump-Toml (Join-Path $RepoRoot "surfsense_backend\pyproject.toml")
Write-Host ""
Write-Host "Syncing lock files..."
if (Get-Command uv -ErrorAction SilentlyContinue) {
Push-Location (Join-Path $RepoRoot "surfsense_backend")
uv lock
Pop-Location
Write-Host " OK surfsense_backend/uv.lock"
} else {
Write-Host " SKIP uv not found -- run 'uv lock' in surfsense_backend/ manually"
}
Write-Host "---------------------------------"
Write-Host "Done. All packages set to $Version"

69
scripts/bump-version.sh Normal file
View file

@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION_FILE="$REPO_ROOT/VERSION"
if [ ! -f "$VERSION_FILE" ]; then
echo "ERROR: VERSION file not found at $VERSION_FILE" >&2
exit 1
fi
VERSION="$(tr -d '[:space:]' < "$VERSION_FILE")"
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then
echo "ERROR: '$VERSION' is not valid semver (expected X.Y.Z)" >&2
exit 1
fi
echo "Bumping all packages to $VERSION"
echo "---------------------------------"
bump_json() {
local file="$1"
if [ ! -f "$file" ]; then
echo " SKIP $file (not found)"
return
fi
local old
old="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$file" | head -1)"
if [ "$old" = "$VERSION" ]; then
echo " OK $file ($old -- already up to date)"
else
sed -i "s/\"version\": \"$old\"/\"version\": \"$VERSION\"/" "$file"
echo " SET $file ($old -> $VERSION)"
fi
}
bump_toml() {
local file="$1"
if [ ! -f "$file" ]; then
echo " SKIP $file (not found)"
return
fi
local old
old="$(sed -n 's/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$file" | head -1)"
if [ "$old" = "$VERSION" ]; then
echo " OK $file ($old -- already up to date)"
else
sed -i "s/^version = \"$old\"/version = \"$VERSION\"/" "$file"
echo " SET $file ($old -> $VERSION)"
fi
}
bump_json "$REPO_ROOT/surfsense_web/package.json"
bump_json "$REPO_ROOT/surfsense_browser_extension/package.json"
bump_json "$REPO_ROOT/surfsense_desktop/package.json"
bump_toml "$REPO_ROOT/surfsense_backend/pyproject.toml"
echo ""
echo "Syncing lock files..."
if command -v uv &>/dev/null; then
(cd "$REPO_ROOT/surfsense_backend" && uv lock)
echo " OK surfsense_backend/uv.lock"
else
echo " SKIP uv not found -- run 'uv lock' in surfsense_backend/ manually"
fi
echo "---------------------------------"
echo "Done. All packages set to $VERSION"

View file

@ -25,7 +25,7 @@ from sqlalchemy import (
) )
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, backref, declared_attr, relationship
from app.config import config from app.config import config
@ -1086,7 +1086,9 @@ class DocumentVersion(BaseModel, TimestampMixin):
content_hash = Column(String, nullable=False) content_hash = Column(String, nullable=False)
title = Column(String, nullable=True) title = Column(String, nullable=True)
document = relationship("Document", backref="versions") document = relationship(
"Document", backref=backref("versions", passive_deletes=True)
)
class Chunk(BaseModel, TimestampMixin): class Chunk(BaseModel, TimestampMixin):

View file

@ -17,6 +17,7 @@ class ConnectorDocument(BaseModel):
metadata: dict = {} metadata: dict = {}
connector_id: int | None = None connector_id: int | None = None
created_by_id: str created_by_id: str
folder_id: int | None = None
@field_validator("title", "source_markdown", "unique_id", "created_by_id") @field_validator("title", "source_markdown", "unique_id", "created_by_id")
@classmethod @classmethod

View file

@ -268,6 +268,8 @@ class IndexingPipelineService:
): ):
existing.status = DocumentStatus.pending() existing.status = DocumentStatus.pending()
existing.updated_at = datetime.now(UTC) existing.updated_at = datetime.now(UTC)
if connector_doc.folder_id is not None:
existing.folder_id = connector_doc.folder_id
documents.append(existing) documents.append(existing)
log_document_requeued(ctx) log_document_requeued(ctx)
continue continue
@ -294,6 +296,8 @@ class IndexingPipelineService:
existing.document_metadata = connector_doc.metadata existing.document_metadata = connector_doc.metadata
existing.updated_at = datetime.now(UTC) existing.updated_at = datetime.now(UTC)
existing.status = DocumentStatus.pending() existing.status = DocumentStatus.pending()
if connector_doc.folder_id is not None:
existing.folder_id = connector_doc.folder_id
documents.append(existing) documents.append(existing)
log_document_updated(ctx) log_document_updated(ctx)
continue continue
@ -317,6 +321,7 @@ class IndexingPipelineService:
created_by_id=connector_doc.created_by_id, created_by_id=connector_doc.created_by_id,
updated_at=datetime.now(UTC), updated_at=datetime.now(UTC),
status=DocumentStatus.pending(), status=DocumentStatus.pending(),
folder_id=connector_doc.folder_id,
) )
self.session.add(document) self.session.add(document)
documents.append(document) documents.append(document)

View file

@ -1385,45 +1385,48 @@ async def restore_document_version(
} }
# ===== Local folder indexing endpoints ===== # ===== Upload-based local folder indexing endpoints =====
# These work for ALL deployment modes (cloud, self-hosted remote, self-hosted local).
# The desktop app reads files locally and uploads them here.
class FolderIndexRequest(PydanticBaseModel): class FolderMtimeCheckFile(PydanticBaseModel):
folder_path: str relative_path: str
mtime: float
class FolderMtimeCheckRequest(PydanticBaseModel):
folder_name: str folder_name: str
search_space_id: int search_space_id: int
exclude_patterns: list[str] | None = None files: list[FolderMtimeCheckFile]
file_extensions: list[str] | None = None
root_folder_id: int | None = None
enable_summary: bool = False
class FolderIndexFilesRequest(PydanticBaseModel): class FolderUnlinkRequest(PydanticBaseModel):
folder_path: str
folder_name: str folder_name: str
search_space_id: int search_space_id: int
target_file_paths: list[str]
root_folder_id: int | None = None root_folder_id: int | None = None
enable_summary: bool = False relative_paths: list[str]
@router.post("/documents/folder-index") class FolderSyncFinalizeRequest(PydanticBaseModel):
async def folder_index( folder_name: str
request: FolderIndexRequest, search_space_id: int
root_folder_id: int | None = None
all_relative_paths: list[str]
@router.post("/documents/folder-mtime-check")
async def folder_mtime_check(
request: FolderMtimeCheckRequest,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
"""Full-scan index of a local folder. Creates the root Folder row synchronously """Pre-upload optimization: check which files need uploading based on mtime.
and dispatches the heavy indexing work to a Celery task.
Returns the root_folder_id so the desktop can persist it.
"""
from app.config import config as app_config
if not app_config.is_self_hosted(): Returns the subset of relative paths where the file is new or has a
raise HTTPException( different mtime, so the client can skip reading/uploading unchanged files.
status_code=400, """
detail="Local folder indexing is only available in self-hosted mode", from app.indexing_pipeline.document_hashing import compute_identifier_hash
)
await check_permission( await check_permission(
session, session,
@ -1433,28 +1436,123 @@ async def folder_index(
"You don't have permission to create documents in this search space", "You don't have permission to create documents in this search space",
) )
watched_metadata = { uid_hashes = {}
"watched": True, for f in request.files:
"folder_path": request.folder_path, uid = f"{request.folder_name}:{f.relative_path}"
"exclude_patterns": request.exclude_patterns, uid_hash = compute_identifier_hash(
"file_extensions": request.file_extensions, DocumentType.LOCAL_FOLDER_FILE.value, uid, request.search_space_id
} )
uid_hashes[uid_hash] = f
root_folder_id = request.root_folder_id existing_docs = (
if root_folder_id: (
existing = ( await session.execute(
await session.execute(select(Folder).where(Folder.id == root_folder_id)) select(Document).where(
).scalar_one_or_none() Document.unique_identifier_hash.in_(list(uid_hashes.keys())),
if not existing: Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
root_folder_id = None )
else: )
existing.folder_metadata = watched_metadata )
await session.commit() .scalars()
.all()
)
existing_by_hash = {doc.unique_identifier_hash: doc for doc in existing_docs}
mtime_tolerance = 1.0
files_to_upload: list[str] = []
for uid_hash, file_info in uid_hashes.items():
doc = existing_by_hash.get(uid_hash)
if doc is None:
files_to_upload.append(file_info.relative_path)
continue
stored_mtime = (doc.document_metadata or {}).get("mtime")
if stored_mtime is None:
files_to_upload.append(file_info.relative_path)
continue
if abs(file_info.mtime - stored_mtime) >= mtime_tolerance:
files_to_upload.append(file_info.relative_path)
return {"files_to_upload": files_to_upload}
@router.post("/documents/folder-upload")
async def folder_upload(
files: list[UploadFile],
folder_name: str = Form(...),
search_space_id: int = Form(...),
relative_paths: str = Form(...),
root_folder_id: int | None = Form(None),
enable_summary: bool = Form(False),
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Upload files from the desktop app for folder indexing.
Files are written to temp storage and dispatched to a Celery task.
Works for all deployment modes (no is_self_hosted guard).
"""
import json
import tempfile
await check_permission(
session,
user,
search_space_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create documents in this search space",
)
if not files:
raise HTTPException(status_code=400, detail="No files provided")
try:
rel_paths: list[str] = json.loads(relative_paths)
except (json.JSONDecodeError, TypeError) as e:
raise HTTPException(
status_code=400, detail=f"Invalid relative_paths JSON: {e}"
) from e
if len(rel_paths) != len(files):
raise HTTPException(
status_code=400,
detail=f"Mismatch: {len(files)} files but {len(rel_paths)} relative_paths",
)
for file in files:
file_size = file.size or 0
if file_size > MAX_FILE_SIZE_BYTES:
raise HTTPException(
status_code=413,
detail=f"File '{file.filename}' ({file_size / (1024 * 1024):.1f} MB) "
f"exceeds the {MAX_FILE_SIZE_BYTES // (1024 * 1024)} MB per-file limit.",
)
if not root_folder_id: if not root_folder_id:
watched_metadata = {
"watched": True,
"folder_path": folder_name,
}
existing_root = (
await session.execute(
select(Folder).where(
Folder.name == folder_name,
Folder.parent_id.is_(None),
Folder.search_space_id == search_space_id,
)
)
).scalar_one_or_none()
if existing_root:
root_folder_id = existing_root.id
existing_root.folder_metadata = watched_metadata
else:
root_folder = Folder( root_folder = Folder(
name=request.folder_name, name=folder_name,
search_space_id=request.search_space_id, search_space_id=search_space_id,
created_by_id=str(user.id), created_by_id=str(user.id),
position="a0", position="a0",
folder_metadata=watched_metadata, folder_metadata=watched_metadata,
@ -1462,84 +1560,185 @@ async def folder_index(
session.add(root_folder) session.add(root_folder)
await session.flush() await session.flush()
root_folder_id = root_folder.id root_folder_id = root_folder.id
await session.commit() await session.commit()
from app.tasks.celery_tasks.document_tasks import index_local_folder_task async def _read_and_save(file: UploadFile, idx: int) -> dict:
content = await file.read()
filename = file.filename or rel_paths[idx].split("/")[-1]
index_local_folder_task.delay( def _write_temp() -> str:
search_space_id=request.search_space_id, with tempfile.NamedTemporaryFile(
delete=False, suffix=os.path.splitext(filename)[1]
) as tmp:
tmp.write(content)
return tmp.name
temp_path = await asyncio.to_thread(_write_temp)
return {
"temp_path": temp_path,
"relative_path": rel_paths[idx],
"filename": filename,
}
file_mappings = await asyncio.gather(
*(_read_and_save(f, i) for i, f in enumerate(files))
)
from app.tasks.celery_tasks.document_tasks import (
index_uploaded_folder_files_task,
)
index_uploaded_folder_files_task.delay(
search_space_id=search_space_id,
user_id=str(user.id), user_id=str(user.id),
folder_path=request.folder_path, folder_name=folder_name,
folder_name=request.folder_name,
exclude_patterns=request.exclude_patterns,
file_extensions=request.file_extensions,
root_folder_id=root_folder_id, root_folder_id=root_folder_id,
enable_summary=request.enable_summary, enable_summary=enable_summary,
file_mappings=list(file_mappings),
) )
return { return {
"message": "Folder indexing started", "message": f"Folder upload started for {len(files)} file(s)",
"status": "processing", "status": "processing",
"root_folder_id": root_folder_id, "root_folder_id": root_folder_id,
"file_count": len(files),
} }
@router.post("/documents/folder-index-files") @router.post("/documents/folder-unlink")
async def folder_index_files( async def folder_unlink(
request: FolderIndexFilesRequest, request: FolderUnlinkRequest,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
"""Index multiple files within a watched folder (batched chokidar trigger). """Handle file deletion events from the desktop watcher.
Validates that all target_file_paths are under folder_path.
Dispatches a single Celery task that processes them in parallel. For each relative path, find the matching document and delete it.
""" """
from app.config import config as app_config from app.indexing_pipeline.document_hashing import compute_identifier_hash
from app.tasks.connector_indexers.local_folder_indexer import (
if not app_config.is_self_hosted(): _cleanup_empty_folder_chain,
raise HTTPException(
status_code=400,
detail="Local folder indexing is only available in self-hosted mode",
)
if not request.target_file_paths:
raise HTTPException(
status_code=400, detail="target_file_paths must not be empty"
) )
await check_permission( await check_permission(
session, session,
user, user,
request.search_space_id, request.search_space_id,
Permission.DOCUMENTS_CREATE.value, Permission.DOCUMENTS_DELETE.value,
"You don't have permission to create documents in this search space", "You don't have permission to delete documents in this search space",
) )
from pathlib import Path deleted_count = 0
for fp in request.target_file_paths: for rel_path in request.relative_paths:
try: unique_id = f"{request.folder_name}:{rel_path}"
Path(fp).relative_to(request.folder_path) uid_hash = compute_identifier_hash(
except ValueError as err: DocumentType.LOCAL_FOLDER_FILE.value,
raise HTTPException( unique_id,
status_code=400, request.search_space_id,
detail=f"target_file_path {fp} must be inside folder_path",
) from err
from app.tasks.celery_tasks.document_tasks import index_local_folder_task
index_local_folder_task.delay(
search_space_id=request.search_space_id,
user_id=str(user.id),
folder_path=request.folder_path,
folder_name=request.folder_name,
target_file_paths=request.target_file_paths,
root_folder_id=request.root_folder_id,
enable_summary=request.enable_summary,
) )
return { existing = (
"message": f"Batch indexing started for {len(request.target_file_paths)} file(s)", await session.execute(
"status": "processing", select(Document).where(Document.unique_identifier_hash == uid_hash)
"file_count": len(request.target_file_paths), )
} ).scalar_one_or_none()
if existing:
deleted_folder_id = existing.folder_id
await session.delete(existing)
await session.flush()
if deleted_folder_id and request.root_folder_id:
await _cleanup_empty_folder_chain(
session, deleted_folder_id, request.root_folder_id
)
deleted_count += 1
await session.commit()
return {"deleted_count": deleted_count}
@router.post("/documents/folder-sync-finalize")
async def folder_sync_finalize(
request: FolderSyncFinalizeRequest,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Finalize a full folder scan by deleting orphaned documents.
The client sends the complete list of relative paths currently in the
folder. Any document in the DB for this folder that is NOT in the list
gets deleted.
"""
from app.indexing_pipeline.document_hashing import compute_identifier_hash
from app.services.folder_service import get_folder_subtree_ids
from app.tasks.connector_indexers.local_folder_indexer import (
_cleanup_empty_folders,
)
await check_permission(
session,
user,
request.search_space_id,
Permission.DOCUMENTS_DELETE.value,
"You don't have permission to delete documents in this search space",
)
if not request.root_folder_id:
return {"deleted_count": 0}
subtree_ids = await get_folder_subtree_ids(session, request.root_folder_id)
seen_hashes: set[str] = set()
for rel_path in request.all_relative_paths:
unique_id = f"{request.folder_name}:{rel_path}"
uid_hash = compute_identifier_hash(
DocumentType.LOCAL_FOLDER_FILE.value,
unique_id,
request.search_space_id,
)
seen_hashes.add(uid_hash)
all_folder_docs = (
(
await session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == request.search_space_id,
Document.folder_id.in_(subtree_ids),
)
)
)
.scalars()
.all()
)
deleted_count = 0
for doc in all_folder_docs:
if doc.unique_identifier_hash not in seen_hashes:
await session.delete(doc)
deleted_count += 1
await session.flush()
existing_dirs: set[str] = set()
for rel_path in request.all_relative_paths:
parent = str(os.path.dirname(rel_path))
if parent and parent != ".":
existing_dirs.add(parent)
folder_mapping: dict[str, int] = {"": request.root_folder_id}
await _cleanup_empty_folders(
session,
request.root_folder_id,
request.search_space_id,
existing_dirs,
folder_mapping,
subtree_ids=subtree_ids,
)
await session.commit()
return {"deleted_count": deleted_count}

View file

@ -11,7 +11,10 @@ from app.config import config
from app.services.notification_service import NotificationService from app.services.notification_service import NotificationService
from app.services.task_logging_service import TaskLoggingService from app.services.task_logging_service import TaskLoggingService
from app.tasks.celery_tasks import get_celery_session_maker from app.tasks.celery_tasks import get_celery_session_maker
from app.tasks.connector_indexers.local_folder_indexer import index_local_folder from app.tasks.connector_indexers.local_folder_indexer import (
index_local_folder,
index_uploaded_files,
)
from app.tasks.document_processors import ( from app.tasks.document_processors import (
add_extension_received_document, add_extension_received_document,
add_youtube_video_document, add_youtube_video_document,
@ -1411,3 +1414,132 @@ async def _index_local_folder_async(
heartbeat_task.cancel() heartbeat_task.cancel()
if notification_id is not None: if notification_id is not None:
_stop_heartbeat(notification_id) _stop_heartbeat(notification_id)
# ===== Upload-based folder indexing task =====
@celery_app.task(name="index_uploaded_folder_files", bind=True)
def index_uploaded_folder_files_task(
self,
search_space_id: int,
user_id: str,
folder_name: str,
root_folder_id: int,
enable_summary: bool,
file_mappings: list[dict],
):
"""Celery task to index files uploaded from the desktop app."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(
_index_uploaded_folder_files_async(
search_space_id=search_space_id,
user_id=user_id,
folder_name=folder_name,
root_folder_id=root_folder_id,
enable_summary=enable_summary,
file_mappings=file_mappings,
)
)
finally:
loop.close()
async def _index_uploaded_folder_files_async(
search_space_id: int,
user_id: str,
folder_name: str,
root_folder_id: int,
enable_summary: bool,
file_mappings: list[dict],
):
"""Run upload-based folder indexing with notification + heartbeat."""
file_count = len(file_mappings)
doc_name = f"{folder_name} ({file_count} file{'s' if file_count != 1 else ''})"
notification = None
notification_id: int | None = None
heartbeat_task = None
async with get_celery_session_maker()() as session:
try:
notification = (
await NotificationService.document_processing.notify_processing_started(
session=session,
user_id=UUID(user_id),
document_type="LOCAL_FOLDER_FILE",
document_name=doc_name,
search_space_id=search_space_id,
)
)
notification_id = notification.id
_start_heartbeat(notification_id)
heartbeat_task = asyncio.create_task(_run_heartbeat_loop(notification_id))
except Exception:
logger.warning(
"Failed to create notification for uploaded folder indexing",
exc_info=True,
)
async def _heartbeat_progress(completed_count: int) -> None:
if notification:
with contextlib.suppress(Exception):
await NotificationService.document_processing.notify_processing_progress(
session=session,
notification=notification,
stage="indexing",
stage_message=f"Syncing files ({completed_count}/{file_count})",
)
try:
_indexed, _failed, err = await index_uploaded_files(
session=session,
search_space_id=search_space_id,
user_id=user_id,
folder_name=folder_name,
root_folder_id=root_folder_id,
enable_summary=enable_summary,
file_mappings=file_mappings,
on_heartbeat_callback=_heartbeat_progress,
)
if notification:
try:
await session.refresh(notification)
if err:
await NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
error_message=err,
)
else:
await NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
)
except Exception:
logger.warning(
"Failed to update notification after uploaded folder indexing",
exc_info=True,
)
except Exception as e:
logger.exception(f"Uploaded folder indexing failed: {e}")
if notification:
try:
await session.refresh(notification)
await NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
error_message=str(e)[:200],
)
except Exception:
pass
raise
finally:
if heartbeat_task:
heartbeat_task.cancel()
if notification_id is not None:
_stop_heartbeat(notification_id)

View file

@ -14,13 +14,14 @@ no connector row is read.
""" """
import asyncio import asyncio
import contextlib
import os import os
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import ( from app.db import (
@ -178,6 +179,22 @@ def _content_hash(content: str, search_space_id: int) -> str:
return hashlib.sha256(f"{search_space_id}:{content}".encode()).hexdigest() return hashlib.sha256(f"{search_space_id}:{content}".encode()).hexdigest()
def _compute_raw_file_hash(file_path: str) -> str:
"""SHA-256 hash of the raw file bytes.
Much cheaper than ETL/OCR extraction -- only performs sequential I/O.
Used as a pre-filter to skip expensive content extraction when the
underlying file hasn't changed at all.
"""
import hashlib
h = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
async def _compute_file_content_hash( async def _compute_file_content_hash(
file_path: str, file_path: str,
filename: str, filename: str,
@ -328,6 +345,27 @@ async def _resolve_folder_for_file(
return current_parent_id return current_parent_id
async def _set_indexing_flag(session: AsyncSession, folder_id: int) -> None:
folder = await session.get(Folder, folder_id)
if folder:
meta = dict(folder.folder_metadata or {})
meta["indexing_in_progress"] = True
folder.folder_metadata = meta
await session.commit()
async def _clear_indexing_flag(session: AsyncSession, folder_id: int) -> None:
try:
folder = await session.get(Folder, folder_id)
if folder:
meta = dict(folder.folder_metadata or {})
meta.pop("indexing_in_progress", None)
folder.folder_metadata = meta
await session.commit()
except Exception:
pass
async def _cleanup_empty_folder_chain( async def _cleanup_empty_folder_chain(
session: AsyncSession, session: AsyncSession,
folder_id: int, folder_id: int,
@ -371,24 +409,21 @@ async def _cleanup_empty_folders(
search_space_id: int, search_space_id: int,
existing_dirs_on_disk: set[str], existing_dirs_on_disk: set[str],
folder_mapping: dict[str, int], folder_mapping: dict[str, int],
subtree_ids: list[int] | None = None,
) -> None: ) -> None:
"""Delete Folder rows that are empty (no docs, no children) and no longer on disk.""" """Delete Folder rows that are empty (no docs, no children) and no longer on disk."""
from sqlalchemy import delete as sa_delete from sqlalchemy import delete as sa_delete
id_to_rel: dict[int, str] = {fid: rel for rel, fid in folder_mapping.items() if rel} id_to_rel: dict[int, str] = {fid: rel for rel, fid in folder_mapping.items() if rel}
all_folders = ( query = select(Folder).where(
(
await session.execute(
select(Folder).where(
Folder.search_space_id == search_space_id, Folder.search_space_id == search_space_id,
Folder.id != root_folder_id, Folder.id != root_folder_id,
) )
) if subtree_ids is not None:
) query = query.where(Folder.id.in_(subtree_ids))
.scalars()
.all() all_folders = (await session.execute(query)).scalars().all()
)
candidates: list[Folder] = [] candidates: list[Folder] = []
for folder in all_folders: for folder in all_folders:
@ -518,6 +553,9 @@ async def index_local_folder(
# BATCH MODE (1..N files) # BATCH MODE (1..N files)
# ==================================================================== # ====================================================================
if target_file_paths: if target_file_paths:
if root_folder_id:
await _set_indexing_flag(session, root_folder_id)
try:
if len(target_file_paths) == 1: if len(target_file_paths) == 1:
indexed, skipped, err = await _index_single_file( indexed, skipped, err = await _index_single_file(
session=session, session=session,
@ -556,6 +594,9 @@ async def index_local_folder(
{"indexed": indexed, "failed": failed}, {"indexed": indexed, "failed": failed},
) )
return indexed, failed, root_folder_id, err return indexed, failed, root_folder_id, err
finally:
if root_folder_id:
await _clear_indexing_flag(session, root_folder_id)
# ==================================================================== # ====================================================================
# FULL-SCAN MODE # FULL-SCAN MODE
@ -575,6 +616,7 @@ async def index_local_folder(
exclude_patterns=exclude_patterns, exclude_patterns=exclude_patterns,
) )
await session.flush() await session.flush()
await _set_indexing_flag(session, root_folder_id)
try: try:
files = scan_folder(folder_path, file_extensions, exclude_patterns) files = scan_folder(folder_path, file_extensions, exclude_patterns)
@ -582,6 +624,7 @@ async def index_local_folder(
await task_logger.log_task_failure( await task_logger.log_task_failure(
log_entry, f"Failed to scan folder: {e}", "Scan error", {} log_entry, f"Failed to scan folder: {e}", "Scan error", {}
) )
await _clear_indexing_flag(session, root_folder_id)
return 0, 0, root_folder_id, f"Failed to scan folder: {e}" return 0, 0, root_folder_id, f"Failed to scan folder: {e}"
logger.info(f"Found {len(files)} files in folder") logger.info(f"Found {len(files)} files in folder")
@ -630,6 +673,24 @@ async def index_local_folder(
skipped_count += 1 skipped_count += 1
continue continue
raw_hash = await asyncio.to_thread(
_compute_raw_file_hash, file_path_abs
)
stored_raw_hash = (existing_document.document_metadata or {}).get(
"raw_file_hash"
)
if stored_raw_hash and stored_raw_hash == raw_hash:
meta = dict(existing_document.document_metadata or {})
meta["mtime"] = current_mtime
existing_document.document_metadata = meta
if not DocumentStatus.is_state(
existing_document.status, DocumentStatus.READY
):
existing_document.status = DocumentStatus.ready()
skipped_count += 1
continue
try: try:
estimated_pages = await _check_page_limit_or_skip( estimated_pages = await _check_page_limit_or_skip(
page_limit_service, user_id, file_path_abs page_limit_service, user_id, file_path_abs
@ -653,6 +714,7 @@ async def index_local_folder(
if existing_document.content_hash == content_hash: if existing_document.content_hash == content_hash:
meta = dict(existing_document.document_metadata or {}) meta = dict(existing_document.document_metadata or {})
meta["mtime"] = current_mtime meta["mtime"] = current_mtime
meta["raw_file_hash"] = raw_hash
existing_document.document_metadata = meta existing_document.document_metadata = meta
if not DocumentStatus.is_state( if not DocumentStatus.is_state(
existing_document.status, DocumentStatus.READY existing_document.status, DocumentStatus.READY
@ -687,6 +749,10 @@ async def index_local_folder(
skipped_count += 1 skipped_count += 1
continue continue
raw_hash = await asyncio.to_thread(
_compute_raw_file_hash, file_path_abs
)
doc = _build_connector_doc( doc = _build_connector_doc(
title=file_info["name"], title=file_info["name"],
content=content, content=content,
@ -702,6 +768,7 @@ async def index_local_folder(
"mtime": file_info["modified_at"].timestamp(), "mtime": file_info["modified_at"].timestamp(),
"estimated_pages": estimated_pages, "estimated_pages": estimated_pages,
"content_length": len(content), "content_length": len(content),
"raw_file_hash": raw_hash,
} }
except Exception as e: except Exception as e:
@ -753,29 +820,16 @@ async def index_local_folder(
compute_unique_identifier_hash, compute_unique_identifier_hash,
) )
pipeline = IndexingPipelineService(session) for cd in connector_docs:
doc_map = {compute_unique_identifier_hash(cd): cd for cd in connector_docs}
documents = await pipeline.prepare_for_indexing(connector_docs)
# Assign folder_id immediately so docs appear in the correct
# folder while still pending/processing (visible via Zero sync).
for document in documents:
cd = doc_map.get(document.unique_identifier_hash)
if cd is None:
continue
rel_path = (cd.metadata or {}).get("file_path", "") rel_path = (cd.metadata or {}).get("file_path", "")
parent_dir = str(Path(rel_path).parent) if rel_path else "" parent_dir = str(Path(rel_path).parent) if rel_path else ""
if parent_dir == ".": if parent_dir == ".":
parent_dir = "" parent_dir = ""
document.folder_id = folder_mapping.get( cd.folder_id = folder_mapping.get(parent_dir, folder_mapping.get(""))
parent_dir, folder_mapping.get("")
) pipeline = IndexingPipelineService(session)
try: doc_map = {compute_unique_identifier_hash(cd): cd for cd in connector_docs}
await session.commit() documents = await pipeline.prepare_for_indexing(connector_docs)
except IntegrityError:
await session.rollback()
for document in documents:
await session.refresh(document)
llm = await get_user_long_context_llm(session, user_id, search_space_id) llm = await get_user_long_context_llm(session, user_id, search_space_id)
@ -795,6 +849,7 @@ async def index_local_folder(
doc_meta = dict(result.document_metadata or {}) doc_meta = dict(result.document_metadata or {})
doc_meta["mtime"] = mtime_info.get("mtime") doc_meta["mtime"] = mtime_info.get("mtime")
doc_meta["raw_file_hash"] = mtime_info.get("raw_file_hash")
result.document_metadata = doc_meta result.document_metadata = doc_meta
est = mtime_info.get("estimated_pages", 1) est = mtime_info.get("estimated_pages", 1)
@ -823,8 +878,16 @@ async def index_local_folder(
root_fid = folder_mapping.get("") root_fid = folder_mapping.get("")
if root_fid: if root_fid:
from app.services.folder_service import get_folder_subtree_ids
subtree_ids = await get_folder_subtree_ids(session, root_fid)
await _cleanup_empty_folders( await _cleanup_empty_folders(
session, root_fid, search_space_id, existing_dirs, folder_mapping session,
root_fid,
search_space_id,
existing_dirs,
folder_mapping,
subtree_ids=subtree_ids,
) )
try: try:
@ -851,6 +914,7 @@ async def index_local_folder(
}, },
) )
await _clear_indexing_flag(session, root_folder_id)
return indexed_count, skipped_count, root_folder_id, warning_message return indexed_count, skipped_count, root_folder_id, warning_message
except SQLAlchemyError as e: except SQLAlchemyError as e:
@ -859,6 +923,8 @@ async def index_local_folder(
await task_logger.log_task_failure( await task_logger.log_task_failure(
log_entry, f"DB error: {e}", "Database error", {} log_entry, f"DB error: {e}", "Database error", {}
) )
if root_folder_id:
await _clear_indexing_flag(session, root_folder_id)
return 0, 0, root_folder_id, f"Database error: {e}" return 0, 0, root_folder_id, f"Database error: {e}"
except Exception as e: except Exception as e:
@ -866,6 +932,8 @@ async def index_local_folder(
await task_logger.log_task_failure( await task_logger.log_task_failure(
log_entry, f"Error: {e}", "Unexpected error", {} log_entry, f"Error: {e}", "Unexpected error", {}
) )
if root_folder_id:
await _clear_indexing_flag(session, root_folder_id)
return 0, 0, root_folder_id, str(e) return 0, 0, root_folder_id, str(e)
@ -988,6 +1056,22 @@ async def _index_single_file(
DocumentType.LOCAL_FOLDER_FILE.value, unique_id, search_space_id DocumentType.LOCAL_FOLDER_FILE.value, unique_id, search_space_id
) )
raw_hash = await asyncio.to_thread(_compute_raw_file_hash, str(full_path))
existing = await check_document_by_unique_identifier(session, uid_hash)
if existing:
stored_raw_hash = (existing.document_metadata or {}).get("raw_file_hash")
if stored_raw_hash and stored_raw_hash == raw_hash:
mtime = full_path.stat().st_mtime
meta = dict(existing.document_metadata or {})
meta["mtime"] = mtime
existing.document_metadata = meta
if not DocumentStatus.is_state(existing.status, DocumentStatus.READY):
existing.status = DocumentStatus.ready()
await session.commit()
return 0, 0, None
page_limit_service = PageLimitService(session) page_limit_service = PageLimitService(session)
try: try:
estimated_pages = await _check_page_limit_or_skip( estimated_pages = await _check_page_limit_or_skip(
@ -1006,13 +1090,12 @@ async def _index_single_file(
if not content.strip(): if not content.strip():
return 0, 1, None return 0, 1, None
existing = await check_document_by_unique_identifier(session, uid_hash)
if existing: if existing:
if existing.content_hash == content_hash: if existing.content_hash == content_hash:
mtime = full_path.stat().st_mtime mtime = full_path.stat().st_mtime
meta = dict(existing.document_metadata or {}) meta = dict(existing.document_metadata or {})
meta["mtime"] = mtime meta["mtime"] = mtime
meta["raw_file_hash"] = raw_hash
existing.document_metadata = meta existing.document_metadata = meta
await session.commit() await session.commit()
return 0, 1, None return 0, 1, None
@ -1031,6 +1114,11 @@ async def _index_single_file(
enable_summary=enable_summary, enable_summary=enable_summary,
) )
if root_folder_id:
connector_doc.folder_id = await _resolve_folder_for_file(
session, rel_path, root_folder_id, search_space_id, user_id
)
pipeline = IndexingPipelineService(session) pipeline = IndexingPipelineService(session)
llm = await get_user_long_context_llm(session, user_id, search_space_id) llm = await get_user_long_context_llm(session, user_id, search_space_id)
documents = await pipeline.prepare_for_indexing([connector_doc]) documents = await pipeline.prepare_for_indexing([connector_doc])
@ -1040,21 +1128,12 @@ async def _index_single_file(
db_doc = documents[0] db_doc = documents[0]
if root_folder_id:
try:
db_doc.folder_id = await _resolve_folder_for_file(
session, rel_path, root_folder_id, search_space_id, user_id
)
await session.commit()
except IntegrityError:
await session.rollback()
await session.refresh(db_doc)
await pipeline.index(db_doc, connector_doc, llm) await pipeline.index(db_doc, connector_doc, llm)
await session.refresh(db_doc) await session.refresh(db_doc)
doc_meta = dict(db_doc.document_metadata or {}) doc_meta = dict(db_doc.document_metadata or {})
doc_meta["mtime"] = mtime doc_meta["mtime"] = mtime
doc_meta["raw_file_hash"] = raw_hash
db_doc.document_metadata = doc_meta db_doc.document_metadata = doc_meta
await session.commit() await session.commit()
@ -1081,3 +1160,305 @@ async def _index_single_file(
logger.exception(f"Error indexing single file {target_file_path}: {e}") logger.exception(f"Error indexing single file {target_file_path}: {e}")
await session.rollback() await session.rollback()
return 0, 0, str(e) return 0, 0, str(e)
# ========================================================================
# Upload-based folder indexing (works for all deployment modes)
# ========================================================================
async def _mirror_folder_structure_from_paths(
session: AsyncSession,
relative_paths: list[str],
folder_name: str,
search_space_id: int,
user_id: str,
root_folder_id: int | None = None,
) -> tuple[dict[str, int], int]:
"""Create DB Folder rows from a list of relative file paths.
Unlike ``_mirror_folder_structure`` this does not walk the filesystem;
it derives the directory tree from the paths provided by the client.
Returns (mapping, root_folder_id) where mapping is
relative_dir_path -> folder_id. The empty-string key maps to root.
"""
dir_set: set[str] = set()
for rp in relative_paths:
parent = str(Path(rp).parent)
if parent == ".":
continue
parts = Path(parent).parts
for i in range(len(parts)):
dir_set.add(str(Path(*parts[: i + 1])))
subdirs = sorted(dir_set, key=lambda p: p.count(os.sep))
mapping: dict[str, int] = {}
if root_folder_id:
existing = (
await session.execute(select(Folder).where(Folder.id == root_folder_id))
).scalar_one_or_none()
if existing:
mapping[""] = existing.id
else:
root_folder_id = None
if not root_folder_id:
root_folder = Folder(
name=folder_name,
search_space_id=search_space_id,
created_by_id=user_id,
position="a0",
)
session.add(root_folder)
await session.flush()
mapping[""] = root_folder.id
root_folder_id = root_folder.id
for rel_dir in subdirs:
dir_parts = Path(rel_dir).parts
dir_name = dir_parts[-1]
parent_rel = str(Path(*dir_parts[:-1])) if len(dir_parts) > 1 else ""
parent_id = mapping.get(parent_rel, mapping[""])
existing_folder = (
await session.execute(
select(Folder).where(
Folder.name == dir_name,
Folder.parent_id == parent_id,
Folder.search_space_id == search_space_id,
)
)
).scalar_one_or_none()
if existing_folder:
mapping[rel_dir] = existing_folder.id
else:
new_folder = Folder(
name=dir_name,
parent_id=parent_id,
search_space_id=search_space_id,
created_by_id=user_id,
position="a0",
)
session.add(new_folder)
await session.flush()
mapping[rel_dir] = new_folder.id
await session.flush()
return mapping, root_folder_id
UPLOAD_BATCH_CONCURRENCY = 5
async def index_uploaded_files(
session: AsyncSession,
search_space_id: int,
user_id: str,
folder_name: str,
root_folder_id: int,
enable_summary: bool,
file_mappings: list[dict],
on_heartbeat_callback: HeartbeatCallbackType | None = None,
) -> tuple[int, int, str | None]:
"""Index files uploaded from the desktop app via temp paths.
Each entry in *file_mappings* is ``{temp_path, relative_path, filename}``.
This function mirrors the folder structure from the provided relative
paths, then indexes each file exactly like ``_index_single_file`` but
reads from the temp path. Temp files are cleaned up after processing.
Returns ``(indexed_count, failed_count, error_summary_or_none)``.
"""
task_logger = TaskLoggingService(session, search_space_id)
log_entry = await task_logger.log_task_start(
task_name="local_folder_indexing",
source="uploaded_folder_indexing",
message=f"Indexing {len(file_mappings)} uploaded file(s) for {folder_name}",
metadata={"file_count": len(file_mappings)},
)
try:
all_relative_paths = [m["relative_path"] for m in file_mappings]
_folder_mapping, root_folder_id = await _mirror_folder_structure_from_paths(
session=session,
relative_paths=all_relative_paths,
folder_name=folder_name,
search_space_id=search_space_id,
user_id=user_id,
root_folder_id=root_folder_id,
)
await session.flush()
await _set_indexing_flag(session, root_folder_id)
page_limit_service = PageLimitService(session)
pipeline = IndexingPipelineService(session)
llm = await get_user_long_context_llm(session, user_id, search_space_id)
indexed_count = 0
failed_count = 0
errors: list[str] = []
for i, mapping in enumerate(file_mappings):
temp_path = mapping["temp_path"]
relative_path = mapping["relative_path"]
filename = mapping["filename"]
try:
unique_id = f"{folder_name}:{relative_path}"
uid_hash = compute_identifier_hash(
DocumentType.LOCAL_FOLDER_FILE.value,
unique_id,
search_space_id,
)
raw_hash = await asyncio.to_thread(_compute_raw_file_hash, temp_path)
existing = await check_document_by_unique_identifier(session, uid_hash)
if existing:
stored_raw_hash = (existing.document_metadata or {}).get(
"raw_file_hash"
)
if stored_raw_hash and stored_raw_hash == raw_hash:
meta = dict(existing.document_metadata or {})
meta["mtime"] = datetime.now(UTC).timestamp()
existing.document_metadata = meta
if not DocumentStatus.is_state(
existing.status, DocumentStatus.READY
):
existing.status = DocumentStatus.ready()
await session.commit()
continue
try:
estimated_pages = await _check_page_limit_or_skip(
page_limit_service, user_id, temp_path
)
except PageLimitExceededError:
logger.warning(f"Page limit exceeded, skipping: {relative_path}")
failed_count += 1
continue
try:
content, content_hash = await _compute_file_content_hash(
temp_path, filename, search_space_id
)
except Exception as e:
logger.warning(f"Could not read {relative_path}: {e}")
failed_count += 1
errors.append(f"{filename}: {e}")
continue
if not content.strip():
failed_count += 1
continue
if existing:
if existing.content_hash == content_hash:
meta = dict(existing.document_metadata or {})
meta["mtime"] = datetime.now(UTC).timestamp()
meta["raw_file_hash"] = raw_hash
existing.document_metadata = meta
if not DocumentStatus.is_state(
existing.status, DocumentStatus.READY
):
existing.status = DocumentStatus.ready()
await session.commit()
continue
await create_version_snapshot(session, existing)
connector_doc = _build_connector_doc(
title=filename,
content=content,
relative_path=relative_path,
folder_name=folder_name,
search_space_id=search_space_id,
user_id=user_id,
enable_summary=enable_summary,
)
connector_doc.folder_id = await _resolve_folder_for_file(
session,
relative_path,
root_folder_id,
search_space_id,
user_id,
)
documents = await pipeline.prepare_for_indexing([connector_doc])
if not documents:
failed_count += 1
continue
db_doc = documents[0]
await pipeline.index(db_doc, connector_doc, llm)
await session.refresh(db_doc)
doc_meta = dict(db_doc.document_metadata or {})
doc_meta["mtime"] = datetime.now(UTC).timestamp()
doc_meta["raw_file_hash"] = raw_hash
db_doc.document_metadata = doc_meta
await session.commit()
if DocumentStatus.is_state(db_doc.status, DocumentStatus.READY):
indexed_count += 1
final_pages = _compute_final_pages(
page_limit_service, estimated_pages, len(content)
)
await page_limit_service.update_page_usage(
user_id, final_pages, allow_exceed=True
)
else:
failed_count += 1
if on_heartbeat_callback and (i + 1) % 5 == 0:
await on_heartbeat_callback(i + 1)
except Exception as e:
logger.exception(f"Error indexing uploaded file {relative_path}: {e}")
await session.rollback()
failed_count += 1
errors.append(f"{filename}: {e}")
finally:
with contextlib.suppress(OSError):
os.unlink(temp_path)
error_summary = None
if errors:
error_summary = f"{failed_count} file(s) failed: " + "; ".join(errors[:5])
if len(errors) > 5:
error_summary += f" ... and {len(errors) - 5} more"
await task_logger.log_task_success(
log_entry,
f"Upload indexing complete: {indexed_count} indexed, {failed_count} failed",
{"indexed": indexed_count, "failed": failed_count},
)
return indexed_count, failed_count, error_summary
except SQLAlchemyError as e:
logger.exception(f"Database error during uploaded file indexing: {e}")
await session.rollback()
await task_logger.log_task_failure(
log_entry, f"DB error: {e}", "Database error", {}
)
return 0, 0, f"Database error: {e}"
except Exception as e:
logger.exception(f"Error during uploaded file indexing: {e}")
await task_logger.log_task_failure(
log_entry, f"Error: {e}", "Unexpected error", {}
)
return 0, 0, str(e)
finally:
await _clear_indexing_flag(session, root_folder_id)

View file

@ -1,6 +1,6 @@
[project] [project]
name = "surf-new-backend" name = "surf-new-backend"
version = "0.0.14" version = "0.0.15"
description = "SurfSense Backend" description = "SurfSense Backend"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [

View file

@ -1,4 +1,4 @@
"""Integration tests for local folder indexer — Tier 3 (I1-I5), Tier 4 (F1-F7), Tier 5 (P1), Tier 6 (B1-B2).""" """Integration tests for local folder indexer — Tier 3 (I1-I5), Tier 4 (F1-F7), Tier 5 (P1), Tier 6 (B1-B2), Tier 7 (IP1-IP3)."""
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -1178,3 +1178,131 @@ class TestPageLimits:
await db_session.refresh(db_user) await db_session.refresh(db_user)
assert db_user.pages_used > 0 assert db_user.pages_used > 0
assert db_user.pages_used <= db_user.pages_limit + 1 assert db_user.pages_used <= db_user.pages_limit + 1
# ====================================================================
# Tier 7: Indexing Progress Flag (IP1-IP3)
# ====================================================================
class TestIndexingProgressFlag:
@pytest.mark.usefixtures(*UNIFIED_FIXTURES)
async def test_ip1_full_scan_clears_flag(
self,
db_session: AsyncSession,
db_user: User,
db_search_space: SearchSpace,
tmp_path: Path,
):
"""IP1: Full-scan mode clears indexing_in_progress after completion."""
from app.tasks.connector_indexers.local_folder_indexer import index_local_folder
(tmp_path / "note.md").write_text("# Hello\n\nContent.")
_, _, root_folder_id, _ = await index_local_folder(
session=db_session,
search_space_id=db_search_space.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
)
assert root_folder_id is not None
root_folder = (
await db_session.execute(select(Folder).where(Folder.id == root_folder_id))
).scalar_one()
meta = root_folder.folder_metadata or {}
assert "indexing_in_progress" not in meta
@pytest.mark.usefixtures(*UNIFIED_FIXTURES)
async def test_ip2_single_file_clears_flag(
self,
db_session: AsyncSession,
db_user: User,
db_search_space: SearchSpace,
tmp_path: Path,
):
"""IP2: Single-file (Chokidar) mode clears indexing_in_progress after completion."""
from app.tasks.connector_indexers.local_folder_indexer import index_local_folder
(tmp_path / "root.md").write_text("root")
_, _, root_folder_id, _ = await index_local_folder(
session=db_session,
search_space_id=db_search_space.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
)
(tmp_path / "new.md").write_text("new file content")
await index_local_folder(
session=db_session,
search_space_id=db_search_space.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
target_file_paths=[str(tmp_path / "new.md")],
root_folder_id=root_folder_id,
)
root_folder = (
await db_session.execute(select(Folder).where(Folder.id == root_folder_id))
).scalar_one()
meta = root_folder.folder_metadata or {}
assert "indexing_in_progress" not in meta
@pytest.mark.usefixtures(*UNIFIED_FIXTURES)
async def test_ip3_flag_set_during_indexing(
self,
db_session: AsyncSession,
db_user: User,
db_search_space: SearchSpace,
tmp_path: Path,
):
"""IP3: indexing_in_progress is True on the root folder while indexing is running."""
from app.tasks.connector_indexers.local_folder_indexer import index_local_folder
(tmp_path / "note.md").write_text("# Check flag\n\nDuring indexing.")
from app.indexing_pipeline.indexing_pipeline_service import (
IndexingPipelineService,
)
original_index = IndexingPipelineService.index
flag_observed = []
async def patched_index(self_pipe, document, connector_doc, llm):
folder = (
await db_session.execute(
select(Folder).where(
Folder.search_space_id == db_search_space.id,
Folder.parent_id.is_(None),
)
)
).scalar_one_or_none()
if folder:
meta = folder.folder_metadata or {}
flag_observed.append(meta.get("indexing_in_progress", False))
return await original_index(self_pipe, document, connector_doc, llm)
IndexingPipelineService.index = patched_index
try:
_, _, root_folder_id, _ = await index_local_folder(
session=db_session,
search_space_id=db_search_space.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
)
finally:
IndexingPipelineService.index = original_index
assert len(flag_observed) > 0, "index() should have been called at least once"
assert all(flag_observed), "indexing_in_progress should be True during indexing"
root_folder = (
await db_session.execute(select(Folder).where(Folder.id == root_folder_id))
).scalar_one()
meta = root_folder.folder_metadata or {}
assert "indexing_in_progress" not in meta

8455
surfsense_backend/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{ {
"name": "surfsense_browser_extension", "name": "surfsense_browser_extension",
"displayName": "Surfsense Browser Extension", "displayName": "Surfsense Browser Extension",
"version": "0.0.14", "version": "0.0.15",
"description": "Extension to collect Browsing History for SurfSense.", "description": "Extension to collect Browsing History for SurfSense.",
"author": "https://github.com/MODSetter", "author": "https://github.com/MODSetter",
"engines": { "engines": {

View file

@ -1,6 +1,6 @@
{ {
"name": "surfsense-desktop", "name": "surfsense-desktop",
"version": "0.1.0", "version": "0.0.15",
"description": "SurfSense Desktop App", "description": "SurfSense Desktop App",
"main": "dist/main.js", "main": "dist/main.js",
"scripts": { "scripts": {

View file

@ -30,6 +30,8 @@ export const IPC_CHANNELS = {
FOLDER_SYNC_RENDERER_READY: 'folder-sync:renderer-ready', FOLDER_SYNC_RENDERER_READY: 'folder-sync:renderer-ready',
FOLDER_SYNC_GET_PENDING_EVENTS: 'folder-sync:get-pending-events', FOLDER_SYNC_GET_PENDING_EVENTS: 'folder-sync:get-pending-events',
FOLDER_SYNC_ACK_EVENTS: 'folder-sync:ack-events', FOLDER_SYNC_ACK_EVENTS: 'folder-sync:ack-events',
FOLDER_SYNC_LIST_FILES: 'folder-sync:list-files',
FOLDER_SYNC_SEED_MTIMES: 'folder-sync:seed-mtimes',
BROWSE_FILES: 'browse:files', BROWSE_FILES: 'browse:files',
READ_LOCAL_FILES: 'browse:read-local-files', READ_LOCAL_FILES: 'browse:read-local-files',
// Auth token sync across windows // Auth token sync across windows

View file

@ -19,6 +19,9 @@ import {
markRendererReady, markRendererReady,
browseFiles, browseFiles,
readLocalFiles, readLocalFiles,
listFolderFiles,
seedFolderMtimes,
type WatchedFolderConfig,
} from '../modules/folder-watcher'; } from '../modules/folder-watcher';
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts'; import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space'; import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space';
@ -91,6 +94,16 @@ export function registerIpcHandlers(): void {
acknowledgeFileEvents(eventIds) acknowledgeFileEvents(eventIds)
); );
ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_LIST_FILES, (_event, config: WatchedFolderConfig) =>
listFolderFiles(config)
);
ipcMain.handle(
IPC_CHANNELS.FOLDER_SYNC_SEED_MTIMES,
(_event, folderPath: string, mtimes: Record<string, number>) =>
seedFolderMtimes(folderPath, mtimes),
);
ipcMain.handle(IPC_CHANNELS.BROWSE_FILES, () => browseFiles()); ipcMain.handle(IPC_CHANNELS.BROWSE_FILES, () => browseFiles());
ipcMain.handle(IPC_CHANNELS.READ_LOCAL_FILES, (_event, paths: string[]) => ipcMain.handle(IPC_CHANNELS.READ_LOCAL_FILES, (_event, paths: string[]) =>

View file

@ -1,16 +1,25 @@
import { app, dialog } from 'electron'; import { app, dialog } from 'electron';
import { autoUpdater } from 'electron-updater';
const SEMVER_RE = /^\d+\.\d+\.\d+/;
export function setupAutoUpdater(): void { export function setupAutoUpdater(): void {
if (!app.isPackaged) return; if (!app.isPackaged) return;
const version = app.getVersion();
if (!SEMVER_RE.test(version)) {
console.log(`Auto-updater: skipping — "${version}" is not valid semver`);
return;
}
const { autoUpdater } = require('electron-updater');
autoUpdater.autoDownload = true; autoUpdater.autoDownload = true;
autoUpdater.on('update-available', (info) => { autoUpdater.on('update-available', (info: { version: string }) => {
console.log(`Update available: ${info.version}`); console.log(`Update available: ${info.version}`);
}); });
autoUpdater.on('update-downloaded', (info) => { autoUpdater.on('update-downloaded', (info: { version: string }) => {
console.log(`Update downloaded: ${info.version}`); console.log(`Update downloaded: ${info.version}`);
dialog.showMessageBox({ dialog.showMessageBox({
type: 'info', type: 'info',
@ -18,14 +27,14 @@ export function setupAutoUpdater(): void {
defaultId: 0, defaultId: 0,
title: 'Update Ready', title: 'Update Ready',
message: `Version ${info.version} has been downloaded. Restart to apply the update.`, message: `Version ${info.version} has been downloaded. Restart to apply the update.`,
}).then(({ response }) => { }).then(({ response }: { response: number }) => {
if (response === 0) { if (response === 0) {
autoUpdater.quitAndInstall(); autoUpdater.quitAndInstall();
} }
}); });
}); });
autoUpdater.on('error', (err) => { autoUpdater.on('error', (err: Error) => {
console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]); console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]);
}); });

View file

@ -188,6 +188,31 @@ function walkFolderMtimes(config: WatchedFolderConfig): MtimeMap {
return result; return result;
} }
export interface FolderFileEntry {
relativePath: string;
fullPath: string;
size: number;
mtimeMs: number;
}
export function listFolderFiles(config: WatchedFolderConfig): FolderFileEntry[] {
const root = config.path;
const mtimeMap = walkFolderMtimes(config);
const entries: FolderFileEntry[] = [];
for (const [relativePath, mtimeMs] of Object.entries(mtimeMap)) {
const fullPath = path.join(root, relativePath);
try {
const stat = fs.statSync(fullPath);
entries.push({ relativePath, fullPath, size: stat.size, mtimeMs });
} catch {
// File may have been removed between walk and stat
}
}
return entries;
}
function getMainWindow(): BrowserWindow | null { function getMainWindow(): BrowserWindow | null {
const windows = BrowserWindow.getAllWindows(); const windows = BrowserWindow.getAllWindows();
return windows.length > 0 ? windows[0] : null; return windows.length > 0 ? windows[0] : null;
@ -424,14 +449,30 @@ export async function acknowledgeFileEvents(eventIds: string[]): Promise<{ ackno
const ackSet = new Set(eventIds); const ackSet = new Set(eventIds);
let acknowledged = 0; let acknowledged = 0;
const foldersToUpdate = new Set<string>();
for (const [key, event] of outboxEvents.entries()) { for (const [key, event] of outboxEvents.entries()) {
if (ackSet.has(event.id)) { if (ackSet.has(event.id)) {
if (event.action !== 'unlink') {
const map = mtimeMaps.get(event.folderPath);
if (map) {
try {
map[event.relativePath] = fs.statSync(event.fullPath).mtimeMs;
foldersToUpdate.add(event.folderPath);
} catch {
// File may have been removed
}
}
}
outboxEvents.delete(key); outboxEvents.delete(key);
acknowledged += 1; acknowledged += 1;
} }
} }
for (const fp of foldersToUpdate) {
persistMtimeMap(fp);
}
if (acknowledged > 0) { if (acknowledged > 0) {
persistOutbox(); persistOutbox();
} }
@ -439,6 +480,17 @@ export async function acknowledgeFileEvents(eventIds: string[]): Promise<{ ackno
return { acknowledged }; return { acknowledged };
} }
export async function seedFolderMtimes(
folderPath: string,
mtimes: Record<string, number>,
): Promise<void> {
const ms = await getMtimeStore();
const existing: MtimeMap = ms.get(folderPath) ?? {};
const merged = { ...existing, ...mtimes };
mtimeMaps.set(folderPath, merged);
ms.set(folderPath, merged);
}
export async function pauseWatcher(): Promise<void> { export async function pauseWatcher(): Promise<void> {
for (const [, entry] of watchers) { for (const [, entry] of watchers) {
if (entry.watcher) { if (entry.watcher) {

View file

@ -64,6 +64,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
signalRendererReady: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_RENDERER_READY), signalRendererReady: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_RENDERER_READY),
getPendingFileEvents: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_GET_PENDING_EVENTS), getPendingFileEvents: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_GET_PENDING_EVENTS),
acknowledgeFileEvents: (eventIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_ACK_EVENTS, eventIds), acknowledgeFileEvents: (eventIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_ACK_EVENTS, eventIds),
listFolderFiles: (config: any) => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_LIST_FILES, config),
seedFolderMtimes: (folderPath: string, mtimes: Record<string, number>) =>
ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_SEED_MTIMES, folderPath, mtimes),
// Browse files via native dialog // Browse files via native dialog
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES), browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),

View file

@ -156,6 +156,7 @@ export function LocalLoginForm() {
<input <input
id="email" id="email"
type="email" type="email"
autoComplete="username"
required required
placeholder="you@example.com" placeholder="you@example.com"
value={username} value={username}
@ -177,6 +178,7 @@ export function LocalLoginForm() {
<input <input
id="password" id="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
autoComplete="current-password"
required required
placeholder="Enter your password" placeholder="Enter your password"
value={password} value={password}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -16,6 +16,7 @@ import { LocalLoginForm } from "./LocalLoginForm";
function LoginContent() { function LoginContent() {
const t = useTranslations("auth"); const t = useTranslations("auth");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const router = useRouter();
const [authType, setAuthType] = useState<string | null>(null); const [authType, setAuthType] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null); const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null);
@ -79,7 +80,7 @@ function LoginContent() {
if (shouldRetry(error)) { if (shouldRetry(error)) {
toastOptions.action = { toastOptions.action = {
label: "Retry", label: "Retry",
onClick: () => window.location.reload(), onClick: () => router.refresh(),
}; };
} }

View file

@ -235,6 +235,7 @@ export default function RegisterPage() {
<input <input
id="email" id="email"
type="email" type="email"
autoComplete="email"
required required
placeholder="you@example.com" placeholder="you@example.com"
value={email} value={email}
@ -255,6 +256,7 @@ export default function RegisterPage() {
<input <input
id="password" id="password"
type="password" type="password"
autoComplete="new-password"
required required
placeholder="Enter your password" placeholder="Enter your password"
value={password} value={password}
@ -278,6 +280,7 @@ export default function RegisterPage() {
<input <input
id="confirmPassword" id="confirmPassword"
type="password" type="password"
autoComplete="new-password"
required required
placeholder="Confirm your password" placeholder="Confirm your password"
value={confirmPassword} value={confirmPassword}

View file

@ -44,7 +44,7 @@ import {
import { AnimatePresence, motion, type Variants } from "motion/react"; import { AnimatePresence, motion, type Variants } from "motion/react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import React, { useCallback, useContext, useId, useMemo, useRef, useState } from "react"; import React, { useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
createLogMutationAtom, createLogMutationAtom,
@ -95,6 +95,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { CreateLogRequest, Log, UpdateLogRequest } from "@/contracts/types/log.types"; import type { CreateLogRequest, Log, UpdateLogRequest } from "@/contracts/types/log.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { type LogLevel, type LogStatus, useLogs, useLogsSummary } from "@/hooks/use-logs"; import { type LogLevel, type LogStatus, useLogs, useLogsSummary } from "@/hooks/use-logs";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -706,6 +707,15 @@ function LogsFilters({
id: string; id: string;
}) { }) {
const t = useTranslations("logs"); const t = useTranslations("logs");
const [filterInput, setFilterInput] = useState(
(table.getColumn("message")?.getFilterValue() ?? "") as string
);
const debouncedFilter = useDebouncedValue(filterInput, 300);
useEffect(() => {
table.getColumn("message")?.setFilterValue(debouncedFilter || undefined);
}, [debouncedFilter, table]);
return ( return (
<motion.div <motion.div
className="flex flex-wrap items-center justify-start gap-3 w-full" className="flex flex-wrap items-center justify-start gap-3 w-full"
@ -718,24 +728,22 @@ function LogsFilters({
<motion.div className="relative w-full sm:w-auto" variants={fadeInScale}> <motion.div className="relative w-full sm:w-auto" variants={fadeInScale}>
<Input <Input
ref={inputRef} ref={inputRef}
className={cn( className={cn("peer w-full sm:min-w-60 ps-9", Boolean(filterInput) && "pe-9")}
"peer w-full sm:min-w-60 ps-9", value={filterInput}
Boolean(table.getColumn("message")?.getFilterValue()) && "pe-9" onChange={(e) => setFilterInput(e.target.value)}
)}
value={(table.getColumn("message")?.getFilterValue() ?? "") as string}
onChange={(e) => table.getColumn("message")?.setFilterValue(e.target.value)}
placeholder={t("filter_by_message")} placeholder={t("filter_by_message")}
type="text" type="text"
/> />
<div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80"> <div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80">
<ListFilter size={16} strokeWidth={2} /> <ListFilter size={16} strokeWidth={2} />
</div> </div>
{Boolean(table.getColumn("message")?.getFilterValue()) && ( {Boolean(filterInput) && (
<Button <Button
className="absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-lg text-muted-foreground/80 hover:text-foreground" className="absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-lg text-muted-foreground/80 hover:text-foreground"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => { onClick={() => {
setFilterInput("");
table.getColumn("message")?.setFilterValue(""); table.getColumn("message")?.setFilterValue("");
inputRef.current?.focus(); inputRef.current?.focus();
}} }}

View file

@ -1,22 +1,10 @@
"use client";
import { motion } from "motion/react";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() { export default function Loading() {
return ( return (
<motion.div <div className="w-full px-6 py-4 space-y-6 min-h-[calc(100vh-64px)] animate-in fade-in duration-300">
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full px-6 py-4 space-y-6 min-h-[calc(100vh-64px)]"
>
{/* Summary Dashboard Skeleton */} {/* Summary Dashboard Skeleton */}
<motion.div <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
{[...Array(4)].map((_, i) => ( {[...Array(4)].map((_, i) => (
<div key={i} className="rounded-lg border p-4"> <div key={i} className="rounded-lg border p-4">
<div className="flex flex-row items-center justify-between space-y-0 pb-2"> <div className="flex flex-row items-center justify-between space-y-0 pb-2">
@ -29,44 +17,29 @@ export default function Loading() {
</div> </div>
</div> </div>
))} ))}
</motion.div> </div>
{/* Header Section Skeleton */} {/* Header Section Skeleton */}
<motion.div <div className="flex items-center justify-between">
className="flex items-center justify-between"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-8 w-48" /> <Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" /> <Skeleton className="h-4 w-64" />
</div> </div>
<Skeleton className="h-9 w-24" /> <Skeleton className="h-9 w-24" />
</motion.div> </div>
{/* Filters Skeleton */} {/* Filters Skeleton */}
<motion.div <div className="flex flex-wrap items-center justify-start gap-3 w-full">
className="flex flex-wrap items-center justify-start gap-3 w-full"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="flex items-center gap-3 flex-wrap w-full sm:w-auto"> <div className="flex items-center gap-3 flex-wrap w-full sm:w-auto">
<Skeleton className="h-9 w-full sm:w-60" /> <Skeleton className="h-9 w-full sm:w-60" />
<Skeleton className="h-9 w-24" /> <Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-24" /> <Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-20" /> <Skeleton className="h-9 w-20" />
</div> </div>
</motion.div> </div>
{/* Table Skeleton */} {/* Table Skeleton */}
<motion.div <div className="rounded-md border overflow-hidden">
className="rounded-md border overflow-hidden"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
{/* Table Header */} {/* Table Header */}
<div className="border-b bg-muted/50 px-4 py-3 flex items-center gap-4"> <div className="border-b bg-muted/50 px-4 py-3 flex items-center gap-4">
<Skeleton className="h-4 w-4" /> <Skeleton className="h-4 w-4" />
@ -99,27 +72,18 @@ export default function Loading() {
<Skeleton className="h-8 w-8" /> <Skeleton className="h-8 w-8" />
</div> </div>
))} ))}
</motion.div> </div>
{/* Pagination Skeleton */} {/* Pagination Skeleton */}
<div className="flex items-center justify-between gap-8 mt-4"> <div className="flex items-center justify-between gap-8 mt-4">
<motion.div <div className="flex items-center gap-3">
className="flex items-center gap-3"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
>
<Skeleton className="h-4 w-20 max-sm:sr-only" /> <Skeleton className="h-4 w-20 max-sm:sr-only" />
<Skeleton className="h-9 w-16" /> <Skeleton className="h-9 w-16" />
</motion.div> </div>
<motion.div <div className="flex grow justify-end">
className="flex grow justify-end"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Skeleton className="h-4 w-40" /> <Skeleton className="h-4 w-40" />
</motion.div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Skeleton className="h-9 w-9" /> <Skeleton className="h-9 w-9" />
@ -128,6 +92,6 @@ export default function Loading() {
<Skeleton className="h-9 w-9" /> <Skeleton className="h-9 w-9" />
</div> </div>
</div> </div>
</motion.div> </div>
); );
} }

View file

@ -8,6 +8,7 @@ import {
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -37,9 +38,6 @@ import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.ato
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps"; import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
import { MobileEditorPanel } from "@/components/editor-panel/editor-panel";
import { MobileHitlEditPanel } from "@/components/hitl-edit-panel/hitl-edit-panel";
import { MobileReportPanel } from "@/components/report-panel/report-panel";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesSync } from "@/hooks/use-messages-sync"; import { useMessagesSync } from "@/hooks/use-messages-sync";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
@ -79,6 +77,28 @@ import {
} from "@/lib/posthog/events"; } from "@/lib/posthog/events";
import Loading from "../loading"; import Loading from "../loading";
const MobileEditorPanel = dynamic(
() =>
import("@/components/editor-panel/editor-panel").then((m) => ({
default: m.MobileEditorPanel,
})),
{ ssr: false }
);
const MobileHitlEditPanel = dynamic(
() =>
import("@/components/hitl-edit-panel/hitl-edit-panel").then((m) => ({
default: m.MobileHitlEditPanel,
})),
{ ssr: false }
);
const MobileReportPanel = dynamic(
() =>
import("@/components/report-panel/report-panel").then((m) => ({
default: m.MobileReportPanel,
})),
{ ssr: false }
);
/** /**
* After a tool produces output, mark any previously-decided interrupt tool * After a tool produces output, mark any previously-decided interrupt tool
* calls as completed so the ApprovalCard can transition from shimmer to done. * calls as completed so the ApprovalCard can transition from shimmer to done.

View file

@ -93,6 +93,7 @@ export function ProfileContent() {
<Input <Input
id="display-name" id="display-name"
type="text" type="text"
autoComplete="name"
placeholder={user?.email?.split("@")[0]} placeholder={user?.email?.split("@")[0]}
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}

View file

@ -346,15 +346,11 @@ export default function SuggestionPage() {
needsTruncation && !isExpanded ? option.slice(0, TRUNCATE_LENGTH) + "…" : option; needsTruncation && !isExpanded ? option.slice(0, TRUNCATE_LENGTH) + "…" : option;
return ( return (
<div <button
type="button"
key={index} key={index}
role="button"
tabIndex={0}
className="suggestion-option" className="suggestion-option"
onClick={() => handleSelect(option)} onClick={() => handleSelect(option)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSelect(option);
}}
> >
<span className="option-number">{index + 1}</span> <span className="option-number">{index + 1}</span>
<span className="option-text">{displayText}</span> <span className="option-text">{displayText}</span>
@ -370,7 +366,7 @@ export default function SuggestionPage() {
{isExpanded ? "less" : "more"} {isExpanded ? "less" : "more"}
</button> </button>
)} )}
</div> </button>
); );
})} })}
</div> </div>

View file

@ -2,6 +2,7 @@
import { BadgeCheck, LogOut } from "lucide-react"; import { BadgeCheck, LogOut } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -27,6 +28,7 @@ export function UserDropdown({
avatar: string; avatar: string;
}; };
}) { }) {
const router = useRouter();
const [isLoggingOut, setIsLoggingOut] = useState(false); const [isLoggingOut, setIsLoggingOut] = useState(false);
const handleLogout = async () => { const handleLogout = async () => {
@ -38,15 +40,13 @@ export function UserDropdown({
await logout(); await logout();
if (typeof window !== "undefined") { router.push(getLoginPath());
window.location.href = getLoginPath(); router.refresh();
}
} catch (error) { } catch (error) {
console.error("Error during logout:", error); console.error("Error during logout:", error);
await logout(); await logout();
if (typeof window !== "undefined") { router.push(getLoginPath());
window.location.href = getLoginPath(); router.refresh();
}
} }
}; };
@ -60,7 +60,7 @@ export function UserDropdown({
</Avatar> </Avatar>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-44 md:w-56" align="end" forceMount> <DropdownMenuContent className="w-44 md:w-56" align="end">
<DropdownMenuLabel className="font-normal p-2 md:p-3"> <DropdownMenuLabel className="font-normal p-2 md:p-3">
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<p className="text-xs md:text-sm font-medium leading-none">{user.name}</p> <p className="text-xs md:text-sm font-medium leading-none">{user.name}</p>

View file

@ -32,52 +32,6 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
import type { SerializableCitation } from "@/components/tool-ui/citation"; import type { SerializableCitation } from "@/components/tool-ui/citation";
import {
CreateConfluencePageToolUI,
DeleteConfluencePageToolUI,
UpdateConfluencePageToolUI,
} from "@/components/tool-ui/confluence";
import { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "@/components/tool-ui/dropbox";
import { GenerateImageToolUI } from "@/components/tool-ui/generate-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
import {
CreateGmailDraftToolUI,
SendGmailEmailToolUI,
TrashGmailEmailToolUI,
UpdateGmailDraftToolUI,
} from "@/components/tool-ui/gmail";
import {
CreateCalendarEventToolUI,
DeleteCalendarEventToolUI,
UpdateCalendarEventToolUI,
} from "@/components/tool-ui/google-calendar";
import {
CreateGoogleDriveFileToolUI,
DeleteGoogleDriveFileToolUI,
} from "@/components/tool-ui/google-drive";
import {
CreateJiraIssueToolUI,
DeleteJiraIssueToolUI,
UpdateJiraIssueToolUI,
} from "@/components/tool-ui/jira";
import {
CreateLinearIssueToolUI,
DeleteLinearIssueToolUI,
UpdateLinearIssueToolUI,
} from "@/components/tool-ui/linear";
import {
CreateNotionPageToolUI,
DeleteNotionPageToolUI,
UpdateNotionPageToolUI,
} from "@/components/tool-ui/notion";
import { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "@/components/tool-ui/onedrive";
import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute";
import {
openSafeNavigationHref,
resolveSafeNavigationHref,
} from "@/components/tool-ui/shared/media";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { import {
Drawer, Drawer,
DrawerContent, DrawerContent,
@ -95,7 +49,21 @@ const IS_QUICK_ASSIST_WINDOW =
typeof window !== "undefined" && typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("quickAssist") === "true"; new URLSearchParams(window.location.search).get("quickAssist") === "true";
// Dynamically import video presentation tool to avoid loading Babel and Remotion in main bundle // Dynamically import tool UI components to avoid loading them in main bundle
const GenerateReportToolUI = dynamic(
() =>
import("@/components/tool-ui/generate-report").then((m) => ({
default: m.GenerateReportToolUI,
})),
{ ssr: false }
);
const GeneratePodcastToolUI = dynamic(
() =>
import("@/components/tool-ui/generate-podcast").then((m) => ({
default: m.GeneratePodcastToolUI,
})),
{ ssr: false }
);
const GenerateVideoPresentationToolUI = dynamic( const GenerateVideoPresentationToolUI = dynamic(
() => () =>
import("@/components/tool-ui/video-presentation").then((m) => ({ import("@/components/tool-ui/video-presentation").then((m) => ({
@ -103,6 +71,154 @@ const GenerateVideoPresentationToolUI = dynamic(
})), })),
{ ssr: false } { ssr: false }
); );
const GenerateImageToolUI = dynamic(
() =>
import("@/components/tool-ui/generate-image").then((m) => ({ default: m.GenerateImageToolUI })),
{ ssr: false }
);
const SaveMemoryToolUI = dynamic(
() => import("@/components/tool-ui/user-memory").then((m) => ({ default: m.SaveMemoryToolUI })),
{ ssr: false }
);
const RecallMemoryToolUI = dynamic(
() => import("@/components/tool-ui/user-memory").then((m) => ({ default: m.RecallMemoryToolUI })),
{ ssr: false }
);
const SandboxExecuteToolUI = dynamic(
() =>
import("@/components/tool-ui/sandbox-execute").then((m) => ({
default: m.SandboxExecuteToolUI,
})),
{ ssr: false }
);
const CreateNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.CreateNotionPageToolUI })),
{ ssr: false }
);
const UpdateNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.UpdateNotionPageToolUI })),
{ ssr: false }
);
const DeleteNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.DeleteNotionPageToolUI })),
{ ssr: false }
);
const CreateLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.CreateLinearIssueToolUI })),
{ ssr: false }
);
const UpdateLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.UpdateLinearIssueToolUI })),
{ ssr: false }
);
const DeleteLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.DeleteLinearIssueToolUI })),
{ ssr: false }
);
const CreateGoogleDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/google-drive").then((m) => ({
default: m.CreateGoogleDriveFileToolUI,
})),
{ ssr: false }
);
const DeleteGoogleDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/google-drive").then((m) => ({
default: m.DeleteGoogleDriveFileToolUI,
})),
{ ssr: false }
);
const CreateOneDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/onedrive").then((m) => ({ default: m.CreateOneDriveFileToolUI })),
{ ssr: false }
);
const DeleteOneDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/onedrive").then((m) => ({ default: m.DeleteOneDriveFileToolUI })),
{ ssr: false }
);
const CreateDropboxFileToolUI = dynamic(
() =>
import("@/components/tool-ui/dropbox").then((m) => ({ default: m.CreateDropboxFileToolUI })),
{ ssr: false }
);
const DeleteDropboxFileToolUI = dynamic(
() =>
import("@/components/tool-ui/dropbox").then((m) => ({ default: m.DeleteDropboxFileToolUI })),
{ ssr: false }
);
const CreateCalendarEventToolUI = dynamic(
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.CreateCalendarEventToolUI,
})),
{ ssr: false }
);
const UpdateCalendarEventToolUI = dynamic(
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.UpdateCalendarEventToolUI,
})),
{ ssr: false }
);
const DeleteCalendarEventToolUI = dynamic(
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.DeleteCalendarEventToolUI,
})),
{ ssr: false }
);
const CreateGmailDraftToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.CreateGmailDraftToolUI })),
{ ssr: false }
);
const UpdateGmailDraftToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.UpdateGmailDraftToolUI })),
{ ssr: false }
);
const SendGmailEmailToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.SendGmailEmailToolUI })),
{ ssr: false }
);
const TrashGmailEmailToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.TrashGmailEmailToolUI })),
{ ssr: false }
);
const CreateJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.CreateJiraIssueToolUI })),
{ ssr: false }
);
const UpdateJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.UpdateJiraIssueToolUI })),
{ ssr: false }
);
const DeleteJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.DeleteJiraIssueToolUI })),
{ ssr: false }
);
const CreateConfluencePageToolUI = dynamic(
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.CreateConfluencePageToolUI,
})),
{ ssr: false }
);
const UpdateConfluencePageToolUI = dynamic(
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.UpdateConfluencePageToolUI,
})),
{ ssr: false }
);
const DeleteConfluencePageToolUI = dynamic(
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.DeleteConfluencePageToolUI,
})),
{ ssr: false }
);
function extractDomain(url: string): string | undefined { function extractDomain(url: string): string | undefined {
try { try {

View file

@ -66,6 +66,7 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-500" />
<input <input
type="text" type="text"
autoComplete="off"
placeholder="Search" placeholder="Search"
className={cn( className={cn(
"w-full bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 focus:bg-slate-400/10 dark:focus:bg-white/10 border border-border rounded-xl pl-9 py-2 text-sm transition-all outline-none placeholder:text-muted-foreground/50", "w-full bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 focus:bg-slate-400/10 dark:focus:bg-white/10 border border-border rounded-xl pl-9 py-2 text-sm transition-all outline-none placeholder:text-muted-foreground/50",

View file

@ -1,14 +1,5 @@
import dynamic from "next/dynamic";
import type { FC } from "react"; import type { FC } from "react";
import { BaiduSearchApiConnectForm } from "./components/baidu-search-api-connect-form";
import { BookStackConnectForm } from "./components/bookstack-connect-form";
import { CirclebackConnectForm } from "./components/circleback-connect-form";
import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-form";
import { GithubConnectForm } from "./components/github-connect-form";
import { LinkupApiConnectForm } from "./components/linkup-api-connect-form";
import { LumaConnectForm } from "./components/luma-connect-form";
import { MCPConnectForm } from "./components/mcp-connect-form";
import { ObsidianConnectForm } from "./components/obsidian-connect-form";
import { TavilyApiConnectForm } from "./components/tavily-api-connect-form";
export interface ConnectFormProps { export interface ConnectFormProps {
onSubmit: (data: { onSubmit: (data: {
@ -33,32 +24,53 @@ export interface ConnectFormProps {
export type ConnectFormComponent = FC<ConnectFormProps>; export type ConnectFormComponent = FC<ConnectFormProps>;
const formMap: Record<string, () => Promise<{ default: FC<ConnectFormProps> }>> = {
TAVILY_API: () =>
import("./components/tavily-api-connect-form").then((m) => ({
default: m.TavilyApiConnectForm,
})),
LINKUP_API: () =>
import("./components/linkup-api-connect-form").then((m) => ({
default: m.LinkupApiConnectForm,
})),
BAIDU_SEARCH_API: () =>
import("./components/baidu-search-api-connect-form").then((m) => ({
default: m.BaiduSearchApiConnectForm,
})),
ELASTICSEARCH_CONNECTOR: () =>
import("./components/elasticsearch-connect-form").then((m) => ({
default: m.ElasticsearchConnectForm,
})),
BOOKSTACK_CONNECTOR: () =>
import("./components/bookstack-connect-form").then((m) => ({
default: m.BookStackConnectForm,
})),
GITHUB_CONNECTOR: () =>
import("./components/github-connect-form").then((m) => ({ default: m.GithubConnectForm })),
LUMA_CONNECTOR: () =>
import("./components/luma-connect-form").then((m) => ({ default: m.LumaConnectForm })),
CIRCLEBACK_CONNECTOR: () =>
import("./components/circleback-connect-form").then((m) => ({
default: m.CirclebackConnectForm,
})),
MCP_CONNECTOR: () =>
import("./components/mcp-connect-form").then((m) => ({ default: m.MCPConnectForm })),
OBSIDIAN_CONNECTOR: () =>
import("./components/obsidian-connect-form").then((m) => ({ default: m.ObsidianConnectForm })),
};
const componentCache = new Map<string, ConnectFormComponent>();
/** /**
* Factory function to get the appropriate connect form component for a connector type * Factory function to get the appropriate connect form component for a connector type
*/ */
export function getConnectFormComponent(connectorType: string): ConnectFormComponent | null { export function getConnectFormComponent(connectorType: string): ConnectFormComponent | null {
switch (connectorType) { const loader = formMap[connectorType];
case "TAVILY_API": if (!loader) return null;
return TavilyApiConnectForm;
case "LINKUP_API": if (!componentCache.has(connectorType)) {
return LinkupApiConnectForm; componentCache.set(connectorType, dynamic(loader, { ssr: false }));
case "BAIDU_SEARCH_API":
return BaiduSearchApiConnectForm;
case "ELASTICSEARCH_CONNECTOR":
return ElasticsearchConnectForm;
case "BOOKSTACK_CONNECTOR":
return BookStackConnectForm;
case "GITHUB_CONNECTOR":
return GithubConnectForm;
case "LUMA_CONNECTOR":
return LumaConnectForm;
case "CIRCLEBACK_CONNECTOR":
return CirclebackConnectForm;
case "MCP_CONNECTOR":
return MCPConnectForm;
case "OBSIDIAN_CONNECTOR":
return ObsidianConnectForm;
default:
return null;
} }
return componentCache.get(connectorType)!;
} }

View file

@ -1,30 +1,8 @@
"use client"; "use client";
import dynamic from "next/dynamic";
import type { FC } from "react"; import type { FC } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { BaiduSearchApiConfig } from "./components/baidu-search-api-config";
import { BookStackConfig } from "./components/bookstack-config";
import { CirclebackConfig } from "./components/circleback-config";
import { ClickUpConfig } from "./components/clickup-config";
import { ComposioCalendarConfig } from "./components/composio-calendar-config";
import { ComposioDriveConfig } from "./components/composio-drive-config";
import { ComposioGmailConfig } from "./components/composio-gmail-config";
import { ConfluenceConfig } from "./components/confluence-config";
import { DiscordConfig } from "./components/discord-config";
import { DropboxConfig } from "./components/dropbox-config";
import { ElasticsearchConfig } from "./components/elasticsearch-config";
import { GithubConfig } from "./components/github-config";
import { GoogleDriveConfig } from "./components/google-drive-config";
import { JiraConfig } from "./components/jira-config";
import { LinkupApiConfig } from "./components/linkup-api-config";
import { LumaConfig } from "./components/luma-config";
import { MCPConfig } from "./components/mcp-config";
import { ObsidianConfig } from "./components/obsidian-config";
import { OneDriveConfig } from "./components/onedrive-config";
import { SlackConfig } from "./components/slack-config";
import { TavilyApiConfig } from "./components/tavily-api-config";
import { TeamsConfig } from "./components/teams-config";
import { WebcrawlerConfig } from "./components/webcrawler-config";
export interface ConnectorConfigProps { export interface ConnectorConfigProps {
connector: SearchSourceConnector; connector: SearchSourceConnector;
@ -35,61 +13,70 @@ export interface ConnectorConfigProps {
export type ConnectorConfigComponent = FC<ConnectorConfigProps>; export type ConnectorConfigComponent = FC<ConnectorConfigProps>;
const configMap: Record<string, () => Promise<{ default: FC<ConnectorConfigProps> }>> = {
GOOGLE_DRIVE_CONNECTOR: () =>
import("./components/google-drive-config").then((m) => ({ default: m.GoogleDriveConfig })),
TAVILY_API: () =>
import("./components/tavily-api-config").then((m) => ({ default: m.TavilyApiConfig })),
LINKUP_API: () =>
import("./components/linkup-api-config").then((m) => ({ default: m.LinkupApiConfig })),
BAIDU_SEARCH_API: () =>
import("./components/baidu-search-api-config").then((m) => ({
default: m.BaiduSearchApiConfig,
})),
WEBCRAWLER_CONNECTOR: () =>
import("./components/webcrawler-config").then((m) => ({ default: m.WebcrawlerConfig })),
ELASTICSEARCH_CONNECTOR: () =>
import("./components/elasticsearch-config").then((m) => ({ default: m.ElasticsearchConfig })),
SLACK_CONNECTOR: () =>
import("./components/slack-config").then((m) => ({ default: m.SlackConfig })),
DISCORD_CONNECTOR: () =>
import("./components/discord-config").then((m) => ({ default: m.DiscordConfig })),
TEAMS_CONNECTOR: () =>
import("./components/teams-config").then((m) => ({ default: m.TeamsConfig })),
DROPBOX_CONNECTOR: () =>
import("./components/dropbox-config").then((m) => ({ default: m.DropboxConfig })),
ONEDRIVE_CONNECTOR: () =>
import("./components/onedrive-config").then((m) => ({ default: m.OneDriveConfig })),
CONFLUENCE_CONNECTOR: () =>
import("./components/confluence-config").then((m) => ({ default: m.ConfluenceConfig })),
BOOKSTACK_CONNECTOR: () =>
import("./components/bookstack-config").then((m) => ({ default: m.BookStackConfig })),
GITHUB_CONNECTOR: () =>
import("./components/github-config").then((m) => ({ default: m.GithubConfig })),
JIRA_CONNECTOR: () => import("./components/jira-config").then((m) => ({ default: m.JiraConfig })),
CLICKUP_CONNECTOR: () =>
import("./components/clickup-config").then((m) => ({ default: m.ClickUpConfig })),
LUMA_CONNECTOR: () => import("./components/luma-config").then((m) => ({ default: m.LumaConfig })),
CIRCLEBACK_CONNECTOR: () =>
import("./components/circleback-config").then((m) => ({ default: m.CirclebackConfig })),
MCP_CONNECTOR: () => import("./components/mcp-config").then((m) => ({ default: m.MCPConfig })),
OBSIDIAN_CONNECTOR: () =>
import("./components/obsidian-config").then((m) => ({ default: m.ObsidianConfig })),
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: () =>
import("./components/composio-drive-config").then((m) => ({ default: m.ComposioDriveConfig })),
COMPOSIO_GMAIL_CONNECTOR: () =>
import("./components/composio-gmail-config").then((m) => ({ default: m.ComposioGmailConfig })),
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: () =>
import("./components/composio-calendar-config").then((m) => ({
default: m.ComposioCalendarConfig,
})),
};
const componentCache = new Map<string, ConnectorConfigComponent>();
/** /**
* Factory function to get the appropriate config component for a connector type * Factory function to get the appropriate config component for a connector type
*/ */
export function getConnectorConfigComponent( export function getConnectorConfigComponent(
connectorType: string connectorType: string
): ConnectorConfigComponent | null { ): ConnectorConfigComponent | null {
switch (connectorType) { const loader = configMap[connectorType];
case "GOOGLE_DRIVE_CONNECTOR": if (!loader) return null;
return GoogleDriveConfig;
case "TAVILY_API": if (!componentCache.has(connectorType)) {
return TavilyApiConfig; componentCache.set(connectorType, dynamic(loader, { ssr: false }));
case "LINKUP_API":
return LinkupApiConfig;
case "BAIDU_SEARCH_API":
return BaiduSearchApiConfig;
case "WEBCRAWLER_CONNECTOR":
return WebcrawlerConfig;
case "ELASTICSEARCH_CONNECTOR":
return ElasticsearchConfig;
case "SLACK_CONNECTOR":
return SlackConfig;
case "DISCORD_CONNECTOR":
return DiscordConfig;
case "TEAMS_CONNECTOR":
return TeamsConfig;
case "DROPBOX_CONNECTOR":
return DropboxConfig;
case "ONEDRIVE_CONNECTOR":
return OneDriveConfig;
case "CONFLUENCE_CONNECTOR":
return ConfluenceConfig;
case "BOOKSTACK_CONNECTOR":
return BookStackConfig;
case "GITHUB_CONNECTOR":
return GithubConfig;
case "JIRA_CONNECTOR":
return JiraConfig;
case "CLICKUP_CONNECTOR":
return ClickUpConfig;
case "LUMA_CONNECTOR":
return LumaConfig;
case "CIRCLEBACK_CONNECTOR":
return CirclebackConfig;
case "MCP_CONNECTOR":
return MCPConfig;
case "OBSIDIAN_CONNECTOR":
return ObsidianConfig;
case "COMPOSIO_GOOGLE_DRIVE_CONNECTOR":
return ComposioDriveConfig;
case "COMPOSIO_GMAIL_CONNECTOR":
return ComposioGmailConfig;
case "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR":
return ComposioCalendarConfig;
// OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
default:
return null;
} }
return componentCache.get(connectorType)!;
} }

View file

@ -302,12 +302,12 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Document/Files Connectors */} {/* File Storage Integrations */}
{hasDocumentFileConnectors && ( {hasDocumentFileConnectors && (
<section> <section>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground"> <h3 className="text-sm font-semibold text-muted-foreground">
Document/Files Connectors File Storage Integrations
</h3> </h3>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">

View file

@ -1,5 +1,3 @@
"use client";
/** /**
* Maps SearchSourceConnectorType to DocumentType for fetching document counts * Maps SearchSourceConnectorType to DocumentType for fetching document counts
* *

View file

@ -20,7 +20,13 @@ import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.
import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab"; import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
// Context for opening the dialog from anywhere // Context for opening the dialog from anywhere
interface DocumentUploadDialogContextType { interface DocumentUploadDialogContextType {
@ -127,17 +133,15 @@ const DocumentUploadPopupContent: FC<{
onEscapeKeyDown={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}
className="select-none max-w-2xl w-[95vw] sm:w-[640px] h-[min(440px,75dvh)] sm:h-[min(520px,80vh)] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-6 [&>button]:top-5 sm:[&>button]:top-8 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-foreground/10 [&>button]:z-[100] [&>button>svg]:size-4 sm:[&>button>svg]:size-5" className="select-none max-w-2xl w-[95vw] sm:w-[640px] h-[min(440px,75dvh)] sm:h-[min(520px,80vh)] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-6 [&>button]:top-5 sm:[&>button]:top-8 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-foreground/10 [&>button]:z-[100] [&>button>svg]:size-4 sm:[&>button>svg]:size-5"
> >
<DialogTitle className="sr-only">Upload Document</DialogTitle>
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain"> <div className="flex-1 min-h-0 overflow-y-auto overscroll-contain">
<div className="sticky top-0 z-20 bg-muted px-4 sm:px-6 pt-6 sm:pt-8 pb-10"> <DialogHeader className="sticky top-0 z-20 bg-muted px-4 sm:px-6 pt-6 sm:pt-8 pb-10">
<div className="flex items-center gap-2 mb-1 pr-8 sm:pr-0"> <DialogTitle className="text-xl sm:text-3xl font-semibold tracking-tight pr-8 sm:pr-0">
<h2 className="text-xl sm:text-3xl font-semibold tracking-tight">Upload Documents</h2> Upload Documents
</div> </DialogTitle>
<p className="text-xs sm:text-base text-muted-foreground/80 line-clamp-1"> <DialogDescription className="text-xs sm:text-base text-muted-foreground/80 line-clamp-1">
Upload and sync your documents to your search space Upload and sync your documents to your search space
</p> </DialogDescription>
</div> </DialogHeader>
<div className="px-4 sm:px-6 pb-4 sm:pb-6"> <div className="px-4 sm:px-6 pb-4 sm:pb-6">
{!isLoading && !hasDocumentSummaryLLM ? ( {!isLoading && !hasDocumentSummaryLLM ? (

View file

@ -28,7 +28,7 @@ import {
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import Image from "next/image"; import Image from "next/image";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import {
agentToolsAtom, agentToolsAtom,
@ -338,11 +338,13 @@ const Composer: FC = () => {
const [showPromptPicker, setShowPromptPicker] = useState(false); const [showPromptPicker, setShowPromptPicker] = useState(false);
const [mentionQuery, setMentionQuery] = useState(""); const [mentionQuery, setMentionQuery] = useState("");
const [actionQuery, setActionQuery] = useState(""); const [actionQuery, setActionQuery] = useState("");
const [containerPos, setContainerPos] = useState({ bottom: "200px", left: "50%", top: "auto" });
const editorRef = useRef<InlineMentionEditorRef>(null); const editorRef = useRef<InlineMentionEditorRef>(null);
const editorContainerRef = useRef<HTMLDivElement>(null); const editorContainerRef = useRef<HTMLDivElement>(null);
const composerBoxRef = useRef<HTMLDivElement>(null); const composerBoxRef = useRef<HTMLDivElement>(null);
const documentPickerRef = useRef<DocumentMentionPickerRef>(null); const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const promptPickerRef = useRef<PromptPickerRef>(null); const promptPickerRef = useRef<PromptPickerRef>(null);
const viewportRef = useRef<Element | null>(null);
const { search_space_id, chat_id } = useParams(); const { search_space_id, chat_id } = useParams();
const aui = useAui(); const aui = useAui();
const threadViewportStore = useThreadViewportStore(); const threadViewportStore = useThreadViewportStore();
@ -355,6 +357,36 @@ const Composer: FC = () => {
}; };
}, []); }, []);
// Store viewport element reference on mount
useEffect(() => {
viewportRef.current = document.querySelector(".aui-thread-viewport");
}, []);
// Compute picker positions using ResizeObserver to avoid layout reads during render
useLayoutEffect(() => {
if (!editorContainerRef.current) return;
const updatePosition = () => {
if (!editorContainerRef.current) return;
const rect = editorContainerRef.current.getBoundingClientRect();
const composerRect = composerBoxRef.current?.getBoundingClientRect();
setContainerPos({
bottom: `${window.innerHeight - rect.top + 8}px`,
left: `${rect.left}px`,
top: composerRect ? `${composerRect.bottom + 8}px` : "auto",
});
};
updatePosition();
const ro = new ResizeObserver(updatePosition);
ro.observe(editorContainerRef.current);
if (composerBoxRef.current) {
ro.observe(composerBoxRef.current);
}
return () => ro.disconnect();
}, []);
const electronAPI = useElectronAPI(); const electronAPI = useElectronAPI();
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>(); const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
const clipboardLoadedRef = useRef(false); const clipboardLoadedRef = useRef(false);
@ -572,7 +604,7 @@ const Composer: FC = () => {
if (isThreadRunning || isBlockedByOtherUser) return; if (isThreadRunning || isBlockedByOtherUser) return;
if (showDocumentPopover) return; if (showDocumentPopover) return;
const viewportEl = document.querySelector(".aui-thread-viewport"); const viewportEl = viewportRef.current;
const heightBefore = viewportEl?.scrollHeight ?? 0; const heightBefore = viewportEl?.scrollHeight ?? 0;
aui.composer().send(); aui.composer().send();
@ -599,7 +631,7 @@ const Composer: FC = () => {
const pollAndScroll = () => { const pollAndScroll = () => {
if (cancelled) return; if (cancelled) return;
const el = document.querySelector(".aui-thread-viewport"); const el = viewportRef.current;
if (el) { if (el) {
const h = el.scrollHeight; const h = el.scrollHeight;
if (h !== lastHeight) { if (h !== lastHeight) {
@ -723,12 +755,8 @@ const Composer: FC = () => {
initialSelectedDocuments={mentionedDocuments} initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery} externalSearch={mentionQuery}
containerStyle={{ containerStyle={{
bottom: editorContainerRef.current bottom: containerPos.bottom,
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px` left: containerPos.left,
: "200px",
left: editorContainerRef.current
? `${editorContainerRef.current.getBoundingClientRect().left}px`
: "50%",
}} }}
/>, />,
document.body document.body
@ -746,16 +774,10 @@ const Composer: FC = () => {
externalSearch={actionQuery} externalSearch={actionQuery}
containerStyle={{ containerStyle={{
position: "fixed", position: "fixed",
...(clipboardInitialText && composerBoxRef.current ...(clipboardInitialText
? { top: `${composerBoxRef.current.getBoundingClientRect().bottom + 8}px` } ? { top: containerPos.top }
: { : { bottom: containerPos.bottom }),
bottom: editorContainerRef.current left: containerPos.left,
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
: "200px",
}),
left: editorContainerRef.current
? `${editorContainerRef.current.getBoundingClientRect().left}px`
: "50%",
zIndex: 50, zIndex: 50,
}} }}
/>, />,
@ -1151,7 +1173,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
aria-pressed={isWebSearchEnabled} aria-pressed={isWebSearchEnabled}
onClick={() => toggleTool("web_search")} onClick={() => toggleTool("web_search")}
className={cn( className={cn(
"rounded-full transition-all flex items-center gap-1 px-2 py-1 border h-8 select-none", "rounded-full transition-[background-color,border-color,color] flex items-center gap-1 px-2 py-1 border h-8 select-none",
isWebSearchEnabled isWebSearchEnabled
? "bg-sky-500/15 border-sky-500/60 text-sky-500" ? "bg-sky-500/15 border-sky-500/60 text-sky-500"
: "bg-transparent border-transparent text-muted-foreground hover:text-foreground" : "bg-transparent border-transparent text-muted-foreground hover:text-foreground"

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { Slottable } from "@radix-ui/react-slot"; import { Slottable } from "@radix-ui/react-slot";
import { type ComponentPropsWithRef, forwardRef, type ReactNode } from "react"; import { type ComponentPropsWithRef, forwardRef, type ReactNode, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
@ -17,9 +17,13 @@ export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButton
({ children, tooltip, side = "bottom", className, disableTooltip, ...rest }, ref) => { ({ children, tooltip, side = "bottom", className, disableTooltip, ...rest }, ref) => {
const isTouchDevice = useMediaQuery("(pointer: coarse)"); const isTouchDevice = useMediaQuery("(pointer: coarse)");
const suppressTooltip = disableTooltip || isTouchDevice; const suppressTooltip = disableTooltip || isTouchDevice;
const [tooltipOpen, setTooltipOpen] = useState(false);
return ( return (
<Tooltip open={suppressTooltip ? false : undefined}> <Tooltip
open={suppressTooltip ? false : tooltipOpen}
onOpenChange={suppressTooltip ? undefined : setTooltipOpen}
>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"

View file

@ -49,6 +49,7 @@ export interface FolderDisplay {
position: string; position: string;
parentId: number | null; parentId: number | null;
searchSpaceId: number; searchSpaceId: number;
metadata?: Record<string, unknown> | null;
} }
interface FolderNodeProps { interface FolderNodeProps {
@ -354,7 +355,7 @@ export const FolderNode = React.memo(function FolderNode({
className="hidden sm:inline-flex h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" className="hidden sm:inline-flex h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<MoreHorizontal className="h-3.5 w-3.5" /> <MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40"> <DropdownMenuContent align="end" className="w-40">

View file

@ -168,6 +168,12 @@ export function FolderTreeView({
return states; return states;
}, [folders, docsByFolder, foldersByParent, mentionedDocIds]); }, [folders, docsByFolder, foldersByParent, mentionedDocIds]);
const folderMap = useMemo(() => {
const map: Record<number, FolderDisplay> = {};
for (const f of folders) map[f.id] = f;
return map;
}, [folders]);
const folderProcessingStates = useMemo(() => { const folderProcessingStates = useMemo(() => {
const states: Record<number, "idle" | "processing" | "failed"> = {}; const states: Record<number, "idle" | "processing" | "failed"> = {};
@ -178,6 +184,11 @@ export function FolderTreeView({
); );
let hasFailed = directDocs.some((d) => d.status?.state === "failed"); let hasFailed = directDocs.some((d) => d.status?.state === "failed");
const folder = folderMap[folderId];
if (folder?.metadata?.indexing_in_progress) {
hasProcessing = true;
}
for (const child of foldersByParent[folderId] ?? []) { for (const child of foldersByParent[folderId] ?? []) {
const sub = compute(child.id); const sub = compute(child.id);
hasProcessing = hasProcessing || sub.hasProcessing; hasProcessing = hasProcessing || sub.hasProcessing;
@ -195,7 +206,7 @@ export function FolderTreeView({
if (states[f.id] === undefined) compute(f.id); if (states[f.id] === undefined) compute(f.id);
} }
return states; return states;
}, [folders, docsByFolder, foldersByParent]); }, [folders, docsByFolder, foldersByParent, folderMap]);
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] { function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
const key = parentId ?? "root"; const key = parentId ?? "root";
@ -283,7 +294,7 @@ export function FolderTreeView({
if (treeNodes.length === 0 && folders.length === 0 && documents.length === 0) { if (treeNodes.length === 0 && folders.length === 0 && documents.length === 0) {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center gap-1 px-4 py-12 text-muted-foreground"> <div className="flex flex-1 flex-col items-center justify-center gap-1 px-4 py-12 text-muted-foreground select-none">
<p className="text-sm font-medium">No documents found</p> <p className="text-sm font-medium">No documents found</p>
<p className="text-xs text-muted-foreground/70"> <p className="text-xs text-muted-foreground/70">
Use the upload button or connect a source above Use the upload button or connect a source above

View file

@ -59,13 +59,15 @@ const TAB_ITEMS = [
}, },
{ {
title: "Extreme Assist", title: "Extreme Assist",
description: "Get inline writing suggestions powered by your knowledge base as you type in any app.", description:
"Get inline writing suggestions powered by your knowledge base as you type in any app.",
src: "/homepage/hero_tutorial/extreme_assist.mp4", src: "/homepage/hero_tutorial/extreme_assist.mp4",
featured: true, featured: true,
}, },
{ {
title: "Watch Local Folder", title: "Watch Local Folder",
description: "Watch a local folder and automatically sync file changes to your knowledge base. Works great with Obsidian vaults.", description:
"Watch a local folder and automatically sync file changes to your knowledge base. Works great with Obsidian vaults.",
src: "/homepage/hero_tutorial/folder_watch.mp4", src: "/homepage/hero_tutorial/folder_watch.mp4",
featured: true, featured: true,
}, },
@ -84,7 +86,8 @@ const TAB_ITEMS = [
// }, // },
{ {
title: "Video & Presentations", title: "Video & Presentations",
description: "Create short videos and editable presentations with AI-generated visuals and narration from your sources.", description:
"Create short videos and editable presentations with AI-generated visuals and narration from your sources.",
src: "/homepage/hero_tutorial/video_gen_surf.mp4", src: "/homepage/hero_tutorial/video_gen_surf.mp4",
featured: false, featured: false,
}, },
@ -343,7 +346,12 @@ function DownloadButton() {
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<a href={fallbackUrl} target="_blank" rel="noopener noreferrer" className="cursor-pointer"> <a
href={fallbackUrl}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer"
>
All downloads All downloads
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
@ -498,4 +506,3 @@ const TabVideo = memo(function TabVideo({ src }: { src: string }) {
}); });
const GITHUB_RELEASES_URL = "https://github.com/MODSetter/SurfSense/releases/latest"; const GITHUB_RELEASES_URL = "https://github.com/MODSetter/SurfSense/releases/latest";

View file

@ -60,7 +60,7 @@ const DesktopNav = ({ navItems, isScrolled, scrolledBgClassName }: any) => {
setHovered(null); setHovered(null);
}} }}
className={cn( className={cn(
"mx-auto hidden w-full max-w-7xl flex-row items-center justify-between self-start rounded-full px-4 py-2 lg:flex transition-all duration-300", "mx-auto hidden w-full max-w-7xl flex-row items-center justify-between self-start rounded-full px-4 py-2 lg:flex transition-[background-color,border-color,box-shadow] duration-300",
isScrolled isScrolled
? (scrolledBgClassName ?? ? (scrolledBgClassName ??
"bg-white/80 backdrop-blur-md border border-white/20 shadow-lg dark:bg-neutral-950/80 dark:border-neutral-800/50") "bg-white/80 backdrop-blur-md border border-white/20 shadow-lg dark:bg-neutral-950/80 dark:border-neutral-800/50")
@ -143,8 +143,9 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: any) => {
<motion.div <motion.div
ref={navRef} ref={navRef}
animate={{ borderRadius: open ? "4px" : "2rem" }} animate={{ borderRadius: open ? "4px" : "2rem" }}
key={String(open)}
className={cn( className={cn(
"relative mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-col items-center justify-between px-4 py-2 lg:hidden transition-all duration-300", "relative mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-col items-center justify-between px-4 py-2 lg:hidden transition-[background-color,border-color,box-shadow] duration-300",
isScrolled isScrolled
? (scrolledBgClassName ?? ? (scrolledBgClassName ??
"bg-white/80 backdrop-blur-md border border-white/20 shadow-lg dark:bg-neutral-950/80 dark:border-neutral-800/50") "bg-white/80 backdrop-blur-md border border-white/20 shadow-lg dark:bg-neutral-950/80 dark:border-neutral-800/50")

View file

@ -1,9 +1,9 @@
"use client"; "use client";
import { useRef, useState } from "react";
import { motion, useInView } from "motion/react";
import { IconPointerFilled } from "@tabler/icons-react"; import { IconPointerFilled } from "@tabler/icons-react";
import { Check, X } from "lucide-react"; import { Check, X } from "lucide-react";
import { motion, useInView } from "motion/react";
import { useRef, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -40,8 +40,8 @@ export function WhySurfSense() {
Everything NotebookLM should have been Everything NotebookLM should have been
</h2> </h2>
<p className="mx-auto mt-4 max-w-2xl text-base text-muted-foreground"> <p className="mx-auto mt-4 max-w-2xl text-base text-muted-foreground">
Open source. No data limits. No vendor lock-in. Built for teams that Open source. No data limits. No vendor lock-in. Built for teams that care about privacy
care about privacy and flexibility. and flexibility.
</p> </p>
</div> </div>
@ -68,10 +68,7 @@ function UnlimitedSkeleton({ className }: { className?: string }) {
]; ];
return ( return (
<div <div ref={ref} className={cn("flex h-full flex-col justify-center gap-2.5", className)}>
ref={ref}
className={cn("flex h-full flex-col justify-center gap-2.5", className)}
>
{items.map((item, index) => ( {items.map((item, index) => (
<motion.div <motion.div
key={item.label} key={item.label}
@ -81,9 +78,7 @@ function UnlimitedSkeleton({ className }: { className?: string }) {
className="flex items-center gap-2 rounded-lg bg-background px-3 py-2 shadow-sm ring-1 ring-border" className="flex items-center gap-2 rounded-lg bg-background px-3 py-2 shadow-sm ring-1 ring-border"
> >
<span className="text-sm">{item.icon}</span> <span className="text-sm">{item.icon}</span>
<span className="min-w-[60px] text-xs font-medium text-foreground"> <span className="min-w-[60px] text-xs font-medium text-foreground">{item.label}</span>
{item.label}
</span>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<span className="text-[10px] text-muted-foreground line-through"> <span className="text-[10px] text-muted-foreground line-through">
{item.notebookLm} {item.notebookLm}
@ -125,10 +120,7 @@ function LLMFlexibilitySkeleton({ className }: { className?: string }) {
return ( return (
<div <div
ref={ref} ref={ref}
className={cn( className={cn("flex h-full flex-col items-center justify-center gap-3", className)}
"flex h-full flex-col items-center justify-center gap-3",
className,
)}
> >
<motion.div <motion.div
initial={{ opacity: 0, y: 8 }} initial={{ opacity: 0, y: 8 }}
@ -146,19 +138,13 @@ function LLMFlexibilitySkeleton({ className }: { className?: string }) {
transition={{ duration: 0.3, delay: 0.1 + index * 0.1 }} transition={{ duration: 0.3, delay: 0.1 + index * 0.1 }}
className={cn( className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-all", "flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-all",
selected === index selected === index ? "bg-background shadow-sm ring-1 ring-border" : "hover:bg-accent"
? "bg-background shadow-sm ring-1 ring-border"
: "hover:bg-accent",
)} )}
> >
<div className={cn("size-2 shrink-0 rounded-full", model.color)} /> <div className={cn("size-2 shrink-0 rounded-full", model.color)} />
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-xs font-medium text-foreground"> <p className="truncate text-xs font-medium text-foreground">{model.name}</p>
{model.name} <p className="text-[10px] text-muted-foreground">{model.provider}</p>
</p>
<p className="text-[10px] text-muted-foreground">
{model.provider}
</p>
</div> </div>
{selected === index && ( {selected === index && (
<motion.div <motion.div
@ -220,10 +206,7 @@ function MultiplayerSkeleton({ className }: { className?: string }) {
return ( return (
<div <div
ref={ref} ref={ref}
className={cn( className={cn("relative flex h-full items-center justify-center overflow-visible", className)}
"relative flex h-full items-center justify-center overflow-visible",
className,
)}
> >
<motion.div <motion.div
className="relative w-full max-w-[160px] rounded-lg bg-background p-3 shadow-sm ring-1 ring-border" className="relative w-full max-w-[160px] rounded-lg bg-background p-3 shadow-sm ring-1 ring-border"
@ -246,10 +229,7 @@ function MultiplayerSkeleton({ className }: { className?: string }) {
className="my-1.5 flex items-center" className="my-1.5 flex items-center"
style={{ paddingLeft: line.indent * 8 }} style={{ paddingLeft: line.indent * 8 }}
> >
<div <div className={cn("h-1.5 rounded-full", line.color)} style={{ width: line.width }} />
className={cn("h-1.5 rounded-full", line.color)}
style={{ width: line.width }}
/>
</div> </div>
))} ))}
</motion.div> </motion.div>
@ -295,9 +275,7 @@ function MultiplayerSkeleton({ className }: { className?: string }) {
<div className="flex size-5 items-center justify-center rounded-full bg-white/20 text-[9px] font-bold text-white"> <div className="flex size-5 items-center justify-center rounded-full bg-white/20 text-[9px] font-bold text-white">
{collaborator.name[0]} {collaborator.name[0]}
</div> </div>
<span className="shrink-0 text-[10px] font-medium text-white"> <span className="shrink-0 text-[10px] font-medium text-white">{collaborator.name}</span>
{collaborator.name}
</span>
<span className="rounded bg-white/20 px-1 py-px text-[8px] text-white/80"> <span className="rounded bg-white/20 px-1 py-px text-[8px] text-white/80">
{collaborator.role} {collaborator.role}
</span> </span>
@ -321,9 +299,7 @@ function FeatureCard({
<div className="flex h-full flex-col justify-between bg-card p-10 first:rounded-l-2xl last:rounded-r-2xl"> <div className="flex h-full flex-col justify-between bg-card p-10 first:rounded-l-2xl last:rounded-r-2xl">
<div className="h-60 w-full overflow-visible rounded-md">{skeleton}</div> <div className="h-60 w-full overflow-visible rounded-md">{skeleton}</div>
<div className="mt-4"> <div className="mt-4">
<h3 className="text-base font-bold tracking-tight text-card-foreground"> <h3 className="text-base font-bold tracking-tight text-card-foreground">{title}</h3>
{title}
</h3>
<p className="mt-2 text-sm leading-relaxed tracking-tight text-muted-foreground"> <p className="mt-2 text-sm leading-relaxed tracking-tight text-muted-foreground">
{description} {description}
</p> </p>
@ -408,9 +384,7 @@ function ComparisonStrip() {
transition={{ duration: 0.3, delay: 0.15 + index * 0.06 }} transition={{ duration: 0.3, delay: 0.15 + index * 0.06 }}
> >
<div className="grid grid-cols-3 items-center px-4 py-2.5 text-sm sm:px-6"> <div className="grid grid-cols-3 items-center px-4 py-2.5 text-sm sm:px-6">
<span className="font-medium text-card-foreground"> <span className="font-medium text-card-foreground">{row.feature}</span>
{row.feature}
</span>
<span className="flex justify-center"> <span className="flex justify-center">
{typeof row.notebookLm === "boolean" ? ( {typeof row.notebookLm === "boolean" ? (
row.notebookLm ? ( row.notebookLm ? (
@ -419,9 +393,7 @@ function ComparisonStrip() {
<X className="size-4 text-muted-foreground/40" /> <X className="size-4 text-muted-foreground/40" />
) )
) : ( ) : (
<span className="text-muted-foreground"> <span className="text-muted-foreground">{row.notebookLm}</span>
{row.notebookLm}
</span>
)} )}
</span> </span>
<span className="flex justify-center"> <span className="flex justify-center">
@ -436,9 +408,7 @@ function ComparisonStrip() {
)} )}
</span> </span>
</div> </div>
{index !== comparisonRows.length - 1 && ( {index !== comparisonRows.length - 1 && <Separator />}
<Separator />
)}
</motion.div> </motion.div>
))} ))}
</motion.div> </motion.div>

View file

@ -2,6 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -43,6 +44,7 @@ interface CreateSearchSpaceDialogProps {
export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpaceDialogProps) { export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpaceDialogProps) {
const t = useTranslations("searchSpace"); const t = useTranslations("searchSpace");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createSearchSpace } = useAtomValue(createSearchSpaceMutationAtom); const { mutateAsync: createSearchSpace } = useAtomValue(createSearchSpaceMutationAtom);
@ -65,8 +67,7 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
trackSearchSpaceCreated(result.id, values.name); trackSearchSpaceCreated(result.id, values.name);
// Hard redirect to ensure fresh state router.push(`/dashboard/${result.id}/onboard`);
window.location.href = `/dashboard/${result.id}/onboard`;
} catch (error) { } catch (error) {
console.error("Failed to create search space:", error); console.error("Failed to create search space:", error);
setIsSubmitting(false); setIsSubmitting(false);
@ -151,16 +152,10 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
<Button <Button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="h-8 sm:h-9 text-xs sm:text-sm" className="h-8 sm:h-9 text-xs sm:text-sm relative"
> >
{isSubmitting ? ( <span className={isSubmitting ? "opacity-0" : ""}>{t("create_button")}</span>
<> {isSubmitting && <Spinner size="sm" className="absolute" />}
<Spinner size="sm" className="mr-1.5" />
{t("creating")}
</>
) : (
<>{t("create_button")}</>
)}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View file

@ -23,7 +23,11 @@ import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
import { FolderTreeView } from "@/components/documents/FolderTreeView"; import { FolderTreeView } from "@/components/documents/FolderTreeView";
import { VersionHistoryDialog } from "@/components/documents/version-history"; import { VersionHistoryDialog } from "@/components/documents/version-history";
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import { FolderWatchDialog, type SelectedFolder } from "@/components/sources/FolderWatchDialog"; import {
DEFAULT_EXCLUDE_PATTERNS,
FolderWatchDialog,
type SelectedFolder,
} from "@/components/sources/FolderWatchDialog";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -46,6 +50,8 @@ import { useElectronAPI } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { uploadFolderScan } from "@/lib/folder-sync-upload";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
import { queries } from "@/zero/queries/index"; import { queries } from "@/zero/queries/index";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
@ -114,11 +120,10 @@ export function DocumentsSidebar({
setFolderWatchOpen(true); setFolderWatchOpen(true);
}, []); }, []);
useEffect(() => { const refreshWatchedIds = useCallback(async () => {
if (!electronAPI?.getWatchedFolders) return; if (!electronAPI?.getWatchedFolders) return;
const api = electronAPI; const api = electronAPI;
async function loadWatchedIds() {
const folders = await api.getWatchedFolders(); const folders = await api.getWatchedFolders();
if (folders.length === 0) { if (folders.length === 0) {
@ -152,10 +157,11 @@ export function DocumentsSidebar({
folders.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number) folders.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
); );
setWatchedFolderIds(ids); setWatchedFolderIds(ids);
}
loadWatchedIds();
}, [searchSpaceId, electronAPI]); }, [searchSpaceId, electronAPI]);
useEffect(() => {
refreshWatchedIds();
}, [refreshWatchedIds]);
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom); const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
@ -192,6 +198,7 @@ export function DocumentsSidebar({
position: f.position, position: f.position,
parentId: f.parentId ?? null, parentId: f.parentId ?? null,
searchSpaceId: f.searchSpaceId, searchSpaceId: f.searchSpaceId,
metadata: f.metadata as Record<string, unknown> | null | undefined,
})), })),
[zeroFolders] [zeroFolders]
); );
@ -304,14 +311,17 @@ export function DocumentsSidebar({
} }
try { try {
await documentsApiService.folderIndex(searchSpaceId, { toast.info(`Re-scanning folder: ${matched.name}`);
folder_path: matched.path, await uploadFolderScan({
folder_name: matched.name, folderPath: matched.path,
search_space_id: searchSpaceId, folderName: matched.name,
root_folder_id: folder.id, searchSpaceId,
file_extensions: matched.fileExtensions ?? undefined, excludePatterns: matched.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS,
fileExtensions: matched.fileExtensions ?? Array.from(getSupportedExtensionsSet()),
enableSummary: false,
rootFolderId: folder.id,
}); });
toast.success(`Re-scanning folder: ${matched.name}`); toast.success(`Re-scan complete: ${matched.name}`);
} catch (err) { } catch (err) {
toast.error((err as Error)?.message || "Failed to re-scan folder"); toast.error((err as Error)?.message || "Failed to re-scan folder");
} }
@ -337,8 +347,9 @@ export function DocumentsSidebar({
console.error("[DocumentsSidebar] Failed to clear watched metadata:", err); console.error("[DocumentsSidebar] Failed to clear watched metadata:", err);
} }
toast.success(`Stopped watching: ${matched.name}`); toast.success(`Stopped watching: ${matched.name}`);
refreshWatchedIds();
}, },
[electronAPI] [electronAPI, refreshWatchedIds]
); );
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => { const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
@ -867,6 +878,7 @@ export function DocumentsSidebar({
}} }}
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
initialFolder={watchInitialFolder} initialFolder={watchInitialFolder}
onSuccess={refreshWatchedIds}
/> />
)} )}

View file

@ -97,8 +97,8 @@ export function Sidebar({
<div <div
className={cn( className={cn(
"relative flex h-full flex-col bg-sidebar text-sidebar-foreground overflow-hidden select-none", "relative flex h-full flex-col bg-sidebar text-sidebar-foreground overflow-hidden select-none",
isCollapsed ? "w-[60px] transition-all duration-200" : "", isCollapsed ? "w-[60px] transition-[width] duration-200" : "",
!isCollapsed && !isResizing ? "transition-all duration-200" : "", !isCollapsed && !isResizing ? "transition-[width] duration-200" : "",
className className
)} )}
style={!isCollapsed ? { width: sidebarWidth } : undefined} style={!isCollapsed ? { width: sidebarWidth } : undefined}

View file

@ -91,12 +91,12 @@ export function SidebarSlideOutPanel({
{/* Panel extending from sidebar's right edge, flush with the wrapper border */} {/* Panel extending from sidebar's right edge, flush with the wrapper border */}
<motion.div <motion.div
initial={{ width: 0 }} style={{ width, left: "100%", top: -1, bottom: -1 }}
animate={{ width }} initial={{ x: -width }}
exit={{ width: 0 }} animate={{ x: 0 }}
exit={{ x: -width }}
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }} transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className="absolute z-20 overflow-hidden" className="absolute z-20 overflow-hidden"
style={{ left: "100%", top: -1, bottom: -1 }}
> >
<div <div
style={{ width }} style={{ width }}

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { Download, FileQuestionMark, FileText, Loader2, PenLine, RefreshCw } from "lucide-react"; import { Download, FileQuestionMark, FileText, Loader2, PenLine, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
@ -60,6 +61,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
const markdownRef = useRef<string>(""); const markdownRef = useRef<string>("");
const initialLoadDone = useRef(false); const initialLoadDone = useRef(false);
const changeCountRef = useRef(0); const changeCountRef = useRef(0);
const router = useRouter();
const isLargeDocument = (doc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD; const isLargeDocument = (doc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
@ -190,7 +192,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
variant="outline" variant="outline"
size="sm" size="sm"
className="mt-1 gap-1.5" className="mt-1 gap-1.5"
onClick={() => window.location.reload()} onClick={() => router.refresh()}
> >
<RefreshCw className="size-3.5" /> <RefreshCw className="size-3.5" />
Retry Retry

View file

@ -2,7 +2,6 @@ import { createCodePlugin } from "@streamdown/code";
import { createMathPlugin } from "@streamdown/math"; import { createMathPlugin } from "@streamdown/math";
import { Streamdown, type StreamdownProps } from "streamdown"; import { Streamdown, type StreamdownProps } from "streamdown";
import "katex/dist/katex.min.css"; import "katex/dist/katex.min.css";
import { is } from "drizzle-orm";
import Image from "next/image"; import Image from "next/image";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View file

@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { Earth, User, Users } from "lucide-react"; import { Earth, User, Users } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
import { myAccessAtom } from "@/atoms/members/members-query.atoms"; import { myAccessAtom } from "@/atoms/members/members-query.atoms";
@ -63,11 +63,9 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
// Permission check for public sharing // Permission check for public sharing
const { data: access } = useAtomValue(myAccessAtom); const { data: access } = useAtomValue(myAccessAtom);
const canCreatePublicLink = useMemo(() => { const canCreatePublicLink =
if (!access) return false; !!access &&
if (access.is_owner) return true; (access.is_owner || (access.permissions?.includes("public_sharing:create") ?? false));
return access.permissions?.includes("public_sharing:create") ?? false;
}, [access]);
// Query to check if thread has public snapshots // Query to check if thread has public snapshots
const { data: snapshotsData } = useQuery({ const { data: snapshotsData } = useQuery({

View file

@ -121,9 +121,8 @@ export function ModelSelector({
return llmUserConfigs?.find((c) => c.id === agentLlmId) ?? null; return llmUserConfigs?.find((c) => c.id === agentLlmId) ?? null;
}, [preferences, llmGlobalConfigs, llmUserConfigs]); }, [preferences, llmGlobalConfigs, llmUserConfigs]);
const isLLMAutoMode = useMemo(() => { const isLLMAutoMode =
return currentLLMConfig && "is_auto_mode" in currentLLMConfig && currentLLMConfig.is_auto_mode; currentLLMConfig && "is_auto_mode" in currentLLMConfig && currentLLMConfig.is_auto_mode;
}, [currentLLMConfig]);
// ─── Image current config ─── // ─── Image current config ───
const currentImageConfig = useMemo(() => { const currentImageConfig = useMemo(() => {
@ -135,11 +134,8 @@ export function ModelSelector({
return imageUserConfigs?.find((c) => c.id === id) ?? null; return imageUserConfigs?.find((c) => c.id === id) ?? null;
}, [preferences, imageGlobalConfigs, imageUserConfigs]); }, [preferences, imageGlobalConfigs, imageUserConfigs]);
const isImageAutoMode = useMemo(() => { const isImageAutoMode =
return ( currentImageConfig && "is_auto_mode" in currentImageConfig && currentImageConfig.is_auto_mode;
currentImageConfig && "is_auto_mode" in currentImageConfig && currentImageConfig.is_auto_mode
);
}, [currentImageConfig]);
// ─── Vision current config ─── // ─── Vision current config ───
const currentVisionConfig = useMemo(() => { const currentVisionConfig = useMemo(() => {

View file

@ -8,7 +8,7 @@ const demoPlans = [
price: "0", price: "0",
yearlyPrice: "0", yearlyPrice: "0",
period: "", period: "",
billingText: "1,000 pages included", billingText: "500 pages included",
features: [ features: [
"Self Hostable", "Self Hostable",
"500 pages included to start", "500 pages included to start",

View file

@ -43,17 +43,12 @@ export function PublicChatSnapshotsManager({
// Permissions // Permissions
const { data: access } = useAtomValue(myAccessAtom); const { data: access } = useAtomValue(myAccessAtom);
const canView = useMemo(() => { const canView =
if (!access) return false; !!access && (access.is_owner || (access.permissions?.includes("public_sharing:view") ?? false));
if (access.is_owner) return true;
return access.permissions?.includes("public_sharing:view") ?? false;
}, [access]);
const canDelete = useMemo(() => { const canDelete =
if (!access) return false; !!access &&
if (access.is_owner) return true; (access.is_owner || (access.permissions?.includes("public_sharing:delete") ?? false));
return access.permissions?.includes("public_sharing:delete") ?? false;
}, [access]);
// Mutations // Mutations
const { mutateAsync: deleteSnapshot } = useAtomValue(deletePublicChatSnapshotMutationAtom); const { mutateAsync: deleteSnapshot } = useAtomValue(deletePublicChatSnapshotMutationAtom);

View file

@ -8,6 +8,7 @@ import {
useAuiState, useAuiState,
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import { CheckIcon, CopyIcon } from "lucide-react"; import { CheckIcon, CopyIcon } from "lucide-react";
import dynamic from "next/dynamic";
import Image from "next/image"; import Image from "next/image";
import { type FC, type ReactNode, useState } from "react"; import { type FC, type ReactNode, useState } from "react";
import { CitationMetadataProvider } from "@/components/assistant-ui/citation-metadata-context"; import { CitationMetadataProvider } from "@/components/assistant-ui/citation-metadata-context";
@ -17,7 +18,14 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button
import { GenerateImageToolUI } from "@/components/tool-ui/generate-image"; import { GenerateImageToolUI } from "@/components/tool-ui/generate-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation";
const GenerateVideoPresentationToolUI = dynamic(
() =>
import("@/components/tool-ui/video-presentation").then((m) => ({
default: m.GenerateVideoPresentationToolUI,
})),
{ ssr: false }
);
interface PublicThreadProps { interface PublicThreadProps {
footer?: ReactNode; footer?: ReactNode;

View file

@ -78,16 +78,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
}, [members]); }, [members]);
const { data: access } = useAtomValue(myAccessAtom); const { data: access } = useAtomValue(myAccessAtom);
const canCreate = useMemo(() => { const canCreate =
if (!access) return false; !!access &&
if (access.is_owner) return true; (access.is_owner || (access.permissions?.includes("image_generations:create") ?? false));
return access.permissions?.includes("image_generations:create") ?? false; const canDelete =
}, [access]); !!access &&
const canDelete = useMemo(() => { (access.is_owner || (access.permissions?.includes("image_generations:delete") ?? false));
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes("image_generations:delete") ?? false;
}, [access]);
const canUpdate = canCreate; const canUpdate = canCreate;
const isReadOnly = !canCreate && !canDelete; const isReadOnly = !canCreate && !canDelete;

View file

@ -89,21 +89,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
// Permissions // Permissions
const { data: access } = useAtomValue(myAccessAtom); const { data: access } = useAtomValue(myAccessAtom);
const canCreate = useMemo(() => { const canCreate =
if (!access) return false; !!access && (access.is_owner || (access.permissions?.includes("llm_configs:create") ?? false));
if (access.is_owner) return true; const canUpdate =
return access.permissions?.includes("llm_configs:create") ?? false; !!access && (access.is_owner || (access.permissions?.includes("llm_configs:update") ?? false));
}, [access]); const canDelete =
const canUpdate = useMemo(() => { !!access && (access.is_owner || (access.permissions?.includes("llm_configs:delete") ?? false));
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes("llm_configs:update") ?? false;
}, [access]);
const canDelete = useMemo(() => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes("llm_configs:delete") ?? false;
}, [access]);
const isReadOnly = !canCreate && !canUpdate && !canDelete; const isReadOnly = !canCreate && !canUpdate && !canDelete;
// Local state // Local state

View file

@ -2,18 +2,63 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { Bot, Brain, Eye, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react"; import { Bot, Brain, Eye, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type React from "react"; import type React from "react";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
import { ImageModelManager } from "@/components/settings/image-model-manager";
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager";
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
import { RolesManager } from "@/components/settings/roles-manager";
import { SettingsDialog } from "@/components/settings/settings-dialog"; import { SettingsDialog } from "@/components/settings/settings-dialog";
import { VisionModelManager } from "@/components/settings/vision-model-manager";
const GeneralSettingsManager = dynamic(
() =>
import("@/components/settings/general-settings-manager").then((m) => ({
default: m.GeneralSettingsManager,
})),
{ ssr: false }
);
const ModelConfigManager = dynamic(
() =>
import("@/components/settings/model-config-manager").then((m) => ({
default: m.ModelConfigManager,
})),
{ ssr: false }
);
const LLMRoleManager = dynamic(
() =>
import("@/components/settings/llm-role-manager").then((m) => ({ default: m.LLMRoleManager })),
{ ssr: false }
);
const ImageModelManager = dynamic(
() =>
import("@/components/settings/image-model-manager").then((m) => ({
default: m.ImageModelManager,
})),
{ ssr: false }
);
const VisionModelManager = dynamic(
() =>
import("@/components/settings/vision-model-manager").then((m) => ({
default: m.VisionModelManager,
})),
{ ssr: false }
);
const RolesManager = dynamic(
() => import("@/components/settings/roles-manager").then((m) => ({ default: m.RolesManager })),
{ ssr: false }
);
const PromptConfigManager = dynamic(
() =>
import("@/components/settings/prompt-config-manager").then((m) => ({
default: m.PromptConfigManager,
})),
{ ssr: false }
);
const PublicChatSnapshotsManager = dynamic(
() =>
import("@/components/public-chat-snapshots/public-chat-snapshots-manager").then((m) => ({
default: m.PublicChatSnapshotsManager,
})),
{ ssr: false }
);
interface SearchSpaceSettingsDialogProps { interface SearchSpaceSettingsDialogProps {
searchSpaceId: number; searchSpaceId: number;

View file

@ -2,18 +2,56 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { Globe, KeyRound, Monitor, Receipt, Sparkles, User } from "lucide-react"; import { Globe, KeyRound, Monitor, Receipt, Sparkles, User } from "lucide-react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useMemo } from "react"; import { useMemo } from "react";
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
import { CommunityPromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent";
import { DesktopContent } from "@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent";
import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent";
import { PromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PromptsContent";
import { PurchaseHistoryContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { SettingsDialog } from "@/components/settings/settings-dialog"; import { SettingsDialog } from "@/components/settings/settings-dialog";
import { usePlatform } from "@/hooks/use-platform"; import { usePlatform } from "@/hooks/use-platform";
const ProfileContent = dynamic(
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent").then(
(m) => ({ default: m.ProfileContent })
),
{ ssr: false }
);
const ApiKeyContent = dynamic(
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent").then(
(m) => ({ default: m.ApiKeyContent })
),
{ ssr: false }
);
const PromptsContent = dynamic(
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/PromptsContent").then(
(m) => ({ default: m.PromptsContent })
),
{ ssr: false }
);
const CommunityPromptsContent = dynamic(
() =>
import(
"@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent"
).then((m) => ({ default: m.CommunityPromptsContent })),
{ ssr: false }
);
const PurchaseHistoryContent = dynamic(
() =>
import(
"@/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent"
).then((m) => ({ default: m.PurchaseHistoryContent })),
{ ssr: false }
);
const DesktopContent = dynamic(
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent").then(
(m) => ({ default: m.DesktopContent })
),
{ ssr: false }
);
export function UserSettingsDialog() { export function UserSettingsDialog() {
const t = useTranslations("userSettings"); const t = useTranslations("userSettings");
const [state, setState] = useAtom(userSettingsDialogAtom); const [state, setState] = useAtom(userSettingsDialogAtom);

View file

@ -341,19 +341,12 @@ export function DocumentUploadTab({
</button> </button>
) )
) : ( ) : (
<div <button
role="button" type="button"
tabIndex={0}
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent border-none" className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent border-none"
onClick={() => { onClick={() => {
if (!isElectron) fileInputRef.current?.click(); if (!isElectron) fileInputRef.current?.click();
}} }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!isElectron) fileInputRef.current?.click();
}
}}
> >
<Upload className="h-10 w-10 text-muted-foreground" /> <Upload className="h-10 w-10 text-muted-foreground" />
<div className="text-center space-y-1.5"> <div className="text-center space-y-1.5">
@ -362,15 +355,14 @@ export function DocumentUploadTab({
</p> </p>
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p> <p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
</div> </div>
<div <fieldset
className="w-full mt-1" className="w-full mt-1 border-none p-0 m-0"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
role="group"
> >
{renderBrowseButton({ fullWidth: true })} {renderBrowseButton({ fullWidth: true })}
</div> </fieldset>
</div> </button>
)} )}
</div> </div>

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -13,7 +13,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { type FolderSyncProgress, uploadFolderScan } from "@/lib/folder-sync-upload";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
export interface SelectedFolder { export interface SelectedFolder {
@ -29,7 +29,7 @@ interface FolderWatchDialogProps {
initialFolder?: SelectedFolder | null; initialFolder?: SelectedFolder | null;
} }
const DEFAULT_EXCLUDE_PATTERNS = [ export const DEFAULT_EXCLUDE_PATTERNS = [
".git", ".git",
"node_modules", "node_modules",
"__pycache__", "__pycache__",
@ -48,6 +48,8 @@ export function FolderWatchDialog({
const [selectedFolder, setSelectedFolder] = useState<SelectedFolder | null>(null); const [selectedFolder, setSelectedFolder] = useState<SelectedFolder | null>(null);
const [shouldSummarize, setShouldSummarize] = useState(false); const [shouldSummarize, setShouldSummarize] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState<FolderSyncProgress | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => { useEffect(() => {
if (open && initialFolder) { if (open && initialFolder) {
@ -64,33 +66,42 @@ export function FolderWatchDialog({
const folderPath = await api.selectFolder(); const folderPath = await api.selectFolder();
if (!folderPath) return; if (!folderPath) return;
const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath; const folderName = folderPath.split(/[/\\]/).pop() || folderPath;
setSelectedFolder({ path: folderPath, name: folderName }); setSelectedFolder({ path: folderPath, name: folderName });
}, []); }, []);
const handleCancel = useCallback(() => {
abortRef.current?.abort();
}, []);
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
if (!selectedFolder) return; if (!selectedFolder) return;
const api = window.electronAPI; const api = window.electronAPI;
if (!api) return; if (!api) return;
const controller = new AbortController();
abortRef.current = controller;
setSubmitting(true); setSubmitting(true);
try { setProgress(null);
const result = await documentsApiService.folderIndex(searchSpaceId, {
folder_path: selectedFolder.path,
folder_name: selectedFolder.name,
search_space_id: searchSpaceId,
enable_summary: shouldSummarize,
file_extensions: supportedExtensions,
});
const rootFolderId = (result as { root_folder_id?: number })?.root_folder_id ?? null; try {
const rootFolderId = await uploadFolderScan({
folderPath: selectedFolder.path,
folderName: selectedFolder.name,
searchSpaceId,
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
fileExtensions: supportedExtensions,
enableSummary: shouldSummarize,
onProgress: setProgress,
signal: controller.signal,
});
await api.addWatchedFolder({ await api.addWatchedFolder({
path: selectedFolder.path, path: selectedFolder.path,
name: selectedFolder.name, name: selectedFolder.name,
excludePatterns: DEFAULT_EXCLUDE_PATTERNS, excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
fileExtensions: supportedExtensions, fileExtensions: supportedExtensions,
rootFolderId, rootFolderId: rootFolderId ?? null,
searchSpaceId, searchSpaceId,
active: true, active: true,
}); });
@ -98,12 +109,19 @@ export function FolderWatchDialog({
toast.success(`Watching folder: ${selectedFolder.name}`); toast.success(`Watching folder: ${selectedFolder.name}`);
setSelectedFolder(null); setSelectedFolder(null);
setShouldSummarize(false); setShouldSummarize(false);
setProgress(null);
onOpenChange(false); onOpenChange(false);
onSuccess?.(); onSuccess?.();
} catch (err) { } catch (err) {
if ((err as Error)?.name === "AbortError") {
toast.info("Folder sync cancelled. Partial progress was saved.");
} else {
toast.error((err as Error)?.message || "Failed to watch folder"); toast.error((err as Error)?.message || "Failed to watch folder");
}
} finally { } finally {
abortRef.current = null;
setSubmitting(false); setSubmitting(false);
setProgress(null);
} }
}, [ }, [
selectedFolder, selectedFolder,
@ -119,21 +137,44 @@ export function FolderWatchDialog({
if (!nextOpen && !submitting) { if (!nextOpen && !submitting) {
setSelectedFolder(null); setSelectedFolder(null);
setShouldSummarize(false); setShouldSummarize(false);
setProgress(null);
} }
onOpenChange(nextOpen); onOpenChange(nextOpen);
}, },
[onOpenChange, submitting] [onOpenChange, submitting]
); );
const progressLabel = useMemo(() => {
if (!progress) return null;
switch (progress.phase) {
case "listing":
return "Scanning folder...";
case "checking":
return `Checking ${progress.total} file(s)...`;
case "uploading":
return `Uploading ${progress.uploaded}/${progress.total} file(s)...`;
case "finalizing":
return "Finalizing...";
case "done":
return "Done!";
default:
return null;
}
}, [progress]);
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md select-none"> <DialogContent className="sm:max-w-md select-none p-0 gap-0 overflow-hidden bg-muted dark:bg-muted border border-border [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-foreground/10">
<DialogHeader> <DialogHeader className="px-4 sm:px-6 pt-5 sm:pt-6 pb-3">
<DialogTitle>Watch Local Folder</DialogTitle> <DialogTitle className="text-lg sm:text-xl font-semibold tracking-tight">
<DialogDescription>Select a folder to sync and watch for changes.</DialogDescription> Watch Local Folder
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm text-muted-foreground/80">
Select a folder to sync and watch for changes
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 pt-2"> <div className="flex flex-col gap-3 px-4 sm:px-6 pb-4 sm:pb-6 min-h-[17rem]">
{selectedFolder ? ( {selectedFolder ? (
<div className="flex items-center gap-2 py-1.5 pl-4 pr-2 rounded-md bg-slate-400/5 dark:bg-white/5 overflow-hidden"> <div className="flex items-center gap-2 py-1.5 pl-4 pr-2 rounded-md bg-slate-400/5 dark:bg-white/5 overflow-hidden">
<div className="min-w-0 flex-1 select-text"> <div className="min-w-0 flex-1 select-text">
@ -156,7 +197,7 @@ export function FolderWatchDialog({
<button <button
type="button" type="button"
onClick={handleSelectFolder} onClick={handleSelectFolder}
className="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-muted-foreground/30 py-8 text-sm text-muted-foreground transition-colors hover:border-foreground/50 hover:text-foreground" className="flex flex-1 w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-muted-foreground/30 text-sm text-muted-foreground transition-colors hover:border-foreground/50 hover:text-foreground"
> >
Browse for a folder Browse for a folder
</button> </button>
@ -174,15 +215,42 @@ export function FolderWatchDialog({
<Switch checked={shouldSummarize} onCheckedChange={setShouldSummarize} /> <Switch checked={shouldSummarize} onCheckedChange={setShouldSummarize} />
</div> </div>
<Button className="w-full relative" onClick={handleSubmit} disabled={submitting}> {progressLabel && (
<span className={submitting ? "invisible" : ""}>Start Folder Sync</span> <div className="rounded-lg bg-slate-400/5 dark:bg-white/5 px-3 py-2">
{submitting && ( <p className="text-xs text-muted-foreground">{progressLabel}</p>
{progress && progress.phase === "uploading" && progress.total > 0 && (
<div className="mt-1.5 h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-[width] duration-300"
style={{
width: `${Math.round((progress.uploaded / progress.total) * 100)}%`,
}}
/>
</div>
)}
</div>
)}
<div className="flex gap-2 mt-auto">
{submitting ? (
<>
<Button variant="secondary" className="flex-1" onClick={handleCancel}>
Cancel
</Button>
<Button className="flex-1 relative" disabled>
<span className="invisible">Syncing...</span>
<span className="absolute inset-0 flex items-center justify-center"> <span className="absolute inset-0 flex items-center justify-center">
<Spinner size="sm" /> <Spinner size="sm" />
</span> </span>
)}
</Button> </Button>
</> </>
) : (
<Button className="w-full" onClick={handleSubmit}>
Start Folder Sync
</Button>
)}
</div>
</>
)} )}
</div> </div>
</DialogContent> </DialogContent>

View file

@ -251,7 +251,7 @@ export function Audio({ id, src, title, durationMs, className }: AudioProps) {
<div className="relative hidden h-6 w-16 items-center md:flex md:opacity-0 md:pointer-events-none md:group-hover/volume:opacity-100 md:group-hover/volume:pointer-events-auto md:transition-opacity md:duration-200"> <div className="relative hidden h-6 w-16 items-center md:flex md:opacity-0 md:pointer-events-none md:group-hover/volume:opacity-100 md:group-hover/volume:pointer-events-auto md:transition-opacity md:duration-200">
<div className="relative h-1 w-full rounded-full bg-muted-foreground/20"> <div className="relative h-1 w-full rounded-full bg-muted-foreground/20">
<div <div
className="absolute left-0 top-0 h-full rounded-full bg-muted-foreground/60 transition-all" className="absolute left-0 top-0 h-full rounded-full bg-muted-foreground/60 transition-[width]"
style={{ width: `${(isMuted ? 0 : volume) * 100}%` }} style={{ width: `${(isMuted ? 0 : volume) * 100}%` }}
/> />
</div> </div>

View file

@ -199,7 +199,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -193,7 +193,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -214,7 +214,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -195,7 +195,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">

View file

@ -134,7 +134,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">

View file

@ -213,12 +213,20 @@ function ReportCard({
return ( return (
<div <div
className={`my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300 ${isActive ? "ring-1 ring-primary/50" : ""}`} className={`my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300 ${isActive ? "ring-1 ring-primary/50" : ""}`}
> >
<button {/* biome-ignore lint/a11y/useSemanticElements: can't use <button> here because PlateEditor renders nested <button> elements (e.g. CopyButton) */}
type="button" <div
role="button"
tabIndex={0}
onClick={handleOpen} onClick={handleOpen}
className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none" onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleOpen();
}
}}
className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none cursor-pointer"
> >
<div className="px-5 pt-5 pb-4 select-none"> <div className="px-5 pt-5 pb-4 select-none">
<p className="text-sm font-semibold text-foreground line-clamp-2"> <p className="text-sm font-semibold text-foreground line-clamp-2">
@ -264,7 +272,7 @@ function ReportCard({
<p className="text-sm text-muted-foreground italic">No content available</p> <p className="text-sm text-muted-foreground italic">No content available</p>
)} )}
</div> </div>
</button> </div>
</div> </div>
); );
} }

View file

@ -198,7 +198,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -197,7 +197,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -175,7 +175,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -230,7 +230,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -266,7 +266,7 @@ function ApprovalCard({
: attendeesList; : attendeesList;
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -203,7 +203,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -338,7 +338,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -217,7 +217,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -193,7 +193,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -235,7 +235,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -188,7 +188,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -246,7 +246,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -243,7 +243,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -171,7 +171,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -305,7 +305,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -195,7 +195,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -172,7 +172,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -176,7 +176,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -179,7 +179,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>

View file

@ -130,7 +130,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">

View file

@ -434,7 +434,7 @@ function VideoPresentationPlayer({
</span> </span>
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted"> <div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted">
<div <div
className="h-full rounded-full bg-muted-foreground/60 transition-all duration-300" className="h-full rounded-full bg-muted-foreground/60 transition-[box-shadow] duration-300"
style={{ width: `${(renderProgress ?? 0) * 100}%` }} style={{ width: `${(renderProgress ?? 0) * 100}%` }}
/> />
</div> </div>

View file

@ -310,7 +310,8 @@ const TabsList = forwardRef<
}, [updateActiveIndicator]); }, [updateActiveIndicator]);
useEffect(() => { useEffect(() => {
requestAnimationFrame(updateActiveIndicator); const id = requestAnimationFrame(updateActiveIndicator);
return () => cancelAnimationFrame(id);
}, [updateActiveIndicator]); }, [updateActiveIndicator]);
const scrollTabToCenter = useCallback((index: number) => { const scrollTabToCenter = useCallback((index: number) => {
@ -369,7 +370,7 @@ const TabsList = forwardRef<
{showHoverEffect && ( {showHoverEffect && (
<div <div
className={cn( className={cn(
"absolute transition-all duration-300 ease-out flex items-center z-0", "absolute transition-[left,width,opacity] duration-300 ease-out flex items-center z-0",
SIZE_CLASSES[size], SIZE_CLASSES[size],
HOVER_INDICATOR_CLASSES[variant], HOVER_INDICATOR_CLASSES[variant],
hoverIndicatorClassName hoverIndicatorClassName
@ -464,7 +465,7 @@ const TabsList = forwardRef<
{showActiveIndicator && variant !== "pills" && activeIndex >= 0 && ( {showActiveIndicator && variant !== "pills" && activeIndex >= 0 && (
<div <div
className={cn( className={cn(
"absolute transition-all duration-300 ease-out z-10", "absolute transition-[left,width,bottom,top] duration-300 ease-out z-10",
ACTIVE_INDICATOR_CLASSES[variant], ACTIVE_INDICATOR_CLASSES[variant],
activeIndicatorPosition === "top" ? "top-[-1px]" : "bottom-[-1px]", activeIndicatorPosition === "top" ? "top-[-1px]" : "bottom-[-1px]",
activeIndicatorClassName activeIndicatorClassName

View file

@ -2,21 +2,27 @@
import type React from "react"; import type React from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { set } from "zod";
import enMessages from "../messages/en.json"; import enMessages from "../messages/en.json";
import esMessages from "../messages/es.json";
import hiMessages from "../messages/hi.json";
import ptMessages from "../messages/pt.json";
import zhMessages from "../messages/zh.json";
type Locale = "en" | "es" | "pt" | "hi" | "zh"; type Locale = "en" | "es" | "pt" | "hi" | "zh";
const messagesMap: Record<Locale, typeof enMessages> = { /**
en: enMessages, * Dynamically load locale messages on demand.
es: esMessages as typeof enMessages, * English is the default and always available synchronously.
pt: ptMessages as typeof enMessages, */
hi: hiMessages as typeof enMessages, const loadMessages = async (locale: Locale): Promise<typeof enMessages> => {
zh: zhMessages as typeof enMessages, switch (locale) {
case "es":
return (await import("../messages/es.json")).default;
case "hi":
return (await import("../messages/hi.json")).default;
case "pt":
return (await import("../messages/pt.json")).default;
case "zh":
return (await import("../messages/zh.json")).default;
default:
return enMessages;
}
}; };
interface LocaleContextType { interface LocaleContextType {
@ -33,24 +39,30 @@ export function LocaleProvider({ children }: { children: React.ReactNode }) {
// Always start with 'en' to avoid hydration mismatch // Always start with 'en' to avoid hydration mismatch
// Then sync with localStorage after mount // Then sync with localStorage after mount
const [locale, setLocaleState] = useState<Locale>("en"); const [locale, setLocaleState] = useState<Locale>("en");
const [messages, setMessages] = useState<typeof enMessages>(enMessages);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
// Get messages based on current locale
const messages = messagesMap[locale] || enMessages;
// Load locale from localStorage after component mounts (client-side only) // Load locale from localStorage after component mounts (client-side only)
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY); const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && (["en", "es", "pt", "hi", "zh"] as const).includes(stored as Locale)) { if (stored && (["en", "es", "pt", "hi", "zh"] as const).includes(stored as Locale)) {
setLocaleState(stored as Locale); const storedLocale = stored as Locale;
setLocaleState(storedLocale);
// Load messages for non-English locale
if (storedLocale !== "en") {
loadMessages(storedLocale).then(setMessages);
}
} }
} }
}, []); }, []);
// Update locale and persist to localStorage // Update locale and persist to localStorage
const setLocale = useCallback((newLocale: Locale) => { const setLocale = useCallback(async (newLocale: Locale) => {
// Load messages for the new locale
const newMessages = await loadMessages(newLocale);
setMessages(newMessages);
setLocaleState(newLocale); setLocaleState(newLocale);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem(LOCALE_STORAGE_KEY, newLocale); localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);

View file

@ -9,7 +9,7 @@ export const folder = z.object({
created_by_id: z.string().nullable().optional(), created_by_id: z.string().nullable().optional(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
metadata: z.record(z.unknown()).nullable().optional(), metadata: z.record(z.string(), z.any()).nullable().optional(),
}); });
export const folderCreateRequest = z.object({ export const folderCreateRequest = z.object({

View file

@ -20,12 +20,18 @@ const DEBOUNCE_MS = 2000;
const MAX_WAIT_MS = 10_000; const MAX_WAIT_MS = 10_000;
const MAX_BATCH_SIZE = 50; const MAX_BATCH_SIZE = 50;
interface FileEntry {
fullPath: string;
relativePath: string;
action: string;
}
interface BatchItem { interface BatchItem {
folderPath: string; folderPath: string;
folderName: string; folderName: string;
searchSpaceId: number; searchSpaceId: number;
rootFolderId: number | null; rootFolderId: number | null;
filePaths: string[]; files: FileEntry[];
ackIds: string[]; ackIds: string[];
} }
@ -44,18 +50,42 @@ export function useFolderSync() {
while (queueRef.current.length > 0) { while (queueRef.current.length > 0) {
const batch = queueRef.current.shift()!; const batch = queueRef.current.shift()!;
try { try {
await documentsApiService.folderIndexFiles(batch.searchSpaceId, { const addChangeFiles = batch.files.filter(
folder_path: batch.folderPath, (f) => f.action === "add" || f.action === "change"
);
const unlinkFiles = batch.files.filter((f) => f.action === "unlink");
if (addChangeFiles.length > 0 && electronAPI?.readLocalFiles) {
const fullPaths = addChangeFiles.map((f) => f.fullPath);
const fileDataArr = await electronAPI.readLocalFiles(fullPaths);
const files: File[] = fileDataArr.map((fd) => {
const blob = new Blob([fd.data], { type: fd.mimeType || "application/octet-stream" });
return new File([blob], fd.name, { type: blob.type });
});
await documentsApiService.folderUploadFiles(files, {
folder_name: batch.folderName, folder_name: batch.folderName,
search_space_id: batch.searchSpaceId, search_space_id: batch.searchSpaceId,
target_file_paths: batch.filePaths, relative_paths: addChangeFiles.map((f) => f.relativePath),
root_folder_id: batch.rootFolderId, root_folder_id: batch.rootFolderId,
}); });
}
if (unlinkFiles.length > 0) {
await documentsApiService.folderNotifyUnlinked({
folder_name: batch.folderName,
search_space_id: batch.searchSpaceId,
root_folder_id: batch.rootFolderId,
relative_paths: unlinkFiles.map((f) => f.relativePath),
});
}
if (electronAPI?.acknowledgeFileEvents && batch.ackIds.length > 0) { if (electronAPI?.acknowledgeFileEvents && batch.ackIds.length > 0) {
await electronAPI.acknowledgeFileEvents(batch.ackIds); await electronAPI.acknowledgeFileEvents(batch.ackIds);
} }
} catch (err) { } catch (err) {
console.error("[FolderSync] Failed to trigger batch re-index:", err); console.error("[FolderSync] Failed to process batch:", err);
} }
} }
processingRef.current = false; processingRef.current = false;
@ -68,10 +98,10 @@ export function useFolderSync() {
if (!pending) return; if (!pending) return;
pendingByFolder.current.delete(folderKey); pendingByFolder.current.delete(folderKey);
for (let i = 0; i < pending.filePaths.length; i += MAX_BATCH_SIZE) { for (let i = 0; i < pending.files.length; i += MAX_BATCH_SIZE) {
queueRef.current.push({ queueRef.current.push({
...pending, ...pending,
filePaths: pending.filePaths.slice(i, i + MAX_BATCH_SIZE), files: pending.files.slice(i, i + MAX_BATCH_SIZE),
ackIds: i === 0 ? pending.ackIds : [], ackIds: i === 0 ? pending.ackIds : [],
}); });
} }
@ -83,9 +113,14 @@ export function useFolderSync() {
const existing = pendingByFolder.current.get(folderKey); const existing = pendingByFolder.current.get(folderKey);
if (existing) { if (existing) {
const pathSet = new Set(existing.filePaths); const pathSet = new Set(existing.files.map((f) => f.fullPath));
pathSet.add(event.fullPath); if (!pathSet.has(event.fullPath)) {
existing.filePaths = Array.from(pathSet); existing.files.push({
fullPath: event.fullPath,
relativePath: event.relativePath,
action: event.action,
});
}
if (!existing.ackIds.includes(event.id)) { if (!existing.ackIds.includes(event.id)) {
existing.ackIds.push(event.id); existing.ackIds.push(event.id);
} }
@ -95,7 +130,13 @@ export function useFolderSync() {
folderName: event.folderName, folderName: event.folderName,
searchSpaceId: event.searchSpaceId, searchSpaceId: event.searchSpaceId,
rootFolderId: event.rootFolderId, rootFolderId: event.rootFolderId,
filePaths: [event.fullPath], files: [
{
fullPath: event.fullPath,
relativePath: event.relativePath,
action: event.action,
},
],
ackIds: [event.id], ackIds: [event.id],
}); });
firstEventTime.current.set(folderKey, Date.now()); firstEventTime.current.set(folderKey, Date.now());

View file

@ -12,6 +12,8 @@ function initPostHog() {
capture_pageleave: true, capture_pageleave: true,
before_send: (event) => { before_send: (event) => {
if (event?.properties) { if (event?.properties) {
event.properties.platform = "web";
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const ref = params.get("ref"); const ref = params.get("ref");
if (ref) { if (ref) {
@ -28,6 +30,7 @@ function initPostHog() {
event.properties.$set = { event.properties.$set = {
...event.properties.$set, ...event.properties.$set,
platform: "web",
last_seen_at: new Date().toISOString(), last_seen_at: new Date().toISOString(),
}; };
} }

View file

@ -424,33 +424,79 @@ class DocumentsApiService {
return baseApiService.post(`/api/v1/documents/${documentId}/versions/${versionNumber}/restore`); return baseApiService.post(`/api/v1/documents/${documentId}/versions/${versionNumber}/restore`);
}; };
folderIndex = async ( folderMtimeCheck = async (body: {
searchSpaceId: number,
body: {
folder_path: string;
folder_name: string; folder_name: string;
search_space_id: number; search_space_id: number;
exclude_patterns?: string[]; files: { relative_path: string; mtime: number }[];
file_extensions?: string[]; }): Promise<{ files_to_upload: string[] }> => {
root_folder_id?: number; return baseApiService.post(`/api/v1/documents/folder-mtime-check`, undefined, {
enable_summary?: boolean; body,
} }) as unknown as { files_to_upload: string[] };
) => {
return baseApiService.post(`/api/v1/documents/folder-index`, undefined, { body });
}; };
folderIndexFiles = async ( folderUploadFiles = async (
searchSpaceId: number, files: File[],
body: { metadata: {
folder_path: string;
folder_name: string; folder_name: string;
search_space_id: number; search_space_id: number;
target_file_paths: string[]; relative_paths: string[];
root_folder_id?: number | null; root_folder_id?: number | null;
enable_summary?: boolean; enable_summary?: boolean;
},
signal?: AbortSignal
): Promise<{ message: string; status: string; root_folder_id: number; file_count: number }> => {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
} }
) => { formData.append("folder_name", metadata.folder_name);
return baseApiService.post(`/api/v1/documents/folder-index-files`, undefined, { body }); formData.append("search_space_id", String(metadata.search_space_id));
formData.append("relative_paths", JSON.stringify(metadata.relative_paths));
if (metadata.root_folder_id != null) {
formData.append("root_folder_id", String(metadata.root_folder_id));
}
formData.append("enable_summary", String(metadata.enable_summary ?? false));
const totalSize = files.reduce((acc, f) => acc + f.size, 0);
const timeoutMs = Math.min(Math.max((totalSize / (1024 * 1024)) * 5000, 30_000), 600_000);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
if (signal) {
signal.addEventListener("abort", () => controller.abort(), { once: true });
}
try {
return (await baseApiService.postFormData(`/api/v1/documents/folder-upload`, undefined, {
body: formData,
signal: controller.signal,
})) as { message: string; status: string; root_folder_id: number; file_count: number };
} finally {
clearTimeout(timeoutId);
}
};
folderNotifyUnlinked = async (body: {
folder_name: string;
search_space_id: number;
root_folder_id: number | null;
relative_paths: string[];
}): Promise<{ deleted_count: number }> => {
return baseApiService.post(`/api/v1/documents/folder-unlink`, undefined, {
body,
}) as unknown as { deleted_count: number };
};
folderSyncFinalize = async (body: {
folder_name: string;
search_space_id: number;
root_folder_id: number | null;
all_relative_paths: string[];
}): Promise<{ deleted_count: number }> => {
return baseApiService.post(`/api/v1/documents/folder-sync-finalize`, undefined, {
body,
}) as unknown as { deleted_count: number };
}; };
getWatchedFolders = async (searchSpaceId: number) => { getWatchedFolders = async (searchSpaceId: number) => {

View file

@ -0,0 +1,239 @@
import { documentsApiService } from "@/lib/apis/documents-api.service";
const MAX_BATCH_SIZE_BYTES = 20 * 1024 * 1024; // 20 MB
const MAX_BATCH_FILES = 10;
const UPLOAD_CONCURRENCY = 3;
export interface FolderSyncProgress {
phase: "listing" | "checking" | "uploading" | "finalizing" | "done";
uploaded: number;
total: number;
}
export interface FolderSyncParams {
folderPath: string;
folderName: string;
searchSpaceId: number;
excludePatterns: string[];
fileExtensions: string[];
enableSummary: boolean;
rootFolderId?: number | null;
onProgress?: (progress: FolderSyncProgress) => void;
signal?: AbortSignal;
}
function buildBatches(entries: FolderFileEntry[]): FolderFileEntry[][] {
const batches: FolderFileEntry[][] = [];
let currentBatch: FolderFileEntry[] = [];
let currentSize = 0;
for (const entry of entries) {
if (entry.size >= MAX_BATCH_SIZE_BYTES) {
if (currentBatch.length > 0) {
batches.push(currentBatch);
currentBatch = [];
currentSize = 0;
}
batches.push([entry]);
continue;
}
if (currentBatch.length >= MAX_BATCH_FILES || currentSize + entry.size > MAX_BATCH_SIZE_BYTES) {
batches.push(currentBatch);
currentBatch = [];
currentSize = 0;
}
currentBatch.push(entry);
currentSize += entry.size;
}
if (currentBatch.length > 0) {
batches.push(currentBatch);
}
return batches;
}
async function uploadBatchesWithConcurrency(
batches: FolderFileEntry[][],
params: {
folderName: string;
searchSpaceId: number;
rootFolderId: number | null;
enableSummary: boolean;
signal?: AbortSignal;
onBatchComplete?: (filesInBatch: number) => void;
}
): Promise<number | null> {
const api = window.electronAPI;
if (!api) throw new Error("Electron API not available");
let batchIdx = 0;
let resolvedRootFolderId = params.rootFolderId;
const errors: string[] = [];
async function processNext(): Promise<void> {
while (true) {
if (params.signal?.aborted) return;
const idx = batchIdx++;
if (idx >= batches.length) return;
const batch = batches[idx];
const fullPaths = batch.map((e) => e.fullPath);
try {
const fileDataArr = await api.readLocalFiles(fullPaths);
const files: File[] = fileDataArr.map((fd) => {
const blob = new Blob([fd.data], { type: fd.mimeType || "application/octet-stream" });
return new File([blob], fd.name, { type: blob.type });
});
const result = await documentsApiService.folderUploadFiles(
files,
{
folder_name: params.folderName,
search_space_id: params.searchSpaceId,
relative_paths: batch.map((e) => e.relativePath),
root_folder_id: resolvedRootFolderId,
enable_summary: params.enableSummary,
},
params.signal
);
if (result.root_folder_id && !resolvedRootFolderId) {
resolvedRootFolderId = result.root_folder_id;
}
params.onBatchComplete?.(batch.length);
} catch (err) {
if (params.signal?.aborted) return;
const msg = (err as Error)?.message || "Upload failed";
errors.push(`Batch ${idx}: ${msg}`);
}
}
}
const workers = Array.from({ length: Math.min(UPLOAD_CONCURRENCY, batches.length) }, () =>
processNext()
);
await Promise.all(workers);
if (errors.length > 0 && !params.signal?.aborted) {
console.error("Some batches failed:", errors);
}
return resolvedRootFolderId;
}
/**
* Run a full upload-based folder scan: list files, mtime-check, upload
* changed files in parallel batches, and finalize (delete orphans).
*
* Returns the root_folder_id to pass to addWatchedFolder.
*/
export async function uploadFolderScan(params: FolderSyncParams): Promise<number | null> {
const api = window.electronAPI;
if (!api) throw new Error("Electron API not available");
const {
folderPath,
folderName,
searchSpaceId,
excludePatterns,
fileExtensions,
enableSummary,
signal,
} = params;
let rootFolderId = params.rootFolderId ?? null;
params.onProgress?.({ phase: "listing", uploaded: 0, total: 0 });
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
const allFiles = await api.listFolderFiles({
path: folderPath,
name: folderName,
excludePatterns,
fileExtensions,
rootFolderId: rootFolderId ?? null,
searchSpaceId,
active: true,
});
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
params.onProgress?.({ phase: "checking", uploaded: 0, total: allFiles.length });
const mtimeCheckResult = await documentsApiService.folderMtimeCheck({
folder_name: folderName,
search_space_id: searchSpaceId,
files: allFiles.map((f) => ({ relative_path: f.relativePath, mtime: f.mtimeMs / 1000 })),
});
const filesToUpload = mtimeCheckResult.files_to_upload;
const uploadSet = new Set(filesToUpload);
const entriesToUpload = allFiles.filter((f) => uploadSet.has(f.relativePath));
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (entriesToUpload.length > 0) {
const batches = buildBatches(entriesToUpload);
let uploaded = 0;
params.onProgress?.({ phase: "uploading", uploaded: 0, total: entriesToUpload.length });
const uploadedRootId = await uploadBatchesWithConcurrency(batches, {
folderName,
searchSpaceId,
rootFolderId: rootFolderId ?? null,
enableSummary,
signal,
onBatchComplete: (count) => {
uploaded += count;
params.onProgress?.({ phase: "uploading", uploaded, total: entriesToUpload.length });
},
});
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (uploadedRootId) {
rootFolderId = uploadedRootId;
}
}
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
params.onProgress?.({
phase: "finalizing",
uploaded: entriesToUpload.length,
total: entriesToUpload.length,
});
await documentsApiService.folderSyncFinalize({
folder_name: folderName,
search_space_id: searchSpaceId,
root_folder_id: rootFolderId ?? null,
all_relative_paths: allFiles.map((f) => f.relativePath),
});
params.onProgress?.({
phase: "done",
uploaded: entriesToUpload.length,
total: entriesToUpload.length,
});
// Seed the Electron mtime store so the reconciliation scan in
// startWatcher won't re-emit events for files we just indexed.
if (api.seedFolderMtimes) {
const mtimes: Record<string, number> = {};
for (const f of allFiles) {
mtimes[f.relativePath] = f.mtimeMs;
}
await api.seedFolderMtimes(folderPath, mtimes);
}
return rootFolderId;
}

View file

@ -1,6 +1,6 @@
{ {
"name": "surfsense_web", "name": "surfsense_web",
"version": "0.0.14", "version": "0.0.15",
"private": true, "private": true,
"description": "SurfSense Frontend", "description": "SurfSense Frontend",
"scripts": { "scripts": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 3.5 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

After

Width:  |  Height:  |  Size: 12 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 MiB

After

Width:  |  Height:  |  Size: 5.2 MiB

Before After
Before After

Some files were not shown because too many files have changed in this diff Show more