From 656e061f8477c7fd7f5d0ccadad528b7107ee167 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 14 Apr 2026 21:26:00 -0700 Subject: [PATCH] feat: add processing mode support for document uploads and ETL pipeline, improded error handling ux - Introduced a `ProcessingMode` enum to differentiate between basic and premium processing modes. - Updated `EtlRequest` to include a `processing_mode` field, defaulting to basic. - Enhanced ETL pipeline services to utilize the selected processing mode for Azure Document Intelligence and LlamaCloud parsing. - Modified various routes and services to handle processing mode, affecting document upload and indexing tasks. - Improved error handling and logging to include processing mode details. - Added tests to validate processing mode functionality and its impact on ETL operations. --- surfsense_backend/app/app.py | 167 ++++++- .../app/etl_pipeline/etl_document.py | 27 ++ .../app/etl_pipeline/etl_pipeline_service.py | 10 +- .../parsers/azure_doc_intelligence.py | 18 +- .../app/etl_pipeline/parsers/llamacloud.py | 14 +- surfsense_backend/app/exceptions.py | 104 ++++ .../app/routes/documents_routes.py | 12 + .../app/services/task_dispatcher.py | 3 + .../app/tasks/celery_tasks/document_tasks.py | 8 + .../local_folder_indexer.py | 48 +- .../document_processors/file_processors.py | 63 ++- .../integration/document_upload/conftest.py | 6 +- .../etl_pipeline/test_etl_pipeline_service.py | 184 ++++++++ .../tests/unit/test_error_contract.py | 286 +++++++++++ surfsense_web/app/(home)/blog/[slug]/page.tsx | 12 +- .../app/(home)/blog/blog-magazine.tsx | 22 +- surfsense_web/app/(home)/blog/page.tsx | 7 +- surfsense_web/app/(home)/contact/page.tsx | 3 +- .../app/(home)/login/LocalLoginForm.tsx | 50 +- surfsense_web/app/(home)/page.tsx | 12 +- surfsense_web/app/(home)/pricing/page.tsx | 2 +- .../new-chat/[[...chat_id]]/page.tsx | 50 +- surfsense_web/app/dashboard/error.tsx | 29 +- surfsense_web/app/error.tsx | 46 +- surfsense_web/app/global-error.tsx | 50 +- surfsense_web/app/layout.tsx | 6 +- surfsense_web/app/not-found.tsx | 3 +- .../announcements/AnnouncementCard.tsx | 12 +- .../assistant-ui/assistant-message.tsx | 20 +- .../views/connector-edit-view.tsx | 5 +- .../views/indexing-configuration-view.tsx | 5 +- .../hooks/use-connector-dialog.ts | 27 +- .../tabs/active-connectors-tab.tsx | 2 +- .../components/assistant-ui/thread.tsx | 8 +- .../assistant-ui/token-usage-context.tsx | 30 +- .../comment-item/comment-item.tsx | 3 +- .../components/contact/contact-form.tsx | 6 +- .../components/documents/DocumentNode.tsx | 123 +++-- .../components/documents/DocumentTypeIcon.tsx | 1 - .../components/documents/DocumentsFilters.tsx | 22 +- .../components/documents/FolderTreeView.tsx | 6 +- .../components/editor/utils/escape-mdx.ts | 4 +- .../homepage/github-stars-badge.tsx | 7 +- .../components/homepage/hero-section.tsx | 15 +- surfsense_web/components/homepage/navbar.tsx | 16 +- .../layout/ui/sidebar/DocumentsSidebar.tsx | 19 +- .../layout/ui/sidebar/InboxSidebar.tsx | 4 +- .../ui/sidebar/SidebarSlideOutPanel.tsx | 1 - .../new-chat/document-mention-picker.tsx | 8 +- .../components/new-chat/model-selector.tsx | 446 ++++++------------ .../components/new-chat/prompt-picker.tsx | 4 +- .../components/pricing/pricing-section.tsx | 76 +-- .../public-chat-snapshot-row.tsx | 9 +- .../components/seo/breadcrumb-nav.tsx | 9 +- surfsense_web/components/seo/json-ld.tsx | 59 +-- .../settings/general-settings-manager.tsx | 54 +-- .../components/settings/llm-role-manager.tsx | 2 +- .../components/settings/roles-manager.tsx | 9 +- .../settings/search-space-settings-dialog.tsx | 2 +- .../components/shared/model-config-dialog.tsx | 4 +- .../components/sources/DocumentUploadTab.tsx | 103 ++-- .../confluence/create-confluence-page.tsx | 6 +- .../confluence/delete-confluence-page.tsx | 6 +- .../confluence/update-confluence-page.tsx | 6 +- .../tool-ui/dropbox/create-file.tsx | 10 +- .../components/tool-ui/dropbox/trash-file.tsx | 4 +- .../tool-ui/generic-hitl-approval.tsx | 8 +- .../components/tool-ui/gmail/create-draft.tsx | 6 +- .../components/tool-ui/gmail/send-email.tsx | 6 +- .../components/tool-ui/gmail/trash-email.tsx | 6 +- .../components/tool-ui/gmail/update-draft.tsx | 6 +- .../tool-ui/google-calendar/create-event.tsx | 6 +- .../tool-ui/google-calendar/delete-event.tsx | 6 +- .../tool-ui/google-calendar/update-event.tsx | 6 +- .../tool-ui/google-drive/create-file.tsx | 4 +- .../tool-ui/google-drive/trash-file.tsx | 4 +- .../tool-ui/jira/create-jira-issue.tsx | 6 +- .../tool-ui/jira/delete-jira-issue.tsx | 6 +- .../tool-ui/jira/update-jira-issue.tsx | 6 +- .../tool-ui/linear/create-linear-issue.tsx | 10 +- .../tool-ui/linear/delete-linear-issue.tsx | 4 +- .../tool-ui/linear/update-linear-issue.tsx | 4 +- .../tool-ui/notion/create-notion-page.tsx | 10 +- .../tool-ui/notion/delete-notion-page.tsx | 4 +- .../tool-ui/notion/update-notion-page.tsx | 4 +- .../tool-ui/onedrive/create-file.tsx | 10 +- .../tool-ui/onedrive/trash-file.tsx | 4 +- .../contracts/types/document.types.ts | 4 + surfsense_web/lib/apis/base-api.service.ts | 62 ++- .../lib/apis/documents-api.service.ts | 6 +- surfsense_web/lib/chat/message-utils.ts | 27 +- surfsense_web/lib/chat/streaming-state.ts | 14 +- surfsense_web/lib/chat/thread-persistence.ts | 5 +- surfsense_web/lib/error-toast.ts | 72 +++ surfsense_web/lib/error.ts | 37 +- surfsense_web/lib/folder-sync-upload.ts | 5 + surfsense_web/lib/hitl/index.ts | 2 +- surfsense_web/lib/hitl/use-hitl-decision.ts | 4 +- surfsense_web/lib/query-client/client.ts | 15 +- surfsense_web/messages/en.json | 7 +- surfsense_web/messages/es.json | 7 +- surfsense_web/messages/hi.json | 7 +- surfsense_web/messages/pt.json | 7 +- surfsense_web/messages/zh.json | 7 +- 104 files changed, 1900 insertions(+), 909 deletions(-) create mode 100644 surfsense_backend/app/exceptions.py create mode 100644 surfsense_backend/tests/unit/test_error_contract.py create mode 100644 surfsense_web/lib/error-toast.ts diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py index 7b2b421ac..73705885e 100644 --- a/surfsense_backend/app/app.py +++ b/surfsense_backend/app/app.py @@ -2,12 +2,15 @@ import asyncio import gc import logging import time +import uuid from collections import defaultdict from contextlib import asynccontextmanager +from datetime import UTC, datetime from threading import Lock import redis from fastapi import Depends, FastAPI, HTTPException, Request, status +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from limits.storage import MemoryStorage @@ -32,6 +35,7 @@ from app.config import ( initialize_vision_llm_router, ) from app.db import User, create_db_and_tables, get_async_session +from app.exceptions import GENERIC_5XX_MESSAGE, ISSUES_URL, SurfSenseError from app.routes import router as crud_router from app.routes.auth_routes import router as auth_router from app.schemas import UserCreate, UserRead, UserUpdate @@ -39,6 +43,8 @@ from app.tasks.surfsense_docs_indexer import seed_surfsense_docs from app.users import SECRET, auth_backend, current_active_user, fastapi_users from app.utils.perf import get_perf_logger, log_system_snapshot +_error_logger = logging.getLogger("surfsense.errors") + rate_limit_logger = logging.getLogger("surfsense.rate_limit") @@ -61,13 +67,137 @@ limiter = Limiter( ) +def _get_request_id(request: Request) -> str: + """Return the request ID from state, header, or generate a new one.""" + if hasattr(request.state, "request_id"): + return request.state.request_id + return request.headers.get("X-Request-ID", f"req_{uuid.uuid4().hex[:12]}") + + +def _build_error_response( + status_code: int, + message: str, + *, + code: str = "INTERNAL_ERROR", + request_id: str = "", + extra_headers: dict[str, str] | None = None, +) -> JSONResponse: + """Build the standardized error envelope (new ``error`` + legacy ``detail``).""" + body = { + "error": { + "code": code, + "message": message, + "status": status_code, + "request_id": request_id, + "timestamp": datetime.now(UTC).isoformat(), + "report_url": ISSUES_URL, + }, + "detail": message, + } + headers = {"X-Request-ID": request_id} + if extra_headers: + headers.update(extra_headers) + return JSONResponse(status_code=status_code, content=body, headers=headers) + + +# --------------------------------------------------------------------------- +# Global exception handlers +# --------------------------------------------------------------------------- + + +def _surfsense_error_handler(request: Request, exc: SurfSenseError) -> JSONResponse: + """Handle our own structured exceptions.""" + rid = _get_request_id(request) + if exc.status_code >= 500: + _error_logger.error( + "[%s] %s - %s: %s", + rid, + request.url.path, + exc.code, + exc, + exc_info=True, + ) + message = exc.message if exc.safe_for_client else GENERIC_5XX_MESSAGE + return _build_error_response( + exc.status_code, message, code=exc.code, request_id=rid + ) + + +def _http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: + """Wrap FastAPI/Starlette HTTPExceptions into the standard envelope.""" + rid = _get_request_id(request) + detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail) + if exc.status_code >= 500: + _error_logger.error( + "[%s] %s - HTTPException %d: %s", + rid, + request.url.path, + exc.status_code, + detail, + ) + detail = GENERIC_5XX_MESSAGE + code = _status_to_code(exc.status_code, detail) + return _build_error_response(exc.status_code, detail, code=code, request_id=rid) + + +def _validation_error_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: + """Return 422 with field-level detail in the standard envelope.""" + rid = _get_request_id(request) + fields = [] + for err in exc.errors(): + loc = " -> ".join(str(part) for part in err.get("loc", [])) + fields.append(f"{loc}: {err.get('msg', 'invalid')}") + message = ( + f"Validation failed: {'; '.join(fields)}" if fields else "Validation failed." + ) + return _build_error_response(422, message, code="VALIDATION_ERROR", request_id=rid) + + +def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """Catch-all: log full traceback, return sanitized 500.""" + rid = _get_request_id(request) + _error_logger.error( + "[%s] Unhandled exception on %s %s", + rid, + request.method, + request.url.path, + exc_info=True, + ) + return _build_error_response( + 500, GENERIC_5XX_MESSAGE, code="INTERNAL_ERROR", request_id=rid + ) + + +def _status_to_code(status_code: int, detail: str = "") -> str: + if detail == "RATE_LIMIT_EXCEEDED": + return "RATE_LIMIT_EXCEEDED" + mapping = { + 400: "BAD_REQUEST", + 401: "UNAUTHORIZED", + 403: "FORBIDDEN", + 404: "NOT_FOUND", + 405: "METHOD_NOT_ALLOWED", + 409: "CONFLICT", + 422: "VALIDATION_ERROR", + 429: "RATE_LIMIT_EXCEEDED", + } + return mapping.get( + status_code, "INTERNAL_ERROR" if status_code >= 500 else "CLIENT_ERROR" + ) + + def _rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded): - """Custom 429 handler that returns JSON matching our frontend error format.""" + """Custom 429 handler that returns JSON matching our error envelope.""" + rid = _get_request_id(request) retry_after = exc.detail.split("per")[-1].strip() if exc.detail else "60" - return JSONResponse( - status_code=429, - content={"detail": "RATE_LIMIT_EXCEEDED"}, - headers={"Retry-After": retry_after}, + return _build_error_response( + 429, + "Too many requests. Please slow down and try again.", + code="RATE_LIMIT_EXCEEDED", + request_id=rid, + extra_headers={"Retry-After": retry_after}, ) @@ -258,6 +388,33 @@ app = FastAPI(lifespan=lifespan) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +# Register structured global exception handlers (order matters: most specific first) +app.add_exception_handler(SurfSenseError, _surfsense_error_handler) +app.add_exception_handler(RequestValidationError, _validation_error_handler) +app.add_exception_handler(HTTPException, _http_exception_handler) +app.add_exception_handler(Exception, _unhandled_exception_handler) + + +# --------------------------------------------------------------------------- +# Request-ID middleware +# --------------------------------------------------------------------------- + + +class RequestIDMiddleware(BaseHTTPMiddleware): + """Attach a unique request ID to every request and echo it in the response.""" + + async def dispatch( + self, request: StarletteRequest, call_next: RequestResponseEndpoint + ) -> StarletteResponse: + request_id = request.headers.get("X-Request-ID", f"req_{uuid.uuid4().hex[:12]}") + request.state.request_id = request_id + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + return response + + +app.add_middleware(RequestIDMiddleware) + # --------------------------------------------------------------------------- # Request-level performance middleware diff --git a/surfsense_backend/app/etl_pipeline/etl_document.py b/surfsense_backend/app/etl_pipeline/etl_document.py index 350c3299f..f4a01ae14 100644 --- a/surfsense_backend/app/etl_pipeline/etl_document.py +++ b/surfsense_backend/app/etl_pipeline/etl_document.py @@ -1,10 +1,37 @@ +from enum import StrEnum + from pydantic import BaseModel, field_validator +class ProcessingMode(StrEnum): + BASIC = "basic" + PREMIUM = "premium" + + @classmethod + def coerce(cls, value: str | None) -> "ProcessingMode": + if value is None: + return cls.BASIC + try: + return cls(value.lower()) + except ValueError: + return cls.BASIC + + @property + def page_multiplier(self) -> int: + return _PAGE_MULTIPLIERS[self] + + +_PAGE_MULTIPLIERS: dict["ProcessingMode", int] = { + ProcessingMode.BASIC: 1, + ProcessingMode.PREMIUM: 10, +} + + class EtlRequest(BaseModel): file_path: str filename: str estimated_pages: int = 0 + processing_mode: ProcessingMode = ProcessingMode.BASIC @field_validator("filename") @classmethod diff --git a/surfsense_backend/app/etl_pipeline/etl_pipeline_service.py b/surfsense_backend/app/etl_pipeline/etl_pipeline_service.py index b4438ce4d..4bb38b7b0 100644 --- a/surfsense_backend/app/etl_pipeline/etl_pipeline_service.py +++ b/surfsense_backend/app/etl_pipeline/etl_pipeline_service.py @@ -145,13 +145,17 @@ class EtlPipelineService: and getattr(app_config, "AZURE_DI_KEY", None) ) + mode_value = request.processing_mode.value + if azure_configured and ext in AZURE_DI_DOCUMENT_EXTENSIONS: try: from app.etl_pipeline.parsers.azure_doc_intelligence import ( parse_with_azure_doc_intelligence, ) - return await parse_with_azure_doc_intelligence(request.file_path) + return await parse_with_azure_doc_intelligence( + request.file_path, processing_mode=mode_value + ) except Exception: logging.warning( "Azure Document Intelligence failed for %s, " @@ -162,4 +166,6 @@ class EtlPipelineService: from app.etl_pipeline.parsers.llamacloud import parse_with_llamacloud - return await parse_with_llamacloud(request.file_path, request.estimated_pages) + return await parse_with_llamacloud( + request.file_path, request.estimated_pages, processing_mode=mode_value + ) diff --git a/surfsense_backend/app/etl_pipeline/parsers/azure_doc_intelligence.py b/surfsense_backend/app/etl_pipeline/parsers/azure_doc_intelligence.py index aa7ebeb49..c15e18e97 100644 --- a/surfsense_backend/app/etl_pipeline/parsers/azure_doc_intelligence.py +++ b/surfsense_backend/app/etl_pipeline/parsers/azure_doc_intelligence.py @@ -10,7 +10,15 @@ BASE_DELAY = 10 MAX_DELAY = 120 -async def parse_with_azure_doc_intelligence(file_path: str) -> str: +AZURE_MODEL_BY_MODE = { + "basic": "prebuilt-read", + "premium": "prebuilt-layout", +} + + +async def parse_with_azure_doc_intelligence( + file_path: str, processing_mode: str = "basic" +) -> str: from azure.ai.documentintelligence.aio import DocumentIntelligenceClient from azure.ai.documentintelligence.models import DocumentContentFormat from azure.core.credentials import AzureKeyCredential @@ -21,9 +29,15 @@ async def parse_with_azure_doc_intelligence(file_path: str) -> str: ServiceResponseError, ) + model_id = AZURE_MODEL_BY_MODE.get(processing_mode, "prebuilt-read") file_size_mb = os.path.getsize(file_path) / (1024 * 1024) retryable_exceptions = (ServiceRequestError, ServiceResponseError) + logging.info( + f"Azure Document Intelligence using model={model_id} " + f"(mode={processing_mode}, file={file_size_mb:.1f}MB)" + ) + last_exception = None attempt_errors: list[str] = [] @@ -36,7 +50,7 @@ async def parse_with_azure_doc_intelligence(file_path: str) -> str: async with client: with open(file_path, "rb") as f: poller = await client.begin_analyze_document( - "prebuilt-layout", + model_id, body=f, output_content_format=DocumentContentFormat.MARKDOWN, ) diff --git a/surfsense_backend/app/etl_pipeline/parsers/llamacloud.py b/surfsense_backend/app/etl_pipeline/parsers/llamacloud.py index ae2a34234..138786b74 100644 --- a/surfsense_backend/app/etl_pipeline/parsers/llamacloud.py +++ b/surfsense_backend/app/etl_pipeline/parsers/llamacloud.py @@ -16,8 +16,15 @@ from app.etl_pipeline.constants import ( calculate_upload_timeout, ) +LLAMA_TIER_BY_MODE = { + "basic": "cost_effective", + "premium": "agentic_plus", +} -async def parse_with_llamacloud(file_path: str, estimated_pages: int) -> str: + +async def parse_with_llamacloud( + file_path: str, estimated_pages: int, processing_mode: str = "basic" +) -> str: from llama_cloud_services import LlamaParse from llama_cloud_services.parse.utils import ResultType @@ -34,10 +41,12 @@ async def parse_with_llamacloud(file_path: str, estimated_pages: int) -> str: pool=120.0, ) + tier = LLAMA_TIER_BY_MODE.get(processing_mode, "cost_effective") + logging.info( f"LlamaCloud upload configured: file_size={file_size_mb:.1f}MB, " f"pages={estimated_pages}, upload_timeout={upload_timeout:.0f}s, " - f"job_timeout={job_timeout:.0f}s" + f"job_timeout={job_timeout:.0f}s, tier={tier} (mode={processing_mode})" ) last_exception = None @@ -56,6 +65,7 @@ async def parse_with_llamacloud(file_path: str, estimated_pages: int) -> str: job_timeout_in_seconds=job_timeout, job_timeout_extra_time_per_page_in_seconds=PER_PAGE_JOB_TIMEOUT, custom_client=custom_client, + tier=tier, ) result = await parser.aparse(file_path) diff --git a/surfsense_backend/app/exceptions.py b/surfsense_backend/app/exceptions.py new file mode 100644 index 000000000..183781cd4 --- /dev/null +++ b/surfsense_backend/app/exceptions.py @@ -0,0 +1,104 @@ +"""Structured error hierarchy for SurfSense. + +Every error response follows a backward-compatible contract: + + { + "error": { + "code": "SOME_ERROR_CODE", + "message": "Human-readable, client-safe message.", + "status": 422, + "request_id": "req_...", + "timestamp": "2026-04-14T12:00:00Z", + "report_url": "https://github.com/MODSetter/SurfSense/issues" + }, + "detail": "Human-readable, client-safe message." # legacy compat + } +""" + +from __future__ import annotations + +ISSUES_URL = "https://github.com/MODSetter/SurfSense/issues" + +GENERIC_5XX_MESSAGE = ( + "An internal error occurred. Please try again or report this issue if it persists." +) + + +class SurfSenseError(Exception): + """Base exception that global handlers translate into the structured envelope.""" + + def __init__( + self, + message: str = GENERIC_5XX_MESSAGE, + *, + code: str = "INTERNAL_ERROR", + status_code: int = 500, + safe_for_client: bool = True, + ) -> None: + super().__init__(message) + self.message = message + self.code = code + self.status_code = status_code + self.safe_for_client = safe_for_client + + +class ConnectorError(SurfSenseError): + def __init__(self, message: str, *, code: str = "CONNECTOR_ERROR") -> None: + super().__init__(message, code=code, status_code=502) + + +class DatabaseError(SurfSenseError): + def __init__( + self, + message: str = "A database error occurred.", + *, + code: str = "DATABASE_ERROR", + ) -> None: + super().__init__(message, code=code, status_code=500) + + +class ConfigurationError(SurfSenseError): + def __init__( + self, + message: str = "A configuration error occurred.", + *, + code: str = "CONFIGURATION_ERROR", + ) -> None: + super().__init__(message, code=code, status_code=500) + + +class ExternalServiceError(SurfSenseError): + def __init__( + self, + message: str = "An external service is unavailable.", + *, + code: str = "EXTERNAL_SERVICE_ERROR", + ) -> None: + super().__init__(message, code=code, status_code=502) + + +class NotFoundError(SurfSenseError): + def __init__( + self, + message: str = "The requested resource was not found.", + *, + code: str = "NOT_FOUND", + ) -> None: + super().__init__(message, code=code, status_code=404) + + +class ForbiddenError(SurfSenseError): + def __init__( + self, + message: str = "You don't have permission to access this resource.", + *, + code: str = "FORBIDDEN", + ) -> None: + super().__init__(message, code=code, status_code=403) + + +class ValidationError(SurfSenseError): + def __init__( + self, message: str = "Validation failed.", *, code: str = "VALIDATION_ERROR" + ) -> None: + super().__init__(message, code=code, status_code=422) diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py index aa7f98294..f558481cf 100644 --- a/surfsense_backend/app/routes/documents_routes.py +++ b/surfsense_backend/app/routes/documents_routes.py @@ -124,6 +124,7 @@ async def create_documents_file_upload( search_space_id: int = Form(...), should_summarize: bool = Form(False), use_vision_llm: bool = Form(False), + processing_mode: str = Form("basic"), session: AsyncSession = Depends(get_async_session), user: User = Depends(current_active_user), dispatcher: TaskDispatcher = Depends(get_task_dispatcher), @@ -142,12 +143,15 @@ async def create_documents_file_upload( from datetime import datetime from app.db import DocumentStatus + from app.etl_pipeline.etl_document import ProcessingMode from app.tasks.document_processors.base import ( check_document_by_unique_identifier, get_current_timestamp, ) from app.utils.document_converters import generate_unique_identifier_hash + validated_mode = ProcessingMode.coerce(processing_mode) + try: await check_permission( session, @@ -274,6 +278,7 @@ async def create_documents_file_upload( user_id=str(user.id), should_summarize=should_summarize, use_vision_llm=use_vision_llm, + processing_mode=validated_mode.value, ) return { @@ -1493,6 +1498,7 @@ async def folder_upload( root_folder_id: int | None = Form(None), enable_summary: bool = Form(False), use_vision_llm: bool = Form(False), + processing_mode: str = Form("basic"), session: AsyncSession = Depends(get_async_session), user: User = Depends(current_active_user), ): @@ -1504,6 +1510,10 @@ async def folder_upload( import json import tempfile + from app.etl_pipeline.etl_document import ProcessingMode + + validated_mode = ProcessingMode.coerce(processing_mode) + await check_permission( session, user, @@ -1558,6 +1568,7 @@ async def folder_upload( watched_metadata = { "watched": True, "folder_path": folder_name, + "processing_mode": validated_mode.value, } existing_root = ( await session.execute( @@ -1621,6 +1632,7 @@ async def folder_upload( enable_summary=enable_summary, use_vision_llm=use_vision_llm, file_mappings=list(file_mappings), + processing_mode=validated_mode.value, ) return { diff --git a/surfsense_backend/app/services/task_dispatcher.py b/surfsense_backend/app/services/task_dispatcher.py index 7bb70b406..210084102 100644 --- a/surfsense_backend/app/services/task_dispatcher.py +++ b/surfsense_backend/app/services/task_dispatcher.py @@ -20,6 +20,7 @@ class TaskDispatcher(Protocol): user_id: str, should_summarize: bool = False, use_vision_llm: bool = False, + processing_mode: str = "basic", ) -> None: ... @@ -36,6 +37,7 @@ class CeleryTaskDispatcher: user_id: str, should_summarize: bool = False, use_vision_llm: bool = False, + processing_mode: str = "basic", ) -> None: from app.tasks.celery_tasks.document_tasks import ( process_file_upload_with_document_task, @@ -49,6 +51,7 @@ class CeleryTaskDispatcher: user_id=user_id, should_summarize=should_summarize, use_vision_llm=use_vision_llm, + processing_mode=processing_mode, ) diff --git a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py index 719cfb940..9d12f91f6 100644 --- a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py +++ b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py @@ -780,6 +780,7 @@ def process_file_upload_with_document_task( user_id: str, should_summarize: bool = False, use_vision_llm: bool = False, + processing_mode: str = "basic", ): """ Celery task to process uploaded file with existing pending document. @@ -836,6 +837,7 @@ def process_file_upload_with_document_task( user_id, should_summarize=should_summarize, use_vision_llm=use_vision_llm, + processing_mode=processing_mode, ) ) logger.info( @@ -873,6 +875,7 @@ async def _process_file_with_document( user_id: str, should_summarize: bool = False, use_vision_llm: bool = False, + processing_mode: str = "basic", ): """ Process file and update existing pending document status. @@ -976,6 +979,7 @@ async def _process_file_with_document( notification=notification, should_summarize=should_summarize, use_vision_llm=use_vision_llm, + processing_mode=processing_mode, ) # Update notification on success @@ -1434,6 +1438,7 @@ def index_uploaded_folder_files_task( enable_summary: bool, file_mappings: list[dict], use_vision_llm: bool = False, + processing_mode: str = "basic", ): """Celery task to index files uploaded from the desktop app.""" loop = asyncio.new_event_loop() @@ -1448,6 +1453,7 @@ def index_uploaded_folder_files_task( enable_summary=enable_summary, file_mappings=file_mappings, use_vision_llm=use_vision_llm, + processing_mode=processing_mode, ) ) finally: @@ -1462,6 +1468,7 @@ async def _index_uploaded_folder_files_async( enable_summary: bool, file_mappings: list[dict], use_vision_llm: bool = False, + processing_mode: str = "basic", ): """Run upload-based folder indexing with notification + heartbeat.""" file_count = len(file_mappings) @@ -1512,6 +1519,7 @@ async def _index_uploaded_folder_files_async( file_mappings=file_mappings, on_heartbeat_callback=_heartbeat_progress, use_vision_llm=use_vision_llm, + processing_mode=processing_mode, ) if notification: diff --git a/surfsense_backend/app/tasks/connector_indexers/local_folder_indexer.py b/surfsense_backend/app/tasks/connector_indexers/local_folder_indexer.py index b6797f77a..9352b60e0 100644 --- a/surfsense_backend/app/tasks/connector_indexers/local_folder_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/local_folder_indexer.py @@ -60,14 +60,16 @@ async def _check_page_limit_or_skip( page_limit_service: PageLimitService, user_id: str, file_path: str, -) -> int: + page_multiplier: int = 1, +) -> tuple[int, int]: """Estimate pages and check the limit; raises PageLimitExceededError if over quota. - Returns the estimated page count on success. + Returns (estimated_pages, billable_pages). """ estimated = _estimate_pages_safe(page_limit_service, file_path) - await page_limit_service.check_page_limit(user_id, estimated) - return estimated + billable = estimated * page_multiplier + await page_limit_service.check_page_limit(user_id, billable) + return estimated, billable def _compute_final_pages( @@ -153,17 +155,20 @@ def scan_folder( return files -async def _read_file_content(file_path: str, filename: str, *, vision_llm=None) -> str: +async def _read_file_content( + file_path: str, filename: str, *, vision_llm=None, processing_mode: str = "basic" +) -> str: """Read file content via the unified ETL pipeline. All file types (plaintext, audio, direct-convert, document, image) are handled by ``EtlPipelineService``. """ - from app.etl_pipeline.etl_document import EtlRequest + from app.etl_pipeline.etl_document import EtlRequest, ProcessingMode from app.etl_pipeline.etl_pipeline_service import EtlPipelineService + mode = ProcessingMode.coerce(processing_mode) result = await EtlPipelineService(vision_llm=vision_llm).extract( - EtlRequest(file_path=file_path, filename=filename) + EtlRequest(file_path=file_path, filename=filename, processing_mode=mode) ) return result.markdown_content @@ -201,12 +206,15 @@ async def _compute_file_content_hash( search_space_id: int, *, vision_llm=None, + processing_mode: str = "basic", ) -> tuple[str, str]: """Read a file (via ETL if needed) and compute its content hash. Returns (content_text, content_hash). """ - content = await _read_file_content(file_path, filename, vision_llm=vision_llm) + content = await _read_file_content( + file_path, filename, vision_llm=vision_llm, processing_mode=processing_mode + ) return content, _content_hash(content, search_space_id) @@ -694,7 +702,7 @@ async def index_local_folder( continue try: - estimated_pages = await _check_page_limit_or_skip( + estimated_pages, _billable = await _check_page_limit_or_skip( page_limit_service, user_id, file_path_abs ) except PageLimitExceededError: @@ -730,7 +738,7 @@ async def index_local_folder( await create_version_snapshot(session, existing_document) else: try: - estimated_pages = await _check_page_limit_or_skip( + estimated_pages, _billable = await _check_page_limit_or_skip( page_limit_service, user_id, file_path_abs ) except PageLimitExceededError: @@ -1080,7 +1088,7 @@ async def _index_single_file( page_limit_service = PageLimitService(session) try: - estimated_pages = await _check_page_limit_or_skip( + estimated_pages, _billable = await _check_page_limit_or_skip( page_limit_service, user_id, str(full_path) ) except PageLimitExceededError as e: @@ -1271,6 +1279,7 @@ async def index_uploaded_files( file_mappings: list[dict], on_heartbeat_callback: HeartbeatCallbackType | None = None, use_vision_llm: bool = False, + processing_mode: str = "basic", ) -> tuple[int, int, str | None]: """Index files uploaded from the desktop app via temp paths. @@ -1281,12 +1290,16 @@ async def index_uploaded_files( Returns ``(indexed_count, failed_count, error_summary_or_none)``. """ + from app.etl_pipeline.etl_document import ProcessingMode + + mode = ProcessingMode.coerce(processing_mode) + 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)}, + metadata={"file_count": len(file_mappings), "processing_mode": mode.value}, ) try: @@ -1350,8 +1363,11 @@ async def index_uploaded_files( continue try: - estimated_pages = await _check_page_limit_or_skip( - page_limit_service, user_id, temp_path + estimated_pages, _billable_pages = await _check_page_limit_or_skip( + page_limit_service, + user_id, + temp_path, + page_multiplier=mode.page_multiplier, ) except PageLimitExceededError: logger.warning(f"Page limit exceeded, skipping: {relative_path}") @@ -1364,6 +1380,7 @@ async def index_uploaded_files( filename, search_space_id, vision_llm=vision_llm_instance, + processing_mode=mode.value, ) except Exception as e: logger.warning(f"Could not read {relative_path}: {e}") @@ -1429,8 +1446,9 @@ async def index_uploaded_files( final_pages = _compute_final_pages( page_limit_service, estimated_pages, len(content) ) + final_billable = final_pages * mode.page_multiplier await page_limit_service.update_page_usage( - user_id, final_pages, allow_exceed=True + user_id, final_billable, allow_exceed=True ) else: failed_count += 1 diff --git a/surfsense_backend/app/tasks/document_processors/file_processors.py b/surfsense_backend/app/tasks/document_processors/file_processors.py index 9364fa1cb..a2d1a6412 100644 --- a/surfsense_backend/app/tasks/document_processors/file_processors.py +++ b/surfsense_backend/app/tasks/document_processors/file_processors.py @@ -47,6 +47,7 @@ class _ProcessingContext: connector: dict | None = None notification: Notification | None = None use_vision_llm: bool = False + processing_mode: str = "basic" enable_summary: bool = field(init=False) def __post_init__(self) -> None: @@ -187,21 +188,28 @@ async def _process_non_document_upload(ctx: _ProcessingContext) -> Document | No async def _process_document_upload(ctx: _ProcessingContext) -> Document | None: """Route a document file to the configured ETL service via the unified pipeline.""" - from app.etl_pipeline.etl_document import EtlRequest + from app.etl_pipeline.etl_document import EtlRequest, ProcessingMode from app.etl_pipeline.etl_pipeline_service import EtlPipelineService from app.services.page_limit_service import PageLimitExceededError, PageLimitService + mode = ProcessingMode.coerce(ctx.processing_mode) page_limit_service = PageLimitService(ctx.session) estimated_pages = _estimate_pages_safe(page_limit_service, ctx.file_path) + billable_pages = estimated_pages * mode.page_multiplier await ctx.task_logger.log_task_progress( ctx.log_entry, f"Estimated {estimated_pages} pages for file: {ctx.filename}", - {"estimated_pages": estimated_pages, "file_type": "document"}, + { + "estimated_pages": estimated_pages, + "billable_pages": billable_pages, + "processing_mode": mode.value, + "file_type": "document", + }, ) try: - await page_limit_service.check_page_limit(ctx.user_id, estimated_pages) + await page_limit_service.check_page_limit(ctx.user_id, billable_pages) except PageLimitExceededError as e: await ctx.task_logger.log_task_failure( ctx.log_entry, @@ -212,6 +220,8 @@ async def _process_document_upload(ctx: _ProcessingContext) -> Document | None: "pages_used": e.pages_used, "pages_limit": e.pages_limit, "estimated_pages": estimated_pages, + "billable_pages": billable_pages, + "processing_mode": mode.value, }, ) with contextlib.suppress(Exception): @@ -225,6 +235,7 @@ async def _process_document_upload(ctx: _ProcessingContext) -> Document | None: file_path=ctx.file_path, filename=ctx.filename, estimated_pages=estimated_pages, + processing_mode=mode, ) ) @@ -246,7 +257,7 @@ async def _process_document_upload(ctx: _ProcessingContext) -> Document | None: if result: await page_limit_service.update_page_usage( - ctx.user_id, estimated_pages, allow_exceed=True + ctx.user_id, billable_pages, allow_exceed=True ) if ctx.connector: await update_document_from_connector(result, ctx.connector, ctx.session) @@ -259,6 +270,8 @@ async def _process_document_upload(ctx: _ProcessingContext) -> Document | None: "file_type": "document", "etl_service": etl_result.etl_service, "pages_processed": estimated_pages, + "billable_pages": billable_pages, + "processing_mode": mode.value, }, ) else: @@ -290,6 +303,7 @@ async def process_file_in_background( connector: dict | None = None, notification: Notification | None = None, use_vision_llm: bool = False, + processing_mode: str = "basic", ) -> Document | None: ctx = _ProcessingContext( session=session, @@ -302,6 +316,7 @@ async def process_file_in_background( connector=connector, notification=notification, use_vision_llm=use_vision_llm, + processing_mode=processing_mode, ) try: @@ -353,22 +368,25 @@ async def _extract_file_content( log_entry: Log, notification: Notification | None, use_vision_llm: bool = False, -) -> tuple[str, str]: + processing_mode: str = "basic", +) -> tuple[str, str, int]: """ Extract markdown content from a file regardless of type. Returns: - Tuple of (markdown_content, etl_service_name). + Tuple of (markdown_content, etl_service_name, billable_pages). """ - from app.etl_pipeline.etl_document import EtlRequest + from app.etl_pipeline.etl_document import EtlRequest, ProcessingMode from app.etl_pipeline.etl_pipeline_service import EtlPipelineService from app.etl_pipeline.file_classifier import ( FileCategory, classify_file as etl_classify, ) + mode = ProcessingMode.coerce(processing_mode) category = etl_classify(filename) estimated_pages = 0 + billable_pages = 0 if notification: stage_messages = { @@ -397,7 +415,8 @@ async def _extract_file_content( page_limit_service = PageLimitService(session) estimated_pages = _estimate_pages_safe(page_limit_service, file_path) - await page_limit_service.check_page_limit(user_id, estimated_pages) + billable_pages = estimated_pages * mode.page_multiplier + await page_limit_service.check_page_limit(user_id, billable_pages) vision_llm = None if use_vision_llm and category == FileCategory.IMAGE: @@ -410,21 +429,17 @@ async def _extract_file_content( file_path=file_path, filename=filename, estimated_pages=estimated_pages, + processing_mode=mode, ) ) - if category == FileCategory.DOCUMENT: - await page_limit_service.update_page_usage( - user_id, estimated_pages, allow_exceed=True - ) - with contextlib.suppress(Exception): os.unlink(file_path) if not result.markdown_content: raise RuntimeError(f"Failed to extract content from file: {filename}") - return result.markdown_content, result.etl_service + return result.markdown_content, result.etl_service, billable_pages async def process_file_in_background_with_document( @@ -440,12 +455,16 @@ async def process_file_in_background_with_document( notification: Notification | None = None, should_summarize: bool = False, use_vision_llm: bool = False, + processing_mode: str = "basic", ) -> Document | None: """ Process file and update existing pending document (2-phase pattern). Phase 1 (API layer): Created document with pending status. Phase 2 (this function): Process file and update document to ready/failed. + + Page usage is deferred until after dedup check and successful indexing + to avoid charging for duplicate or failed uploads. """ from app.indexing_pipeline.adapters.file_upload_adapter import ( UploadDocumentAdapter, @@ -458,8 +477,7 @@ async def process_file_in_background_with_document( doc_id = document.id try: - # Step 1: extract content - markdown_content, etl_service = await _extract_file_content( + markdown_content, etl_service, billable_pages = await _extract_file_content( file_path, filename, search_space_id, @@ -469,12 +487,12 @@ async def process_file_in_background_with_document( log_entry, notification, use_vision_llm=use_vision_llm, + processing_mode=processing_mode, ) if not markdown_content: raise RuntimeError(f"Failed to extract content from file: {filename}") - # Step 2: duplicate check content_hash = generate_content_hash(markdown_content, search_space_id) existing_by_content = await check_duplicate_document(session, content_hash) if existing_by_content and existing_by_content.id != doc_id: @@ -484,7 +502,6 @@ async def process_file_in_background_with_document( ) return None - # Step 3: index via pipeline if notification: await NotificationService.document_processing.notify_processing_progress( session, @@ -505,6 +522,14 @@ async def process_file_in_background_with_document( should_summarize=should_summarize, ) + if billable_pages > 0: + from app.services.page_limit_service import PageLimitService + + page_limit_service = PageLimitService(session) + await page_limit_service.update_page_usage( + user_id, billable_pages, allow_exceed=True + ) + await task_logger.log_task_success( log_entry, f"Successfully processed file: {filename}", @@ -512,6 +537,8 @@ async def process_file_in_background_with_document( "document_id": doc_id, "content_hash": content_hash, "file_type": etl_service, + "billable_pages": billable_pages, + "processing_mode": processing_mode, }, ) return document diff --git a/surfsense_backend/tests/integration/document_upload/conftest.py b/surfsense_backend/tests/integration/document_upload/conftest.py index f35d2e605..ff44e471a 100644 --- a/surfsense_backend/tests/integration/document_upload/conftest.py +++ b/surfsense_backend/tests/integration/document_upload/conftest.py @@ -70,6 +70,7 @@ class InlineTaskDispatcher: user_id: str, should_summarize: bool = False, use_vision_llm: bool = False, + processing_mode: str = "basic", ) -> None: from app.tasks.celery_tasks.document_tasks import ( _process_file_with_document, @@ -84,6 +85,7 @@ class InlineTaskDispatcher: user_id, should_summarize=should_summarize, use_vision_llm=use_vision_llm, + processing_mode=processing_mode, ) @@ -321,7 +323,9 @@ def _mock_etl_parsing(monkeypatch): # -- LlamaParse mock (external API) -------------------------------- - async def _fake_llamacloud_parse(file_path: str, estimated_pages: int) -> str: + async def _fake_llamacloud_parse( + file_path: str, estimated_pages: int, processing_mode: str = "basic" + ) -> str: _reject_empty(file_path) return _MOCK_ETL_MARKDOWN diff --git a/surfsense_backend/tests/unit/etl_pipeline/test_etl_pipeline_service.py b/surfsense_backend/tests/unit/etl_pipeline/test_etl_pipeline_service.py index bb8d7ef83..faee49711 100644 --- a/surfsense_backend/tests/unit/etl_pipeline/test_etl_pipeline_service.py +++ b/surfsense_backend/tests/unit/etl_pipeline/test_etl_pipeline_service.py @@ -739,3 +739,187 @@ async def test_extract_image_falls_back_to_document_without_vision_llm( assert result.markdown_content == "# OCR text from image" assert result.etl_service == "DOCLING" assert result.content_type == "document" + + +# --------------------------------------------------------------------------- +# Processing Mode enum tests +# --------------------------------------------------------------------------- + + +def test_processing_mode_coerce_basic(): + from app.etl_pipeline.etl_document import ProcessingMode + + assert ProcessingMode.coerce("basic") == ProcessingMode.BASIC + assert ProcessingMode.coerce("BASIC") == ProcessingMode.BASIC + assert ProcessingMode.coerce(None) == ProcessingMode.BASIC + assert ProcessingMode.coerce("invalid") == ProcessingMode.BASIC + + +def test_processing_mode_coerce_premium(): + from app.etl_pipeline.etl_document import ProcessingMode + + assert ProcessingMode.coerce("premium") == ProcessingMode.PREMIUM + assert ProcessingMode.coerce("PREMIUM") == ProcessingMode.PREMIUM + + +def test_processing_mode_page_multiplier(): + from app.etl_pipeline.etl_document import ProcessingMode + + assert ProcessingMode.BASIC.page_multiplier == 1 + assert ProcessingMode.PREMIUM.page_multiplier == 10 + + +def test_etl_request_default_processing_mode(): + from app.etl_pipeline.etl_document import ProcessingMode + + req = EtlRequest(file_path="/tmp/test.pdf", filename="test.pdf") + assert req.processing_mode == ProcessingMode.BASIC + + +def test_etl_request_premium_processing_mode(): + from app.etl_pipeline.etl_document import ProcessingMode + + req = EtlRequest( + file_path="/tmp/test.pdf", + filename="test.pdf", + processing_mode=ProcessingMode.PREMIUM, + ) + assert req.processing_mode == ProcessingMode.PREMIUM + + +# --------------------------------------------------------------------------- +# Azure DI model selection by processing mode +# --------------------------------------------------------------------------- + + +async def test_azure_di_basic_uses_prebuilt_read(tmp_path, mocker): + """Basic mode should use prebuilt-read model for Azure DI.""" + pdf_file = tmp_path / "report.pdf" + pdf_file.write_bytes(b"%PDF-1.4 fake content " * 10) + + mocker.patch("app.config.config.ETL_SERVICE", "LLAMACLOUD") + mocker.patch("app.config.config.LLAMA_CLOUD_API_KEY", "fake-key", create=True) + mocker.patch( + "app.config.config.AZURE_DI_ENDPOINT", + "https://fake.cognitiveservices.azure.com/", + create=True, + ) + mocker.patch("app.config.config.AZURE_DI_KEY", "fake-key", create=True) + + fake_client = _mock_azure_di(mocker, "# Azure basic") + _mock_llamacloud(mocker) + + from app.etl_pipeline.etl_document import ProcessingMode + + result = await EtlPipelineService().extract( + EtlRequest( + file_path=str(pdf_file), + filename="report.pdf", + processing_mode=ProcessingMode.BASIC, + ) + ) + + assert result.markdown_content == "# Azure basic" + call_args = fake_client.begin_analyze_document.call_args + assert call_args[0][0] == "prebuilt-read" + + +async def test_azure_di_premium_uses_prebuilt_layout(tmp_path, mocker): + """Premium mode should use prebuilt-layout model for Azure DI.""" + pdf_file = tmp_path / "report.pdf" + pdf_file.write_bytes(b"%PDF-1.4 fake content " * 10) + + mocker.patch("app.config.config.ETL_SERVICE", "LLAMACLOUD") + mocker.patch("app.config.config.LLAMA_CLOUD_API_KEY", "fake-key", create=True) + mocker.patch( + "app.config.config.AZURE_DI_ENDPOINT", + "https://fake.cognitiveservices.azure.com/", + create=True, + ) + mocker.patch("app.config.config.AZURE_DI_KEY", "fake-key", create=True) + + fake_client = _mock_azure_di(mocker, "# Azure premium") + _mock_llamacloud(mocker) + + from app.etl_pipeline.etl_document import ProcessingMode + + result = await EtlPipelineService().extract( + EtlRequest( + file_path=str(pdf_file), + filename="report.pdf", + processing_mode=ProcessingMode.PREMIUM, + ) + ) + + assert result.markdown_content == "# Azure premium" + call_args = fake_client.begin_analyze_document.call_args + assert call_args[0][0] == "prebuilt-layout" + + +# --------------------------------------------------------------------------- +# LlamaCloud tier selection by processing mode +# --------------------------------------------------------------------------- + + +async def test_llamacloud_basic_uses_cost_effective_tier(tmp_path, mocker): + """Basic mode should use cost_effective tier for LlamaCloud.""" + pdf_file = tmp_path / "report.pdf" + pdf_file.write_bytes(b"%PDF-1.4 fake content " * 10) + + mocker.patch("app.config.config.ETL_SERVICE", "LLAMACLOUD") + mocker.patch("app.config.config.LLAMA_CLOUD_API_KEY", "fake-key", create=True) + mocker.patch("app.config.config.AZURE_DI_ENDPOINT", None, create=True) + mocker.patch("app.config.config.AZURE_DI_KEY", None, create=True) + + fake_parser = _mock_llamacloud(mocker, "# Llama basic") + + llama_parse_cls = mocker.patch( + "llama_cloud_services.LlamaParse", return_value=fake_parser + ) + + from app.etl_pipeline.etl_document import ProcessingMode + + result = await EtlPipelineService().extract( + EtlRequest( + file_path=str(pdf_file), + filename="report.pdf", + estimated_pages=5, + processing_mode=ProcessingMode.BASIC, + ) + ) + + assert result.markdown_content == "# Llama basic" + call_kwargs = llama_parse_cls.call_args[1] + assert call_kwargs["tier"] == "cost_effective" + + +async def test_llamacloud_premium_uses_agentic_plus_tier(tmp_path, mocker): + """Premium mode should use agentic_plus tier for LlamaCloud.""" + pdf_file = tmp_path / "report.pdf" + pdf_file.write_bytes(b"%PDF-1.4 fake content " * 10) + + mocker.patch("app.config.config.ETL_SERVICE", "LLAMACLOUD") + mocker.patch("app.config.config.LLAMA_CLOUD_API_KEY", "fake-key", create=True) + mocker.patch("app.config.config.AZURE_DI_ENDPOINT", None, create=True) + mocker.patch("app.config.config.AZURE_DI_KEY", None, create=True) + + fake_parser = _mock_llamacloud(mocker, "# Llama premium") + + llama_parse_cls = mocker.patch( + "llama_cloud_services.LlamaParse", return_value=fake_parser + ) + + from app.etl_pipeline.etl_document import ProcessingMode + + result = await EtlPipelineService().extract( + EtlRequest( + file_path=str(pdf_file), + filename="report.pdf", + estimated_pages=5, + processing_mode=ProcessingMode.PREMIUM, + ) + ) + + assert result.markdown_content == "# Llama premium" + call_kwargs = llama_parse_cls.call_args[1] + assert call_kwargs["tier"] == "agentic_plus" diff --git a/surfsense_backend/tests/unit/test_error_contract.py b/surfsense_backend/tests/unit/test_error_contract.py new file mode 100644 index 000000000..8a1605dd1 --- /dev/null +++ b/surfsense_backend/tests/unit/test_error_contract.py @@ -0,0 +1,286 @@ +"""Unit tests for the structured error response contract. + +Validates that: +- Global exception handlers produce the backward-compatible error envelope. +- 5xx responses never leak raw internal exception text. +- X-Request-ID is propagated correctly. +- SurfSenseError, HTTPException, validation, and unhandled exceptions all + use the same response shape. +""" + +from __future__ import annotations + +import json + +import pytest +from fastapi import HTTPException +from starlette.testclient import TestClient + +from app.exceptions import ( + GENERIC_5XX_MESSAGE, + ISSUES_URL, + ConfigurationError, + ConnectorError, + DatabaseError, + ExternalServiceError, + ForbiddenError, + NotFoundError, + SurfSenseError, + ValidationError, +) + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Helpers - lightweight FastAPI app that re-uses the real global handlers +# --------------------------------------------------------------------------- + + +def _make_test_app(): + """Build a minimal FastAPI app with the same handlers as the real one.""" + from fastapi import FastAPI + from fastapi.exceptions import RequestValidationError + from pydantic import BaseModel + + from app.app import ( + RequestIDMiddleware, + _http_exception_handler, + _surfsense_error_handler, + _unhandled_exception_handler, + _validation_error_handler, + ) + + app = FastAPI() + app.add_middleware(RequestIDMiddleware) + app.add_exception_handler(SurfSenseError, _surfsense_error_handler) + app.add_exception_handler(RequestValidationError, _validation_error_handler) + app.add_exception_handler(HTTPException, _http_exception_handler) + app.add_exception_handler(Exception, _unhandled_exception_handler) + + @app.get("/ok") + async def ok(): + return {"status": "ok"} + + @app.get("/http-400") + async def raise_http_400(): + raise HTTPException(status_code=400, detail="Bad input") + + @app.get("/http-500") + async def raise_http_500(): + raise HTTPException(status_code=500, detail="secret db password leaked") + + @app.get("/surfsense-connector") + async def raise_connector(): + raise ConnectorError("GitHub API returned 401") + + @app.get("/surfsense-notfound") + async def raise_notfound(): + raise NotFoundError("Document #42 was not found") + + @app.get("/surfsense-forbidden") + async def raise_forbidden(): + raise ForbiddenError() + + @app.get("/surfsense-config") + async def raise_config(): + raise ConfigurationError() + + @app.get("/surfsense-db") + async def raise_db(): + raise DatabaseError() + + @app.get("/surfsense-external") + async def raise_external(): + raise ExternalServiceError() + + @app.get("/surfsense-validation") + async def raise_validation(): + raise ValidationError("Email is invalid") + + @app.get("/unhandled") + async def raise_unhandled(): + raise RuntimeError("should never reach the client") + + class Item(BaseModel): + name: str + count: int + + @app.post("/validated") + async def validated(item: Item): + return item.model_dump() + + return app + + +@pytest.fixture(scope="module") +def client(): + app = _make_test_app() + return TestClient(app, raise_server_exceptions=False) + + +# --------------------------------------------------------------------------- +# Envelope shape validation +# --------------------------------------------------------------------------- + + +def _assert_envelope(resp, expected_status: int): + """Every error response MUST contain the standard envelope.""" + assert resp.status_code == expected_status + body = resp.json() + assert "error" in body, f"Missing 'error' key: {body}" + assert "detail" in body, f"Missing legacy 'detail' key: {body}" + + err = body["error"] + assert isinstance(err["code"], str) and len(err["code"]) > 0 + assert isinstance(err["message"], str) and len(err["message"]) > 0 + assert err["status"] == expected_status + assert isinstance(err["request_id"], str) and len(err["request_id"]) > 0 + assert "timestamp" in err + assert err["report_url"] == ISSUES_URL + + # Legacy compat: detail mirrors message + assert body["detail"] == err["message"] + + return body + + +# --------------------------------------------------------------------------- +# X-Request-ID propagation +# --------------------------------------------------------------------------- + + +class TestRequestID: + def test_generated_when_missing(self, client): + resp = client.get("/ok") + assert "X-Request-ID" in resp.headers + assert resp.headers["X-Request-ID"].startswith("req_") + + def test_echoed_when_provided(self, client): + resp = client.get("/ok", headers={"X-Request-ID": "my-trace-123"}) + assert resp.headers["X-Request-ID"] == "my-trace-123" + + def test_present_in_error_response_body(self, client): + resp = client.get("/http-400", headers={"X-Request-ID": "trace-abc"}) + body = _assert_envelope(resp, 400) + assert body["error"]["request_id"] == "trace-abc" + + +# --------------------------------------------------------------------------- +# HTTPException handling +# --------------------------------------------------------------------------- + + +class TestHTTPExceptionHandler: + def test_400_preserves_detail(self, client): + body = _assert_envelope(client.get("/http-400"), 400) + assert body["error"]["message"] == "Bad input" + assert body["error"]["code"] == "BAD_REQUEST" + + def test_500_sanitizes_detail(self, client): + body = _assert_envelope(client.get("/http-500"), 500) + assert "secret" not in body["error"]["message"] + assert "password" not in body["error"]["message"] + assert body["error"]["message"] == GENERIC_5XX_MESSAGE + assert body["error"]["code"] == "INTERNAL_ERROR" + + +# --------------------------------------------------------------------------- +# SurfSenseError hierarchy +# --------------------------------------------------------------------------- + + +class TestSurfSenseErrorHandler: + def test_connector_error(self, client): + body = _assert_envelope(client.get("/surfsense-connector"), 502) + assert body["error"]["code"] == "CONNECTOR_ERROR" + assert "GitHub" in body["error"]["message"] + + def test_not_found_error(self, client): + body = _assert_envelope(client.get("/surfsense-notfound"), 404) + assert body["error"]["code"] == "NOT_FOUND" + + def test_forbidden_error(self, client): + body = _assert_envelope(client.get("/surfsense-forbidden"), 403) + assert body["error"]["code"] == "FORBIDDEN" + + def test_configuration_error(self, client): + body = _assert_envelope(client.get("/surfsense-config"), 500) + assert body["error"]["code"] == "CONFIGURATION_ERROR" + + def test_database_error(self, client): + body = _assert_envelope(client.get("/surfsense-db"), 500) + assert body["error"]["code"] == "DATABASE_ERROR" + + def test_external_service_error(self, client): + body = _assert_envelope(client.get("/surfsense-external"), 502) + assert body["error"]["code"] == "EXTERNAL_SERVICE_ERROR" + + def test_validation_error_custom(self, client): + body = _assert_envelope(client.get("/surfsense-validation"), 422) + assert body["error"]["code"] == "VALIDATION_ERROR" + + +# --------------------------------------------------------------------------- +# Unhandled exception (catch-all) +# --------------------------------------------------------------------------- + + +class TestUnhandledException: + def test_returns_500_generic_message(self, client): + body = _assert_envelope(client.get("/unhandled"), 500) + assert body["error"]["code"] == "INTERNAL_ERROR" + assert body["error"]["message"] == GENERIC_5XX_MESSAGE + assert "should never reach" not in json.dumps(body) + + +# --------------------------------------------------------------------------- +# RequestValidationError (pydantic / FastAPI) +# --------------------------------------------------------------------------- + + +class TestValidationErrorHandler: + def test_missing_fields(self, client): + resp = client.post("/validated", json={}) + body = _assert_envelope(resp, 422) + assert body["error"]["code"] == "VALIDATION_ERROR" + assert "required" in body["error"]["message"].lower() + + def test_wrong_type(self, client): + resp = client.post("/validated", json={"name": "test", "count": "not-a-number"}) + body = _assert_envelope(resp, 422) + assert body["error"]["code"] == "VALIDATION_ERROR" + + +# --------------------------------------------------------------------------- +# SurfSenseError class hierarchy unit tests +# --------------------------------------------------------------------------- + + +class TestSurfSenseErrorClasses: + def test_base_defaults(self): + err = SurfSenseError() + assert err.code == "INTERNAL_ERROR" + assert err.status_code == 500 + assert err.safe_for_client is True + + def test_connector_error(self): + err = ConnectorError("fail") + assert err.code == "CONNECTOR_ERROR" + assert err.status_code == 502 + + def test_database_error(self): + err = DatabaseError() + assert err.status_code == 500 + + def test_not_found_error(self): + err = NotFoundError() + assert err.status_code == 404 + + def test_forbidden_error(self): + err = ForbiddenError() + assert err.status_code == 403 + + def test_custom_code(self): + err = ConnectorError("x", code="GITHUB_TOKEN_EXPIRED") + assert err.code == "GITHUB_TOKEN_EXPIRED" diff --git a/surfsense_web/app/(home)/blog/[slug]/page.tsx b/surfsense_web/app/(home)/blog/[slug]/page.tsx index ce51ae703..79787b774 100644 --- a/surfsense_web/app/(home)/blog/[slug]/page.tsx +++ b/surfsense_web/app/(home)/blog/[slug]/page.tsx @@ -42,9 +42,7 @@ export async function generateMetadata(props: { params: Promise<{ slug: string }>; }): Promise { const { slug } = await props.params; - const page = (source.getPages() as BlogPageItem[]).find( - (p) => p.slugs.join("/") === slug, - ); + const page = (source.getPages() as BlogPageItem[]).find((p) => p.slugs.join("/") === slug); if (!page) return {}; @@ -72,13 +70,9 @@ export async function generateMetadata(props: { }; } -export default async function BlogPostPage(props: { - params: Promise<{ slug: string }>; -}) { +export default async function BlogPostPage(props: { params: Promise<{ slug: string }> }) { const { slug } = await props.params; - const page = (source.getPages() as BlogPageItem[]).find( - (p) => p.slugs.join("/") === slug, - ); + const page = (source.getPages() as BlogPageItem[]).find((p) => p.slugs.join("/") === slug); if (!page) notFound(); diff --git a/surfsense_web/app/(home)/blog/blog-magazine.tsx b/surfsense_web/app/(home)/blog/blog-magazine.tsx index 6ffac71bb..96c7f6789 100644 --- a/surfsense_web/app/(home)/blog/blog-magazine.tsx +++ b/surfsense_web/app/(home)/blog/blog-magazine.tsx @@ -1,10 +1,10 @@ "use client"; -import { Container } from "@/components/container"; import { format } from "date-fns"; +import FuzzySearch from "fuzzy-search"; import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; -import FuzzySearch from "fuzzy-search"; +import { Container } from "@/components/container"; import type { BlogEntry } from "./page"; function truncate(text: string, length: number) { @@ -24,8 +24,10 @@ function SearchIcon({ className }: { className?: string }) { strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" - aria-hidden + aria-hidden="true" + role="img" > + Search @@ -100,9 +102,7 @@ function MagazineFeatured({ blog }: { blog: BlogEntry }) { {blog.author} · - + @@ -124,7 +124,7 @@ function MagazineSearchGrid({ new FuzzySearch(allBlogs, ["title", "description"], { caseSensitive: false, }), - [allBlogs], + [allBlogs] ); const [results, setResults] = useState(allBlogs); @@ -192,9 +192,7 @@ function MagazineCard({ blog }: { blog: BlogEntry }) { className="h-full w-full object-cover transition duration-300 group-hover/card:scale-105" /> ) : ( -
- No image -
+
No image
)}
@@ -218,9 +216,7 @@ function MagazineCard({ blog }: { blog: BlogEntry }) { height={24} className="h-6 w-6 rounded-full object-cover" /> - - {blog.author} - + {blog.author}
diff --git a/surfsense_web/app/(home)/blog/page.tsx b/surfsense_web/app/(home)/blog/page.tsx index 80c83bc3f..2a29c2944 100644 --- a/surfsense_web/app/(home)/blog/page.tsx +++ b/surfsense_web/app/(home)/blog/page.tsx @@ -5,8 +5,7 @@ import { BlogWithSearchMagazine } from "./blog-magazine"; export const metadata: Metadata = { title: "Blog | SurfSense - AI Search & Knowledge Management", - description: - "Product updates, tutorials, and tips from the SurfSense team.", + description: "Product updates, tutorials, and tips from the SurfSense team.", alternates: { canonical: "https://surfsense.com/blog", }, @@ -53,9 +52,7 @@ export default async function BlogPage() { author: page.data.author ?? "SurfSense Team", authorAvatar: page.data.authorAvatar ?? "/logo.png", })) - .sort( - (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), - ); + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); return ; } diff --git a/surfsense_web/app/(home)/contact/page.tsx b/surfsense_web/app/(home)/contact/page.tsx index 0fd819a08..75cec0669 100644 --- a/surfsense_web/app/(home)/contact/page.tsx +++ b/surfsense_web/app/(home)/contact/page.tsx @@ -3,7 +3,8 @@ import { ContactFormGridWithDetails } from "@/components/contact/contact-form"; export const metadata: Metadata = { title: "Contact | SurfSense", - description: "Get in touch with the SurfSense team for enterprise AI search, knowledge management, or partnership inquiries.", + description: + "Get in touch with the SurfSense team for enterprise AI search, knowledge management, or partnership inquiries.", alternates: { canonical: "https://surfsense.com/contact", }, diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx index e3c34306f..608543b37 100644 --- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -174,31 +174,31 @@ export function LocalLoginForm() { -
- setPassword(e.target.value)} - className={`block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${ - error.title - ? "border-destructive focus:border-destructive focus:ring-destructive/40" - : "border-border focus:border-primary focus:ring-primary/40" - }`} - disabled={isLoggingIn} - /> - -
+
+ setPassword(e.target.value)} + className={`block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${ + error.title + ? "border-destructive focus:border-destructive focus:ring-destructive/40" + : "border-border focus:border-primary focus:ring-primary/40" + }`} + disabled={isLoggingIn} + /> + +
+ + {(error.digest || error.code || error.requestId) && ( +
+ {error.code && Code: {error.code}} + {error.requestId && ID: {error.requestId}} + {error.digest && Digest: {error.digest}} +
+ )} + +
+ + + + Report Issue + +
); } diff --git a/surfsense_web/app/global-error.tsx b/surfsense_web/app/global-error.tsx index dc4763c1e..add207cfd 100644 --- a/surfsense_web/app/global-error.tsx +++ b/surfsense_web/app/global-error.tsx @@ -2,9 +2,32 @@ import "./globals.css"; import posthog from "posthog-js"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; +const ISSUES_URL = "https://github.com/MODSetter/SurfSense/issues/new"; + +function buildBasicIssueUrl(error: Error & { digest?: string }) { + const params = new URLSearchParams(); + const lines = [ + "## Bug Report", + "", + "**Describe what happened:**", + "", + "", + "## Diagnostics (auto-filled)", + "", + `- **Error:** ${error.message}`, + ...(error.digest ? [`- **Digest:** \`${error.digest}\``] : []), + `- **Timestamp:** ${new Date().toISOString()}`, + `- **Page:** \`${typeof window !== "undefined" ? window.location.pathname : "unknown"}\``, + `- **User Agent:** \`${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}\``, + ]; + params.set("body", lines.join("\n")); + params.set("labels", "bug"); + return `${ISSUES_URL}?${params.toString()}`; +} + export default function GlobalError({ error, reset, @@ -16,13 +39,32 @@ export default function GlobalError({ posthog.captureException(error); }, [error]); + const issueUrl = useMemo(() => buildBasicIssueUrl(error), [error]); + return ( -
+

Something went wrong

-

An unexpected error occurred.

- +

+ An unexpected error occurred. Please try again, or report this issue if it persists. +

+ + {error.digest && ( +

Digest: {error.digest}

+ )} + +
+ + + Report Issue + +
diff --git a/surfsense_web/app/layout.tsx b/surfsense_web/app/layout.tsx index d9a133dfa..4e6930094 100644 --- a/surfsense_web/app/layout.tsx +++ b/surfsense_web/app/layout.tsx @@ -7,13 +7,17 @@ import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvi import { I18nProvider } from "@/components/providers/I18nProvider"; import { PostHogProvider } from "@/components/providers/PostHogProvider"; import { ZeroProvider } from "@/components/providers/ZeroProvider"; +import { + OrganizationJsonLd, + SoftwareApplicationJsonLd, + WebSiteJsonLd, +} from "@/components/seo/json-ld"; import { ThemeProvider } from "@/components/theme/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { LocaleProvider } from "@/contexts/LocaleContext"; import { PlatformProvider } from "@/contexts/platform-context"; import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider"; import { cn } from "@/lib/utils"; -import { OrganizationJsonLd, SoftwareApplicationJsonLd, WebSiteJsonLd } from "@/components/seo/json-ld"; const roboto = Roboto({ subsets: ["latin"], diff --git a/surfsense_web/app/not-found.tsx b/surfsense_web/app/not-found.tsx index de4906876..1a5d47afd 100644 --- a/surfsense_web/app/not-found.tsx +++ b/surfsense_web/app/not-found.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; export const metadata: Metadata = { title: "Page Not Found | SurfSense", - description: "The page you're looking for doesn't exist. Explore SurfSense - open source enterprise AI search and knowledge management.", + description: + "The page you're looking for doesn't exist. Explore SurfSense - open source enterprise AI search and knowledge management.", }; export default function NotFound() { diff --git a/surfsense_web/components/announcements/AnnouncementCard.tsx b/surfsense_web/components/announcements/AnnouncementCard.tsx index f8021f8e3..ea0288b43 100644 --- a/surfsense_web/components/announcements/AnnouncementCard.tsx +++ b/surfsense_web/components/announcements/AnnouncementCard.tsx @@ -4,13 +4,7 @@ import { Bell, ExternalLink, Info, type LucideIcon, Rocket, Wrench, Zap } from " import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, -} from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader } from "@/components/ui/card"; import type { AnnouncementCategory } from "@/contracts/types/announcement.types"; import type { AnnouncementWithState } from "@/hooks/use-announcements"; import { formatRelativeDate } from "@/lib/format-date"; @@ -66,7 +60,9 @@ export function AnnouncementCard({ announcement }: { announcement: AnnouncementW
-

{announcement.title}

+

+ {announcement.title} +

{config.label} diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 8d3e90b7d..f159d42d2 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -33,11 +33,17 @@ import { useAllCitationMetadata, } from "@/components/assistant-ui/citation-metadata-context"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { useTokenUsage } from "@/components/assistant-ui/token-usage-context"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; import type { SerializableCitation } from "@/components/tool-ui/citation"; +import { + openSafeNavigationHref, + resolveSafeNavigationHref, +} from "@/components/tool-ui/shared/media"; +import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, @@ -46,14 +52,11 @@ import { DrawerTitle, } from "@/components/ui/drawer"; import { DropdownMenuLabel } from "@/components/ui/dropdown-menu"; -import { Button } from "@/components/ui/button"; import { useComments } from "@/hooks/use-comments"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI } from "@/hooks/use-platform"; -import { useTokenUsage } from "@/components/assistant-ui/token-usage-context"; import { getProviderIcon } from "@/lib/provider-icons"; import { cn } from "@/lib/utils"; -import { openSafeNavigationHref, resolveSafeNavigationHref } from "@/components/tool-ui/shared/media"; // Captured once at module load — survives client-side navigations that strip the query param. const IS_QUICK_ASSIST_WINDOW = @@ -440,7 +443,11 @@ const MessageInfoDropdown: FC = () => { models.map(([model, counts]) => { const { name, icon } = resolveModel(model); return ( - e.preventDefault()}> + e.preventDefault()} + > {icon} {name} @@ -452,7 +459,10 @@ const MessageInfoDropdown: FC = () => { ); }) ) : ( - e.preventDefault()}> + e.preventDefault()} + > {usage.total_tokens.toLocaleString()} tokens diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx index bea5d12e8..e19600ab2 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx @@ -282,7 +282,10 @@ export const ConnectorEditView: FC = ({ connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || connector.connector_type === "DROPBOX_CONNECTOR" || connector.connector_type === "ONEDRIVE_CONNECTOR") && ( - + )} {/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx index cb7438cde..13c257004 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx @@ -168,7 +168,10 @@ export const IndexingConfigurationView: FC = ({ config.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || config.connectorType === "DROPBOX_CONNECTOR" || config.connectorType === "ONEDRIVE_CONNECTOR") && ( - + )} {/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */} diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 03922c997..caa85ba2d 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -10,11 +10,17 @@ import { updateConnectorMutationAtom, } from "@/atoms/connectors/connector-mutation.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; +import { + folderWatchDialogOpenAtom, + folderWatchInitialFolderAtom, +} from "@/atoms/folder-sync/folder-sync.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { EnumConnectorName } from "@/contracts/enums/connector"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { searchSourceConnector } from "@/contracts/types/connector.types"; +import { usePlatform } from "@/hooks/use-platform"; import { authenticatedFetch } from "@/lib/auth-utils"; +import { isSelfHosted } from "@/lib/env-config"; import { trackConnectorConnected, trackConnectorDeleted, @@ -33,12 +39,6 @@ import { OAUTH_CONNECTORS, OTHER_CONNECTORS, } from "../constants/connector-constants"; -import { usePlatform } from "@/hooks/use-platform"; -import { isSelfHosted } from "@/lib/env-config"; -import { - folderWatchDialogOpenAtom, - folderWatchInitialFolderAtom, -} from "@/atoms/folder-sync/folder-sync.atoms"; import { dateRangeSchema, @@ -73,7 +73,6 @@ export const useConnectorDialog = () => { const { isDesktop } = usePlatform(); const selfHosted = isSelfHosted(); - // Use global atom for dialog open state so it can be controlled from anywhere const [isOpen, setIsOpen] = useAtom(connectorDialogOpenAtom); const [activeTab, setActiveTab] = useState("all"); @@ -463,10 +462,16 @@ export const useConnectorDialog = () => { setConnectingConnectorType(connectorType); }, - [searchSpaceId, selfHosted, isDesktop, setIsOpen, setFolderWatchOpen, setFolderWatchInitialFolder] + [ + searchSpaceId, + selfHosted, + isDesktop, + setIsOpen, + setFolderWatchOpen, + setFolderWatchInitialFolder, + ] ); - // Handle submitting connect form const handleSubmitConnectForm = useCallback( async ( @@ -787,8 +792,8 @@ export const useConnectorDialog = () => { const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined; // Update connector with summary, periodic sync settings, and config changes - if (enableSummary || enableVisionLlm || periodicEnabled || indexingConnectorConfig) { - const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : undefined; + if (enableSummary || enableVisionLlm || periodicEnabled || indexingConnectorConfig) { + const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : undefined; await updateConnector({ id: indexingConfig.connectorId, data: { diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx index eb161f485..7a29dd5ca 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx @@ -2,12 +2,12 @@ import { Search, Unplug } from "lucide-react"; import type { FC } from "react"; -import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { TabsContent } from "@/components/ui/tabs"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels"; import { cn } from "@/lib/utils"; import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants"; import { getDocumentCountForConnector } from "../utils/connector-document-mapping"; diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index dcb1e3e9e..8d60e2c5c 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -60,7 +60,6 @@ import { } from "@/components/assistant-ui/inline-mention-editor"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { UserMessage } from "@/components/assistant-ui/user-message"; -import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events"; import { DocumentMentionPicker, type DocumentMentionPickerRef, @@ -90,6 +89,7 @@ import { useBatchCommentsPreload } from "@/hooks/use-comments"; import { useCommentsSync } from "@/hooks/use-comments-sync"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI } from "@/hooks/use-platform"; +import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events"; import { cn } from "@/lib/utils"; const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs"; @@ -132,9 +132,9 @@ const ThreadContent: FC = () => { style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }} > - !thread.isEmpty}> - - + !thread.isEmpty}> + + diff --git a/surfsense_web/components/assistant-ui/token-usage-context.tsx b/surfsense_web/components/assistant-ui/token-usage-context.tsx index 8b82f33ff..b3f71ab21 100644 --- a/surfsense_web/components/assistant-ui/token-usage-context.tsx +++ b/surfsense_web/components/assistant-ui/token-usage-context.tsx @@ -1,13 +1,26 @@ "use client"; -import { createContext, useContext, useCallback, useSyncExternalStore, type FC, type ReactNode } from "react"; +import { + createContext, + type FC, + type ReactNode, + useCallback, + useContext, + useSyncExternalStore, +} from "react"; export interface TokenUsageData { prompt_tokens: number; completion_tokens: number; total_tokens: number; - usage?: Record; - model_breakdown?: Record; + usage?: Record< + string, + { prompt_tokens: number; completion_tokens: number; total_tokens: number } + >; + model_breakdown?: Record< + string, + { prompt_tokens: number; completion_tokens: number; total_tokens: number } + >; } type Listener = () => void; @@ -51,9 +64,10 @@ class TokenUsageStore { const TokenUsageContext = createContext(null); -export const TokenUsageProvider: FC<{ store: TokenUsageStore; children: ReactNode }> = ({ store, children }) => ( - {children} -); +export const TokenUsageProvider: FC<{ store: TokenUsageStore; children: ReactNode }> = ({ + store, + children, +}) => {children}; export function useTokenUsageStore(): TokenUsageStore { const store = useContext(TokenUsageContext); @@ -65,11 +79,11 @@ export function useTokenUsage(messageId: string | undefined): TokenUsageData | u const store = useContext(TokenUsageContext); const getSnapshot = useCallback( () => (store && messageId ? store.get(messageId) : undefined), - [store, messageId], + [store, messageId] ); const subscribe = useCallback( (onStoreChange: () => void) => (store ? store.subscribe(onStoreChange) : () => {}), - [store], + [store] ); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } diff --git a/surfsense_web/components/chat-comments/comment-item/comment-item.tsx b/surfsense_web/components/chat-comments/comment-item/comment-item.tsx index 359439d07..03c6c5675 100644 --- a/surfsense_web/components/chat-comments/comment-item/comment-item.tsx +++ b/surfsense_web/components/chat-comments/comment-item/comment-item.tsx @@ -6,10 +6,10 @@ import { useEffect, useRef, useState } from "react"; import { clearTargetCommentIdAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; +import { convertRenderedToDisplay } from "@/lib/comments/utils"; import { cn } from "@/lib/utils"; import { CommentComposer } from "../comment-composer/comment-composer"; import { CommentActions } from "./comment-actions"; -import { convertRenderedToDisplay } from "@/lib/comments/utils"; import type { CommentItemProps } from "./types"; function getInitials(name: string | null, email: string): string { @@ -70,7 +70,6 @@ function formatTimestamp(dateString: string): string { ); } - function renderMentions(content: string): React.ReactNode { // Match @{DisplayName} format from backend const mentionPattern = /@\{([^}]+)\}/g; diff --git a/surfsense_web/components/contact/contact-form.tsx b/surfsense_web/components/contact/contact-form.tsx index 91889ee00..09b2c30a1 100644 --- a/surfsense_web/components/contact/contact-form.tsx +++ b/surfsense_web/components/contact/contact-form.tsx @@ -16,9 +16,9 @@ export function ContactFormGridWithDetails() {
-

- Contact -

+

+ Contact +

We'd love to hear from you!

diff --git a/surfsense_web/components/documents/DocumentNode.tsx b/surfsense_web/components/documents/DocumentNode.tsx index 478037520..edaaba4b8 100644 --- a/surfsense_web/components/documents/DocumentNode.tsx +++ b/surfsense_web/components/documents/DocumentNode.tsx @@ -86,8 +86,7 @@ export const DocumentNode = React.memo(function DocumentNode({ const isProcessing = statusState === "pending" || statusState === "processing"; const isUnavailable = isProcessing || isFailed; const isSelectable = !isUnavailable; - const isEditable = - EDITABLE_DOCUMENT_TYPES.has(doc.document_type) && !isUnavailable; + const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type) && !isUnavailable; const handleCheckChange = useCallback(() => { if (isSelectable) { @@ -261,38 +260,38 @@ export const DocumentNode = React.memo(function DocumentNode({ className="w-40" onClick={(e) => e.stopPropagation()} > - onPreview(doc)} disabled={isUnavailable}> - - Open - - {isEditable && ( - onEdit(doc)}> - - Edit + onPreview(doc)} disabled={isUnavailable}> + + Open - )} - onMove(doc)}> - - Move to... - - {onExport && ( - - - - Export - - - - - - )} - {onVersionHistory && isVersionableType(doc.document_type) && ( - onVersionHistory(doc)}> - - Versions + {isEditable && ( + onEdit(doc)}> + + Edit + + )} + onMove(doc)}> + + Move to... - )} - onDelete(doc)}> + {onExport && ( + + + + Export + + + + + + )} + {onVersionHistory && isVersionableType(doc.document_type) && ( + onVersionHistory(doc)}> + + Versions + + )} + onDelete(doc)}> Delete @@ -304,38 +303,38 @@ export const DocumentNode = React.memo(function DocumentNode({ {contextMenuOpen && ( e.stopPropagation()}> - onPreview(doc)} disabled={isUnavailable}> - - Open - - {isEditable && ( - onEdit(doc)}> - - Edit + onPreview(doc)} disabled={isUnavailable}> + + Open - )} - onMove(doc)}> - - Move to... - - {onExport && ( - - - - Export - - - - - - )} - {onVersionHistory && isVersionableType(doc.document_type) && ( - onVersionHistory(doc)}> - - Versions + {isEditable && ( + onEdit(doc)}> + + Edit + + )} + onMove(doc)}> + + Move to... - )} - onDelete(doc)}> + {onExport && ( + + + + Export + + + + + + )} + {onVersionHistory && isVersionableType(doc.document_type) && ( + onVersionHistory(doc)}> + + Versions + + )} + onDelete(doc)}> Delete diff --git a/surfsense_web/components/documents/DocumentTypeIcon.tsx b/surfsense_web/components/documents/DocumentTypeIcon.tsx index 4c6507081..026fb46f1 100644 --- a/surfsense_web/components/documents/DocumentTypeIcon.tsx +++ b/surfsense_web/components/documents/DocumentTypeIcon.tsx @@ -10,7 +10,6 @@ export function getDocumentTypeIcon(type: string, className?: string): React.Rea return getConnectorIcon(type, className); } - export function DocumentTypeChip({ type, className }: { type: string; className?: string }) { const icon = getDocumentTypeIcon(type, "h-4 w-4"); const fullLabel = getDocumentTypeLabel(type); diff --git a/surfsense_web/components/documents/DocumentsFilters.tsx b/surfsense_web/components/documents/DocumentsFilters.tsx index a5ee57703..948ea53b3 100644 --- a/surfsense_web/components/documents/DocumentsFilters.tsx +++ b/surfsense_web/components/documents/DocumentsFilters.tsx @@ -12,10 +12,10 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Spinner } from "@/components/ui/spinner"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; -import { getDocumentTypeIcon } from "./DocumentTypeIcon"; import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels"; +import { cn } from "@/lib/utils"; +import { getDocumentTypeIcon } from "./DocumentTypeIcon"; export function DocumentsFilters({ typeCounts: typeCountsRecord, @@ -81,18 +81,18 @@ export function DocumentsFilters({ { - e.preventDefault(); - onCreateFolder(); + className="h-9 w-9 shrink-0 border-sidebar-border text-muted-foreground hover:text-foreground hover:border-sidebar-border bg-sidebar" + onClick={(e) => { + e.preventDefault(); + onCreateFolder(); }} > New folder - - )} + + )} {onToggleAiSort && ( @@ -114,9 +114,9 @@ export function DocumentsFilters({ aria-label={aiSortEnabled ? "Disable AI sort" : "Enable AI sort"} aria-pressed={aiSortEnabled} > - {aiSortBusy ? ( - - ) : aiSortEnabled ? ( + {aiSortBusy ? ( + + ) : aiSortEnabled ? ( ) : ( diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx index ca833949c..9b7a393d8 100644 --- a/surfsense_web/components/documents/FolderTreeView.tsx +++ b/surfsense_web/components/documents/FolderTreeView.tsx @@ -247,10 +247,8 @@ export function FolderTreeView({ function renderLevel(parentId: number | null, depth: number): React.ReactNode[] { const key = parentId ?? "root"; const childFolders = (foldersByParent[key] ?? []).slice().sort((a, b) => { - const aIsDate = - a.metadata?.ai_sort === true && a.metadata?.ai_sort_level === 2; - const bIsDate = - b.metadata?.ai_sort === true && b.metadata?.ai_sort_level === 2; + const aIsDate = a.metadata?.ai_sort === true && a.metadata?.ai_sort_level === 2; + const bIsDate = b.metadata?.ai_sort === true && b.metadata?.ai_sort_level === 2; if (aIsDate && bIsDate) { return b.name.localeCompare(a.name); } diff --git a/surfsense_web/components/editor/utils/escape-mdx.ts b/surfsense_web/components/editor/utils/escape-mdx.ts index 87470020c..cd5294b11 100644 --- a/surfsense_web/components/editor/utils/escape-mdx.ts +++ b/surfsense_web/components/editor/utils/escape-mdx.ts @@ -16,9 +16,7 @@ const FENCED_OR_INLINE_CODE = /(```[\s\S]*?```|`[^`\n]+`)/g; // - becomes a thematic break (---) // - All other HTML comments are removed function stripHtmlComments(md: string): string { - return md - .replace(//gi, "\n---\n") - .replace(//g, ""); + return md.replace(//gi, "\n---\n").replace(//g, ""); } // Convert
...
blocks to plain text blockquotes. diff --git a/surfsense_web/components/homepage/github-stars-badge.tsx b/surfsense_web/components/homepage/github-stars-badge.tsx index 4c3ea2efa..e6771d22a 100644 --- a/surfsense_web/components/homepage/github-stars-badge.tsx +++ b/surfsense_web/components/homepage/github-stars-badge.tsx @@ -5,8 +5,8 @@ import { useQuery } from "@tanstack/react-query"; import { motion, useMotionValue, useSpring } from "motion/react"; import * as React from "react"; import { Skeleton } from "@/components/ui/skeleton"; -import { cn } from "@/lib/utils"; import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { cn } from "@/lib/utils"; // --------------------------------------------------------------------------- // Per-digit scrolling wheel @@ -249,10 +249,7 @@ function NavbarGitHubStars({ const { data: stars = 0, isLoading } = useQuery({ queryKey: cacheKeys.github.repoStars(username, repo), queryFn: async ({ signal }) => { - const res = await fetch( - `https://api.github.com/repos/${username}/${repo}`, - { signal }, - ); + const res = await fetch(`https://api.github.com/repos/${username}/${repo}`, { signal }); const data = await res.json(); if (data && typeof data.stargazers_count === "number") { return data.stargazers_count as number; diff --git a/surfsense_web/components/homepage/hero-section.tsx b/surfsense_web/components/homepage/hero-section.tsx index f6406f33e..ce0074042 100644 --- a/surfsense_web/components/homepage/hero-section.tsx +++ b/surfsense_web/components/homepage/hero-section.tsx @@ -153,13 +153,14 @@ export function HeroSection() {
-

- A free, open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free. -

+

+ A free, open source NotebookLM alternative for teams with no data limits. Use ChatGPT, + Claude AI, and any AI model for free. +

diff --git a/surfsense_web/components/homepage/navbar.tsx b/surfsense_web/components/homepage/navbar.tsx index 2684ff047..289c0a0be 100644 --- a/surfsense_web/components/homepage/navbar.tsx +++ b/surfsense_web/components/homepage/navbar.tsx @@ -10,8 +10,8 @@ import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; import { cn } from "@/lib/utils"; interface NavItem { - name: string; - link: string; + name: string; + link: string; } interface NavbarProps { @@ -20,15 +20,15 @@ interface NavbarProps { } interface DesktopNavProps { - navItems: NavItem[]; - isScrolled: boolean; - scrolledBgClassName?: string; + navItems: NavItem[]; + isScrolled: boolean; + scrolledBgClassName?: string; } interface MobileNavProps { - navItems: NavItem[]; - isScrolled: boolean; - scrolledBgClassName?: string; + navItems: NavItem[]; + isScrolled: boolean; + scrolledBgClassName?: string; } export const Navbar = ({ scrolledBgClassName }: NavbarProps = {}) => { diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 71f2c5f68..04f45f5b5 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -167,7 +167,6 @@ export function DocumentsSidebar({ setFolderWatchOpen(true); }, [setWatchInitialFolder, setFolderWatchOpen]); - const refreshWatchedIds = useCallback(async () => { if (!electronAPI?.getWatchedFolders) return; const api = electronAPI; @@ -675,10 +674,10 @@ export function DocumentsSidebar({ function collectSubtreeDocs(parentId: number): DocumentNodeDoc[] { const directDocs = (treeDocuments ?? []).filter( (d) => - d.folderId === parentId && - d.status?.state !== "pending" && - d.status?.state !== "processing" && - d.status?.state !== "failed" + d.folderId === parentId && + d.status?.state !== "pending" && + d.status?.state !== "processing" && + d.status?.state !== "failed" ); const childFolders = foldersByParent[String(parentId)] ?? []; const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id)); @@ -1123,16 +1122,14 @@ export function DocumentsSidebar({ Enable AI File Sorting? - All documents in this search space will be organized into folders by - connector type, date, and AI-generated categories. New documents will - also be sorted automatically. You can disable this at any time. + All documents in this search space will be organized into folders by connector type, + date, and AI-generated categories. New documents will also be sorted automatically. + You can disable this at any time. Cancel - - Enable - + Enable diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 5d8b530c0..65946487e 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -22,8 +22,6 @@ import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; -import { convertRenderedToDisplay } from "@/lib/comments/utils"; -import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; @@ -57,6 +55,8 @@ import { useDebouncedValue } from "@/hooks/use-debounced-value"; import type { InboxItem } from "@/hooks/use-inbox"; import { useMediaQuery } from "@/hooks/use-media-query"; import { notificationsApiService } from "@/lib/apis/notifications-api.service"; +import { convertRenderedToDisplay } from "@/lib/comments/utils"; +import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cn } from "@/lib/utils"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx index 661f76ed3..dbfdf0304 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx @@ -5,7 +5,6 @@ import { useCallback, useEffect } from "react"; import { useMediaQuery } from "@/hooks/use-media-query"; import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events"; - interface SidebarSlideOutPanelProps { open: boolean; onOpenChange: (open: boolean) => void; diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index f2985278d..178982a7d 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -67,13 +67,7 @@ export const DocumentMentionPicker = forwardRef< DocumentMentionPickerRef, DocumentMentionPickerProps >(function DocumentMentionPicker( - { - searchSpaceId, - onSelectionChange, - onDone, - initialSelectedDocuments = [], - externalSearch = "", - }, + { searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" }, ref ) { // Debounced search value to minimize API calls and prevent race conditions diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index 8fec4cc93..3fc609501 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -1,6 +1,5 @@ "use client"; -import type React from "react"; import { useAtomValue } from "jotai"; import { Bot, @@ -11,12 +10,13 @@ import { ChevronUp, Edit3, ImageIcon, - ScanEye, Layers, Plus, + ScanEye, Search, Zap, } from "lucide-react"; +import type React from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { @@ -178,8 +178,7 @@ function formatProviderName(provider: string): string { const key = provider.toUpperCase(); return ( PROVIDER_NAMES[key] ?? - provider.charAt(0).toUpperCase() + - provider.slice(1).toLowerCase().replace(/_/g, " ") + provider.charAt(0).toUpperCase() + provider.slice(1).toLowerCase().replace(/_/g, " ") ); } @@ -202,14 +201,12 @@ interface ConfigBase { function filterAndScore( configs: T[], selectedProvider: string, - searchQuery: string, + searchQuery: string ): T[] { let result = configs; if (selectedProvider !== "all") { - result = result.filter( - (c) => c.provider.toUpperCase() === selectedProvider, - ); + result = result.filter((c) => c.provider.toUpperCase() === selectedProvider); } if (!searchQuery.trim()) return result; @@ -218,9 +215,7 @@ function filterAndScore( const tokens = normalized.split(/\s+/).filter(Boolean); const scored = result.map((c) => { - const aggregate = normalizeText( - [c.name, c.model_name, c.provider].join(" "), - ); + const aggregate = normalizeText([c.name, c.model_name, c.provider].join(" ")); let score = 0; if (aggregate.includes(normalized)) score += 5; for (const token of tokens) { @@ -244,20 +239,11 @@ interface DisplayItem { // ─── Component ────────────────────────────────────────────────────── interface ModelSelectorProps { - onEditLLM: ( - config: NewLLMConfigPublic | GlobalNewLLMConfig, - isGlobal: boolean, - ) => void; + onEditLLM: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void; onAddNewLLM: (provider?: string) => void; - onEditImage?: ( - config: ImageGenerationConfig | GlobalImageGenConfig, - isGlobal: boolean, - ) => void; + onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void; onAddNewImage?: (provider?: string) => void; - onEditVision?: ( - config: VisionLLMConfig | GlobalVisionLLMConfig, - isGlobal: boolean, - ) => void; + onEditVision?: (config: VisionLLMConfig | GlobalVisionLLMConfig, isGlobal: boolean) => void; onAddNewVision?: (provider?: string) => void; className?: string; } @@ -272,9 +258,7 @@ export function ModelSelector({ className, }: ModelSelectorProps) { const [open, setOpen] = useState(false); - const [activeTab, setActiveTab] = useState<"llm" | "image" | "vision">( - "llm", - ); + const [activeTab, setActiveTab] = useState<"llm" | "image" | "vision">("llm"); const [searchQuery, setSearchQuery] = useState(""); const [selectedProvider, setSelectedProvider] = useState("all"); const [focusedIndex, setFocusedIndex] = useState(-1); @@ -292,18 +276,21 @@ export function ModelSelector({ setModelScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); }, []); - const handleSidebarScroll = useCallback((e: React.UIEvent) => { - const el = e.currentTarget; - if (isMobile) { - const atStart = el.scrollLeft <= 2; - const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2; - setSidebarScrollPos(atStart ? "top" : atEnd ? "bottom" : "middle"); - } else { - const atTop = el.scrollTop <= 2; - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; - setSidebarScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); - } - }, [isMobile]); + const handleSidebarScroll = useCallback( + (e: React.UIEvent) => { + const el = e.currentTarget; + if (isMobile) { + const atStart = el.scrollLeft <= 2; + const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2; + setSidebarScrollPos(atStart ? "top" : atEnd ? "bottom" : "middle"); + } else { + const atTop = el.scrollTop <= 2; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; + setSidebarScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + } + }, + [isMobile] + ); // Reset search + provider when tab changes // biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger @@ -344,22 +331,18 @@ export function ModelSelector({ }, [open, isMobile, activeTab]); // ─── Data ─── - const { data: llmUserConfigs, isLoading: llmUserLoading } = - useAtomValue(newLLMConfigsAtom); + const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom); const { data: llmGlobalConfigs, isLoading: llmGlobalLoading } = useAtomValue(globalNewLLMConfigsAtom); - const { data: preferences, isLoading: prefsLoading } = - useAtomValue(llmPreferencesAtom); + const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); - const { mutateAsync: updatePreferences } = useAtomValue( - updateLLMPreferencesMutationAtom, - ); + const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); const { data: imageGlobalConfigs, isLoading: imageGlobalLoading } = useAtomValue(globalImageGenConfigsAtom); - const { data: imageUserConfigs, isLoading: imageUserLoading } = - useAtomValue(imageGenConfigsAtom); - const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } = - useAtomValue(globalVisionLLMConfigsAtom); + const { data: imageUserConfigs, isLoading: imageUserLoading } = useAtomValue(imageGenConfigsAtom); + const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } = useAtomValue( + globalVisionLLMConfigsAtom + ); const { data: visionUserConfigs, isLoading: visionUserLoading } = useAtomValue(visionLLMConfigsAtom); @@ -382,9 +365,7 @@ export function ModelSelector({ }, [preferences, llmGlobalConfigs, llmUserConfigs]); const isLLMAutoMode = - currentLLMConfig && - "is_auto_mode" in currentLLMConfig && - currentLLMConfig.is_auto_mode; + currentLLMConfig && "is_auto_mode" in currentLLMConfig && currentLLMConfig.is_auto_mode; const currentImageConfig = useMemo(() => { if (!preferences) return null; @@ -398,9 +379,7 @@ export function ModelSelector({ }, [preferences, imageGlobalConfigs, imageUserConfigs]); const isImageAutoMode = - currentImageConfig && - "is_auto_mode" in currentImageConfig && - currentImageConfig.is_auto_mode; + currentImageConfig && "is_auto_mode" in currentImageConfig && currentImageConfig.is_auto_mode; const currentVisionConfig = useMemo(() => { if (!preferences) return null; @@ -420,83 +399,47 @@ export function ModelSelector({ // ─── Filtered configs (separate global / user for section headers) ─── const filteredLLMGlobal = useMemo( - () => - filterAndScore(llmGlobalConfigs ?? [], selectedProvider, searchQuery), - [llmGlobalConfigs, selectedProvider, searchQuery], + () => filterAndScore(llmGlobalConfigs ?? [], selectedProvider, searchQuery), + [llmGlobalConfigs, selectedProvider, searchQuery] ); const filteredLLMUser = useMemo( - () => - filterAndScore(llmUserConfigs ?? [], selectedProvider, searchQuery), - [llmUserConfigs, selectedProvider, searchQuery], + () => filterAndScore(llmUserConfigs ?? [], selectedProvider, searchQuery), + [llmUserConfigs, selectedProvider, searchQuery] ); const filteredImageGlobal = useMemo( - () => - filterAndScore( - imageGlobalConfigs ?? [], - selectedProvider, - searchQuery, - ), - [imageGlobalConfigs, selectedProvider, searchQuery], + () => filterAndScore(imageGlobalConfigs ?? [], selectedProvider, searchQuery), + [imageGlobalConfigs, selectedProvider, searchQuery] ); const filteredImageUser = useMemo( - () => - filterAndScore( - imageUserConfigs ?? [], - selectedProvider, - searchQuery, - ), - [imageUserConfigs, selectedProvider, searchQuery], + () => filterAndScore(imageUserConfigs ?? [], selectedProvider, searchQuery), + [imageUserConfigs, selectedProvider, searchQuery] ); const filteredVisionGlobal = useMemo( - () => - filterAndScore( - visionGlobalConfigs ?? [], - selectedProvider, - searchQuery, - ), - [visionGlobalConfigs, selectedProvider, searchQuery], + () => filterAndScore(visionGlobalConfigs ?? [], selectedProvider, searchQuery), + [visionGlobalConfigs, selectedProvider, searchQuery] ); const filteredVisionUser = useMemo( - () => - filterAndScore( - visionUserConfigs ?? [], - selectedProvider, - searchQuery, - ), - [visionUserConfigs, selectedProvider, searchQuery], + () => filterAndScore(visionUserConfigs ?? [], selectedProvider, searchQuery), + [visionUserConfigs, selectedProvider, searchQuery] ); // Combined display list for keyboard navigation const currentDisplayItems: DisplayItem[] = useMemo(() => { - const toItems = ( - configs: ConfigBase[], - isGlobal: boolean, - ): DisplayItem[] => + const toItems = (configs: ConfigBase[], isGlobal: boolean): DisplayItem[] => configs.map((c) => ({ config: c as ConfigBase & Record, isGlobal, isAutoMode: - isGlobal && - "is_auto_mode" in c && - !!(c as Record).is_auto_mode, + isGlobal && "is_auto_mode" in c && !!(c as Record).is_auto_mode, })); switch (activeTab) { case "llm": - return [ - ...toItems(filteredLLMGlobal, true), - ...toItems(filteredLLMUser, false), - ]; + return [...toItems(filteredLLMGlobal, true), ...toItems(filteredLLMUser, false)]; case "image": - return [ - ...toItems(filteredImageGlobal, true), - ...toItems(filteredImageUser, false), - ]; + return [...toItems(filteredImageGlobal, true), ...toItems(filteredImageUser, false)]; case "vision": - return [ - ...toItems(filteredVisionGlobal, true), - ...toItems(filteredVisionUser, false), - ]; + return [...toItems(filteredVisionGlobal, true), ...toItems(filteredVisionUser, false)]; } }, [ activeTab, @@ -513,19 +456,10 @@ export function ModelSelector({ const configuredProviderSet = useMemo(() => { const configs = activeTab === "llm" - ? [ - ...(llmGlobalConfigs ?? []), - ...(llmUserConfigs ?? []), - ] + ? [...(llmGlobalConfigs ?? []), ...(llmUserConfigs ?? [])] : activeTab === "image" - ? [ - ...(imageGlobalConfigs ?? []), - ...(imageUserConfigs ?? []), - ] - : [ - ...(visionGlobalConfigs ?? []), - ...(visionUserConfigs ?? []), - ]; + ? [...(imageGlobalConfigs ?? []), ...(imageUserConfigs ?? [])] + : [...(visionGlobalConfigs ?? []), ...(visionUserConfigs ?? [])]; const set = new Set(); for (const c of configs) { if (c.provider) set.add(c.provider.toUpperCase()); @@ -544,31 +478,18 @@ export function ModelSelector({ // Show only providers valid for the active tab; configured ones first const activeProviders = useMemo(() => { const tabKeys = PROVIDER_KEYS_BY_TAB[activeTab] ?? LLM_PROVIDER_KEYS; - const configured = tabKeys.filter((p) => - configuredProviderSet.has(p), - ); - const unconfigured = tabKeys.filter( - (p) => !configuredProviderSet.has(p), - ); + const configured = tabKeys.filter((p) => configuredProviderSet.has(p)); + const unconfigured = tabKeys.filter((p) => !configuredProviderSet.has(p)); return ["all", ...configured, ...unconfigured]; }, [activeTab, configuredProviderSet]); const providerModelCounts = useMemo(() => { const allConfigs = activeTab === "llm" - ? [ - ...(llmGlobalConfigs ?? []), - ...(llmUserConfigs ?? []), - ] + ? [...(llmGlobalConfigs ?? []), ...(llmUserConfigs ?? [])] : activeTab === "image" - ? [ - ...(imageGlobalConfigs ?? []), - ...(imageUserConfigs ?? []), - ] - : [ - ...(visionGlobalConfigs ?? []), - ...(visionUserConfigs ?? []), - ]; + ? [...(imageGlobalConfigs ?? []), ...(imageUserConfigs ?? [])] + : [...(visionGlobalConfigs ?? []), ...(visionUserConfigs ?? [])]; const counts: Record = { all: allConfigs.length }; for (const c of allConfigs) { const p = c.provider.toUpperCase(); @@ -607,7 +528,7 @@ export function ModelSelector({ toast.error("Failed to switch model"); } }, - [currentLLMConfig, searchSpaceId, updatePreferences], + [currentLLMConfig, searchSpaceId, updatePreferences] ); const handleSelectImage = useCallback( @@ -631,7 +552,7 @@ export function ModelSelector({ toast.error("Failed to switch image model"); } }, - [currentImageConfig, searchSpaceId, updatePreferences], + [currentImageConfig, searchSpaceId, updatePreferences] ); const handleSelectVision = useCallback( @@ -655,16 +576,14 @@ export function ModelSelector({ toast.error("Failed to switch vision model"); } }, - [currentVisionConfig, searchSpaceId, updatePreferences], + [currentVisionConfig, searchSpaceId, updatePreferences] ); const handleSelectItem = useCallback( (item: DisplayItem) => { switch (activeTab) { case "llm": - handleSelectLLM( - item.config as NewLLMConfigPublic | GlobalNewLLMConfig, - ); + handleSelectLLM(item.config as NewLLMConfigPublic | GlobalNewLLMConfig); break; case "image": handleSelectImage(item.config.id); @@ -674,7 +593,7 @@ export function ModelSelector({ break; } }, - [activeTab, handleSelectLLM, handleSelectImage, handleSelectVision], + [activeTab, handleSelectLLM, handleSelectImage, handleSelectVision] ); const handleEditItem = useCallback( @@ -683,26 +602,17 @@ export function ModelSelector({ setOpen(false); switch (activeTab) { case "llm": - onEditLLM( - item.config as NewLLMConfigPublic | GlobalNewLLMConfig, - item.isGlobal, - ); + onEditLLM(item.config as NewLLMConfigPublic | GlobalNewLLMConfig, item.isGlobal); break; case "image": - onEditImage?.( - item.config as ImageGenerationConfig | GlobalImageGenConfig, - item.isGlobal, - ); + onEditImage?.(item.config as ImageGenerationConfig | GlobalImageGenConfig, item.isGlobal); break; case "vision": - onEditVision?.( - item.config as VisionLLMConfig | GlobalVisionLLMConfig, - item.isGlobal, - ); + onEditVision?.(item.config as VisionLLMConfig | GlobalVisionLLMConfig, item.isGlobal); break; } }, - [activeTab, onEditLLM, onEditImage, onEditVision], + [activeTab, onEditLLM, onEditImage, onEditVision] ); // ─── Keyboard navigation ─── @@ -713,8 +623,7 @@ export function ModelSelector({ useEffect(() => { if (focusedIndex < 0 || !modelListRef.current) return; - const items = - modelListRef.current.querySelectorAll("[data-model-index]"); + const items = modelListRef.current.querySelectorAll("[data-model-index]"); items[focusedIndex]?.scrollIntoView({ block: "nearest", behavior: "smooth", @@ -734,13 +643,11 @@ export function ModelSelector({ if (e.key === "ArrowLeft") { next = idx > 0 ? idx - 1 : providers.length - 1; } else { - next = - idx < providers.length - 1 ? idx + 1 : 0; + next = idx < providers.length - 1 ? idx + 1 : 0; } setSelectedProvider(providers[next]); if (providerSidebarRef.current) { - const buttons = - providerSidebarRef.current.querySelectorAll("button"); + const buttons = providerSidebarRef.current.querySelectorAll("button"); buttons[next]?.scrollIntoView({ block: "nearest", inline: "nearest", @@ -755,15 +662,11 @@ export function ModelSelector({ switch (e.key) { case "ArrowDown": e.preventDefault(); - setFocusedIndex((prev) => - prev < count - 1 ? prev + 1 : 0, - ); + setFocusedIndex((prev) => (prev < count - 1 ? prev + 1 : 0)); break; case "ArrowUp": e.preventDefault(); - setFocusedIndex((prev) => - prev > 0 ? prev - 1 : count - 1, - ); + setFocusedIndex((prev) => (prev > 0 ? prev - 1 : count - 1)); break; case "Enter": e.preventDefault(); @@ -781,13 +684,7 @@ export function ModelSelector({ break; } }, - [ - currentDisplayItems, - focusedIndex, - activeProviders, - selectedProvider, - handleSelectItem, - ], + [currentDisplayItems, focusedIndex, activeProviders, selectedProvider, handleSelectItem] ); // ─── Render: Provider sidebar ─── @@ -798,7 +695,7 @@ export function ModelSelector({
{!isMobile && sidebarScrollPos !== "top" && ( @@ -817,29 +714,29 @@ export function ModelSelector({ className={cn( isMobile ? "flex flex-row gap-0.5 px-1 py-1.5 overflow-x-auto [&::-webkit-scrollbar]:h-0 [&::-webkit-scrollbar-track]:bg-transparent" - : "flex flex-col gap-0.5 p-1 overflow-y-auto flex-1 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar-track]:bg-transparent", + : "flex flex-col gap-0.5 p-1 overflow-y-auto flex-1 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar-track]:bg-transparent" )} - style={isMobile ? { - maskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`, - WebkitMaskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`, - } : { - maskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`, - WebkitMaskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`, - }} + style={ + isMobile + ? { + maskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`, + WebkitMaskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`, + } + : { + maskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`, + WebkitMaskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`, + } + } > {activeProviders.map((provider, idx) => { const isAll = provider === "all"; const isActive = selectedProvider === provider; const count = providerModelCounts[provider] || 0; - const isConfigured = - isAll || configuredProviderSet.has(provider); + const isConfigured = isAll || configuredProviderSet.has(provider); // Separator between configured and unconfigured providers // idx 0 is "all", configured run from 1..configuredCount, unconfigured start at configuredCount+1 - const showSeparator = - !isAll && - idx === configuredCount + 1 && - configuredCount > 0; + const showSeparator = !isAll && idx === configuredCount + 1 && configuredCount > 0; return ( @@ -853,20 +750,16 @@ export function ModelSelector({ - - {isAll - ? "All Models" - : formatProviderName( - provider, - )} - {isConfigured - ? ` (${count})` - : " (not configured)"} + + {isAll ? "All Models" : formatProviderName(provider)} + {isConfigured ? ` (${count})` : " (not configured)"} @@ -927,8 +810,7 @@ export function ModelSelector({ const { config, isAutoMode } = item; const isSelected = getSelectedId() === config.id; const isFocused = focusedIndex === index; - const hasCitations = - "citations_enabled" in config && !!config.citations_enabled; + const hasCitations = "citations_enabled" in config && !!config.citations_enabled; return (
handleSelectItem(item)} - onKeyDown={isMobile ? undefined : (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleSelectItem(item); - } - }} + onKeyDown={ + isMobile + ? undefined + : (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelectItem(item); + } + } + } onMouseEnter={() => setFocusedIndex(index)} className={cn( "group flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer", "transition-all duration-150 mx-2", "hover:bg-accent/40", isSelected && "bg-primary/6 dark:bg-primary/8", - isFocused && "bg-accent/50", + isFocused && "bg-accent/50" )} > {/* Provider icon */} @@ -964,9 +850,7 @@ export function ModelSelector({ {/* Model info */}
- - {config.name} - + {config.name} {isAutoMode && (
- {isAutoMode - ? "Auto Mode" - : (config.model_name as string)} + {isAutoMode ? "Auto Mode" : (config.model_name as string)} {!isAutoMode && hasCitations && ( )} - {isSelected && ( - - )} + {isSelected && }
); @@ -1021,11 +901,7 @@ export function ModelSelector({ const userStartIdx = globalItems.length; const addHandler = - activeTab === "llm" - ? onAddNewLLM - : activeTab === "image" - ? onAddNewImage - : onAddNewVision; + activeTab === "llm" ? onAddNewLLM : activeTab === "image" ? onAddNewImage : onAddNewVision; const addLabel = activeTab === "llm" ? "Add Model" @@ -1065,7 +941,7 @@ export function ModelSelector({ "flex items-center justify-center gap-1.5 text-sm font-medium transition-all duration-200 border-b-[1.5px]", activeTab === value ? "border-foreground dark:border-white text-foreground" - : "border-transparent text-muted-foreground hover:text-foreground/70", + : "border-transparent text-muted-foreground hover:text-foreground/70" )} > @@ -1076,14 +952,7 @@ export function ModelSelector({
{/* Two-pane layout */} -
+
{/* Provider sidebar */} {renderProviderSidebar()} @@ -1096,9 +965,7 @@ export function ModelSelector({ ref={searchInputRef} placeholder="Search models" value={searchQuery} - onChange={(e) => - setSearchQuery(e.target.value) - } + onChange={(e) => setSearchQuery(e.target.value)} onKeyDown={isMobile ? undefined : handleKeyDown} role="combobox" aria-expanded={true} @@ -1106,7 +973,7 @@ export function ModelSelector({ className={cn( "w-full pl-8 pr-3 py-2.5 text-sm bg-transparent", "focus:outline-none", - "placeholder:text-muted-foreground", + "placeholder:text-muted-foreground" )} />
@@ -1117,13 +984,9 @@ export function ModelSelector({ {getProviderIcon(selectedProvider, { className: "size-4", })} - - {formatProviderName(selectedProvider)} - + {formatProviderName(selectedProvider)} - {configuredProviderSet.has( - selectedProvider, - ) + {configuredProviderSet.has(selectedProvider) ? `${providerModelCounts[selectedProvider] || 0} models` : "Not configured"} @@ -1144,30 +1007,18 @@ export function ModelSelector({ > {currentDisplayItems.length === 0 ? (
- {selectedProvider !== "all" && - !configuredProviderSet.has( - selectedProvider, - ) ? ( + {selectedProvider !== "all" && !configuredProviderSet.has(selectedProvider) ? ( <>
- {getProviderIcon( - selectedProvider, - { - className: - "size-10", - }, - )} + {getProviderIcon(selectedProvider, { + className: "size-10", + })}

- No{" "} - {formatProviderName( - selectedProvider, - )}{" "} - models configured + No {formatProviderName(selectedProvider)} models configured

- Add a model with this - provider to get started + Add a model with this provider to get started

{addHandler && (
)} @@ -1275,18 +1110,13 @@ export function ModelSelector({ aria-expanded={open} className={cn( "h-8 gap-2 px-3 text-sm bg-main-panel hover:bg-accent/50 dark:hover:bg-white/[0.06] border border-border/40 select-none", - className, + className )} > {isLoading ? ( <> - - - Loading - + + Loading ) : ( <> @@ -1303,9 +1133,7 @@ export function ModelSelector({ ) : ( <> - - Select Model - + Select Model )}
@@ -1352,9 +1180,7 @@ export function ModelSelector({ Select Model -
- {renderContent()} -
+
{renderContent()}
); diff --git a/surfsense_web/components/new-chat/prompt-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx index b1723f0be..9c3cd1d9f 100644 --- a/surfsense_web/components/new-chat/prompt-picker.tsx +++ b/surfsense_web/components/new-chat/prompt-picker.tsx @@ -182,7 +182,9 @@ export const PromptPicker = forwardRef(funct onMouseEnter={() => setHighlightedIndex(createPromptIndex)} className={cn( "w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors rounded-md cursor-pointer text-muted-foreground", - highlightedIndex === createPromptIndex ? "bg-accent text-foreground" : "hover:text-foreground hover:bg-accent/50" + highlightedIndex === createPromptIndex + ? "bg-accent text-foreground" + : "hover:text-foreground hover:bg-accent/50" )} > diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index 9d75e9a1d..000af4e7b 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -1,7 +1,8 @@ "use client"; -import React, { useRef, useEffect, useState } from "react"; -import { AnimatePresence, motion } from "motion/react"; import { IconPlus } from "@tabler/icons-react"; +import { AnimatePresence, motion } from "motion/react"; +import type React from "react"; +import { useEffect, useRef, useState } from "react"; import { Pricing } from "@/components/pricing"; import { cn } from "@/lib/utils"; @@ -79,10 +80,15 @@ const faqData: FAQSection[] = [ title: "Pages & Billing", items: [ { - question: "What exactly is a \"page\" in SurfSense?", + question: 'What exactly is a "page" in SurfSense?', answer: "A page is a simple billing unit that measures how much content you add to your knowledge base. For PDFs, one page equals one real PDF page. For other document types like Word, PowerPoint, and Excel files, pages are automatically estimated based on the file. Every file uses at least 1 page.", }, + { + question: "What are Basic and Premium processing modes?", + answer: + "When uploading documents, you can choose between two processing modes. Basic mode uses standard extraction and costs 1 page credit per page, great for most documents. Premium mode uses advanced extraction optimized for complex financial, medical, and legal documents with intricate tables, layouts, and formatting. Premium costs 10 page credits per page but delivers significantly higher fidelity output for these specialized document types.", + }, { question: "How does the Pay As You Go plan work?", answer: @@ -111,7 +117,7 @@ const faqData: FAQSection[] = [ { question: "How are pages consumed?", answer: - "Pages are deducted whenever a document file is successfully indexed into your knowledge base, whether through direct uploads or file-based connector syncs (Google Drive, OneDrive, Dropbox, Local Folder). SurfSense checks your remaining pages before processing and only charges you after the file is indexed. Duplicate documents are automatically detected and won't cost you extra pages.", + "Pages are deducted whenever a document file is successfully indexed into your knowledge base, whether through direct uploads or file-based connector syncs (Google Drive, OneDrive, Dropbox, Local Folder). In Basic mode, each page costs 1 page credit; in Premium mode, each page costs 10 page credits. SurfSense checks your remaining credits before processing and only charges you after the file is indexed. Duplicate documents are automatically detected and won't cost you extra pages.", }, { question: "Do connectors like Slack, Notion, or Gmail use pages?", @@ -132,13 +138,7 @@ const faqData: FAQSection[] = [ }, ]; -const GridLineHorizontal = ({ - className, - offset, -}: { - className?: string; - offset?: string; -}) => { +const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => { return (
); }; -const GridLineVertical = ({ - className, - offset, -}: { - className?: string; - offset?: string; -}) => { +const GridLineVertical = ({ className, offset }: { className?: string; offset?: string }) => { return (
); @@ -209,10 +203,7 @@ function PricingFAQ() { useEffect(() => { function handleClickOutside(event: MouseEvent) { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setActiveId(null); } } @@ -232,21 +223,15 @@ function PricingFAQ() { Frequently Asked Questions

- Everything you need to know about SurfSense pages and billing. - Can't find what you need? Reach out at{" "} - + Everything you need to know about SurfSense pages and billing. Can't find what you + need? Reach out at{" "} + rohan@surfsense.com

-
+
{faqData.map((section) => (

@@ -264,30 +249,19 @@ function PricingFAQ() { "relative rounded-lg transition-all duration-200", isActive ? "bg-white shadow-sm ring-1 shadow-black/10 ring-black/10 dark:bg-neutral-900 dark:shadow-white/5 dark:ring-white/10" - : "hover:bg-neutral-50 dark:hover:bg-neutral-900", + : "hover:bg-neutral-50 dark:hover:bg-neutral-900" )} > {isActive && (
- - - - + + + +
)}