feat: add processing mode support for document uploads and ETL pipeline, improded error handling ux
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions

- 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.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-14 21:26:00 -07:00
parent b659f41bab
commit 656e061f84
104 changed files with 1900 additions and 909 deletions

View file

@ -2,12 +2,15 @@ import asyncio
import gc import gc
import logging import logging
import time import time
import uuid
from collections import defaultdict from collections import defaultdict
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime
from threading import Lock from threading import Lock
import redis import redis
from fastapi import Depends, FastAPI, HTTPException, Request, status from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from limits.storage import MemoryStorage from limits.storage import MemoryStorage
@ -32,6 +35,7 @@ from app.config import (
initialize_vision_llm_router, initialize_vision_llm_router,
) )
from app.db import User, create_db_and_tables, get_async_session 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 import router as crud_router
from app.routes.auth_routes import router as auth_router from app.routes.auth_routes import router as auth_router
from app.schemas import UserCreate, UserRead, UserUpdate 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.users import SECRET, auth_backend, current_active_user, fastapi_users
from app.utils.perf import get_perf_logger, log_system_snapshot 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") 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): 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" retry_after = exc.detail.split("per")[-1].strip() if exc.detail else "60"
return JSONResponse( return _build_error_response(
status_code=429, 429,
content={"detail": "RATE_LIMIT_EXCEEDED"}, "Too many requests. Please slow down and try again.",
headers={"Retry-After": retry_after}, 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.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) 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 # Request-level performance middleware

View file

@ -1,10 +1,37 @@
from enum import StrEnum
from pydantic import BaseModel, field_validator 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): class EtlRequest(BaseModel):
file_path: str file_path: str
filename: str filename: str
estimated_pages: int = 0 estimated_pages: int = 0
processing_mode: ProcessingMode = ProcessingMode.BASIC
@field_validator("filename") @field_validator("filename")
@classmethod @classmethod

View file

@ -145,13 +145,17 @@ class EtlPipelineService:
and getattr(app_config, "AZURE_DI_KEY", None) and getattr(app_config, "AZURE_DI_KEY", None)
) )
mode_value = request.processing_mode.value
if azure_configured and ext in AZURE_DI_DOCUMENT_EXTENSIONS: if azure_configured and ext in AZURE_DI_DOCUMENT_EXTENSIONS:
try: try:
from app.etl_pipeline.parsers.azure_doc_intelligence import ( from app.etl_pipeline.parsers.azure_doc_intelligence import (
parse_with_azure_doc_intelligence, 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: except Exception:
logging.warning( logging.warning(
"Azure Document Intelligence failed for %s, " "Azure Document Intelligence failed for %s, "
@ -162,4 +166,6 @@ class EtlPipelineService:
from app.etl_pipeline.parsers.llamacloud import parse_with_llamacloud 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
)

View file

@ -10,7 +10,15 @@ BASE_DELAY = 10
MAX_DELAY = 120 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.aio import DocumentIntelligenceClient
from azure.ai.documentintelligence.models import DocumentContentFormat from azure.ai.documentintelligence.models import DocumentContentFormat
from azure.core.credentials import AzureKeyCredential from azure.core.credentials import AzureKeyCredential
@ -21,9 +29,15 @@ async def parse_with_azure_doc_intelligence(file_path: str) -> str:
ServiceResponseError, ServiceResponseError,
) )
model_id = AZURE_MODEL_BY_MODE.get(processing_mode, "prebuilt-read")
file_size_mb = os.path.getsize(file_path) / (1024 * 1024) file_size_mb = os.path.getsize(file_path) / (1024 * 1024)
retryable_exceptions = (ServiceRequestError, ServiceResponseError) 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 last_exception = None
attempt_errors: list[str] = [] attempt_errors: list[str] = []
@ -36,7 +50,7 @@ async def parse_with_azure_doc_intelligence(file_path: str) -> str:
async with client: async with client:
with open(file_path, "rb") as f: with open(file_path, "rb") as f:
poller = await client.begin_analyze_document( poller = await client.begin_analyze_document(
"prebuilt-layout", model_id,
body=f, body=f,
output_content_format=DocumentContentFormat.MARKDOWN, output_content_format=DocumentContentFormat.MARKDOWN,
) )

View file

@ -16,8 +16,15 @@ from app.etl_pipeline.constants import (
calculate_upload_timeout, 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 import LlamaParse
from llama_cloud_services.parse.utils import ResultType 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, pool=120.0,
) )
tier = LLAMA_TIER_BY_MODE.get(processing_mode, "cost_effective")
logging.info( logging.info(
f"LlamaCloud upload configured: file_size={file_size_mb:.1f}MB, " f"LlamaCloud upload configured: file_size={file_size_mb:.1f}MB, "
f"pages={estimated_pages}, upload_timeout={upload_timeout:.0f}s, " 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 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_in_seconds=job_timeout,
job_timeout_extra_time_per_page_in_seconds=PER_PAGE_JOB_TIMEOUT, job_timeout_extra_time_per_page_in_seconds=PER_PAGE_JOB_TIMEOUT,
custom_client=custom_client, custom_client=custom_client,
tier=tier,
) )
result = await parser.aparse(file_path) result = await parser.aparse(file_path)

View file

@ -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)

View file

@ -124,6 +124,7 @@ async def create_documents_file_upload(
search_space_id: int = Form(...), search_space_id: int = Form(...),
should_summarize: bool = Form(False), should_summarize: bool = Form(False),
use_vision_llm: bool = Form(False), use_vision_llm: bool = Form(False),
processing_mode: str = Form("basic"),
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
dispatcher: TaskDispatcher = Depends(get_task_dispatcher), dispatcher: TaskDispatcher = Depends(get_task_dispatcher),
@ -142,12 +143,15 @@ async def create_documents_file_upload(
from datetime import datetime from datetime import datetime
from app.db import DocumentStatus from app.db import DocumentStatus
from app.etl_pipeline.etl_document import ProcessingMode
from app.tasks.document_processors.base import ( from app.tasks.document_processors.base import (
check_document_by_unique_identifier, check_document_by_unique_identifier,
get_current_timestamp, get_current_timestamp,
) )
from app.utils.document_converters import generate_unique_identifier_hash from app.utils.document_converters import generate_unique_identifier_hash
validated_mode = ProcessingMode.coerce(processing_mode)
try: try:
await check_permission( await check_permission(
session, session,
@ -274,6 +278,7 @@ async def create_documents_file_upload(
user_id=str(user.id), user_id=str(user.id),
should_summarize=should_summarize, should_summarize=should_summarize,
use_vision_llm=use_vision_llm, use_vision_llm=use_vision_llm,
processing_mode=validated_mode.value,
) )
return { return {
@ -1493,6 +1498,7 @@ async def folder_upload(
root_folder_id: int | None = Form(None), root_folder_id: int | None = Form(None),
enable_summary: bool = Form(False), enable_summary: bool = Form(False),
use_vision_llm: bool = Form(False), use_vision_llm: bool = Form(False),
processing_mode: str = Form("basic"),
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
@ -1504,6 +1510,10 @@ async def folder_upload(
import json import json
import tempfile import tempfile
from app.etl_pipeline.etl_document import ProcessingMode
validated_mode = ProcessingMode.coerce(processing_mode)
await check_permission( await check_permission(
session, session,
user, user,
@ -1558,6 +1568,7 @@ async def folder_upload(
watched_metadata = { watched_metadata = {
"watched": True, "watched": True,
"folder_path": folder_name, "folder_path": folder_name,
"processing_mode": validated_mode.value,
} }
existing_root = ( existing_root = (
await session.execute( await session.execute(
@ -1621,6 +1632,7 @@ async def folder_upload(
enable_summary=enable_summary, enable_summary=enable_summary,
use_vision_llm=use_vision_llm, use_vision_llm=use_vision_llm,
file_mappings=list(file_mappings), file_mappings=list(file_mappings),
processing_mode=validated_mode.value,
) )
return { return {

View file

@ -20,6 +20,7 @@ class TaskDispatcher(Protocol):
user_id: str, user_id: str,
should_summarize: bool = False, should_summarize: bool = False,
use_vision_llm: bool = False, use_vision_llm: bool = False,
processing_mode: str = "basic",
) -> None: ... ) -> None: ...
@ -36,6 +37,7 @@ class CeleryTaskDispatcher:
user_id: str, user_id: str,
should_summarize: bool = False, should_summarize: bool = False,
use_vision_llm: bool = False, use_vision_llm: bool = False,
processing_mode: str = "basic",
) -> None: ) -> None:
from app.tasks.celery_tasks.document_tasks import ( from app.tasks.celery_tasks.document_tasks import (
process_file_upload_with_document_task, process_file_upload_with_document_task,
@ -49,6 +51,7 @@ class CeleryTaskDispatcher:
user_id=user_id, user_id=user_id,
should_summarize=should_summarize, should_summarize=should_summarize,
use_vision_llm=use_vision_llm, use_vision_llm=use_vision_llm,
processing_mode=processing_mode,
) )

View file

@ -780,6 +780,7 @@ def process_file_upload_with_document_task(
user_id: str, user_id: str,
should_summarize: bool = False, should_summarize: bool = False,
use_vision_llm: bool = False, use_vision_llm: bool = False,
processing_mode: str = "basic",
): ):
""" """
Celery task to process uploaded file with existing pending document. Celery task to process uploaded file with existing pending document.
@ -836,6 +837,7 @@ def process_file_upload_with_document_task(
user_id, user_id,
should_summarize=should_summarize, should_summarize=should_summarize,
use_vision_llm=use_vision_llm, use_vision_llm=use_vision_llm,
processing_mode=processing_mode,
) )
) )
logger.info( logger.info(
@ -873,6 +875,7 @@ async def _process_file_with_document(
user_id: str, user_id: str,
should_summarize: bool = False, should_summarize: bool = False,
use_vision_llm: bool = False, use_vision_llm: bool = False,
processing_mode: str = "basic",
): ):
""" """
Process file and update existing pending document status. Process file and update existing pending document status.
@ -976,6 +979,7 @@ async def _process_file_with_document(
notification=notification, notification=notification,
should_summarize=should_summarize, should_summarize=should_summarize,
use_vision_llm=use_vision_llm, use_vision_llm=use_vision_llm,
processing_mode=processing_mode,
) )
# Update notification on success # Update notification on success
@ -1434,6 +1438,7 @@ def index_uploaded_folder_files_task(
enable_summary: bool, enable_summary: bool,
file_mappings: list[dict], file_mappings: list[dict],
use_vision_llm: bool = False, use_vision_llm: bool = False,
processing_mode: str = "basic",
): ):
"""Celery task to index files uploaded from the desktop app.""" """Celery task to index files uploaded from the desktop app."""
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
@ -1448,6 +1453,7 @@ def index_uploaded_folder_files_task(
enable_summary=enable_summary, enable_summary=enable_summary,
file_mappings=file_mappings, file_mappings=file_mappings,
use_vision_llm=use_vision_llm, use_vision_llm=use_vision_llm,
processing_mode=processing_mode,
) )
) )
finally: finally:
@ -1462,6 +1468,7 @@ async def _index_uploaded_folder_files_async(
enable_summary: bool, enable_summary: bool,
file_mappings: list[dict], file_mappings: list[dict],
use_vision_llm: bool = False, use_vision_llm: bool = False,
processing_mode: str = "basic",
): ):
"""Run upload-based folder indexing with notification + heartbeat.""" """Run upload-based folder indexing with notification + heartbeat."""
file_count = len(file_mappings) file_count = len(file_mappings)
@ -1512,6 +1519,7 @@ async def _index_uploaded_folder_files_async(
file_mappings=file_mappings, file_mappings=file_mappings,
on_heartbeat_callback=_heartbeat_progress, on_heartbeat_callback=_heartbeat_progress,
use_vision_llm=use_vision_llm, use_vision_llm=use_vision_llm,
processing_mode=processing_mode,
) )
if notification: if notification:

View file

@ -60,14 +60,16 @@ async def _check_page_limit_or_skip(
page_limit_service: PageLimitService, page_limit_service: PageLimitService,
user_id: str, user_id: str,
file_path: str, file_path: str,
) -> int: page_multiplier: int = 1,
) -> tuple[int, int]:
"""Estimate pages and check the limit; raises PageLimitExceededError if over quota. """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) estimated = _estimate_pages_safe(page_limit_service, file_path)
await page_limit_service.check_page_limit(user_id, estimated) billable = estimated * page_multiplier
return estimated await page_limit_service.check_page_limit(user_id, billable)
return estimated, billable
def _compute_final_pages( def _compute_final_pages(
@ -153,17 +155,20 @@ def scan_folder(
return files 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. """Read file content via the unified ETL pipeline.
All file types (plaintext, audio, direct-convert, document, image) are All file types (plaintext, audio, direct-convert, document, image) are
handled by ``EtlPipelineService``. 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 from app.etl_pipeline.etl_pipeline_service import EtlPipelineService
mode = ProcessingMode.coerce(processing_mode)
result = await EtlPipelineService(vision_llm=vision_llm).extract( 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 return result.markdown_content
@ -201,12 +206,15 @@ async def _compute_file_content_hash(
search_space_id: int, search_space_id: int,
*, *,
vision_llm=None, vision_llm=None,
processing_mode: str = "basic",
) -> tuple[str, str]: ) -> tuple[str, str]:
"""Read a file (via ETL if needed) and compute its content hash. """Read a file (via ETL if needed) and compute its content hash.
Returns (content_text, 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) return content, _content_hash(content, search_space_id)
@ -694,7 +702,7 @@ async def index_local_folder(
continue continue
try: 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 page_limit_service, user_id, file_path_abs
) )
except PageLimitExceededError: except PageLimitExceededError:
@ -730,7 +738,7 @@ async def index_local_folder(
await create_version_snapshot(session, existing_document) await create_version_snapshot(session, existing_document)
else: else:
try: 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 page_limit_service, user_id, file_path_abs
) )
except PageLimitExceededError: except PageLimitExceededError:
@ -1080,7 +1088,7 @@ async def _index_single_file(
page_limit_service = PageLimitService(session) page_limit_service = PageLimitService(session)
try: 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) page_limit_service, user_id, str(full_path)
) )
except PageLimitExceededError as e: except PageLimitExceededError as e:
@ -1271,6 +1279,7 @@ async def index_uploaded_files(
file_mappings: list[dict], file_mappings: list[dict],
on_heartbeat_callback: HeartbeatCallbackType | None = None, on_heartbeat_callback: HeartbeatCallbackType | None = None,
use_vision_llm: bool = False, use_vision_llm: bool = False,
processing_mode: str = "basic",
) -> tuple[int, int, str | None]: ) -> tuple[int, int, str | None]:
"""Index files uploaded from the desktop app via temp paths. """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)``. 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) task_logger = TaskLoggingService(session, search_space_id)
log_entry = await task_logger.log_task_start( log_entry = await task_logger.log_task_start(
task_name="local_folder_indexing", task_name="local_folder_indexing",
source="uploaded_folder_indexing", source="uploaded_folder_indexing",
message=f"Indexing {len(file_mappings)} uploaded file(s) for {folder_name}", 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: try:
@ -1350,8 +1363,11 @@ async def index_uploaded_files(
continue continue
try: try:
estimated_pages = await _check_page_limit_or_skip( estimated_pages, _billable_pages = await _check_page_limit_or_skip(
page_limit_service, user_id, temp_path page_limit_service,
user_id,
temp_path,
page_multiplier=mode.page_multiplier,
) )
except PageLimitExceededError: except PageLimitExceededError:
logger.warning(f"Page limit exceeded, skipping: {relative_path}") logger.warning(f"Page limit exceeded, skipping: {relative_path}")
@ -1364,6 +1380,7 @@ async def index_uploaded_files(
filename, filename,
search_space_id, search_space_id,
vision_llm=vision_llm_instance, vision_llm=vision_llm_instance,
processing_mode=mode.value,
) )
except Exception as e: except Exception as e:
logger.warning(f"Could not read {relative_path}: {e}") logger.warning(f"Could not read {relative_path}: {e}")
@ -1429,8 +1446,9 @@ async def index_uploaded_files(
final_pages = _compute_final_pages( final_pages = _compute_final_pages(
page_limit_service, estimated_pages, len(content) page_limit_service, estimated_pages, len(content)
) )
final_billable = final_pages * mode.page_multiplier
await page_limit_service.update_page_usage( await page_limit_service.update_page_usage(
user_id, final_pages, allow_exceed=True user_id, final_billable, allow_exceed=True
) )
else: else:
failed_count += 1 failed_count += 1

View file

@ -47,6 +47,7 @@ class _ProcessingContext:
connector: dict | None = None connector: dict | None = None
notification: Notification | None = None notification: Notification | None = None
use_vision_llm: bool = False use_vision_llm: bool = False
processing_mode: str = "basic"
enable_summary: bool = field(init=False) enable_summary: bool = field(init=False)
def __post_init__(self) -> None: 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: async def _process_document_upload(ctx: _ProcessingContext) -> Document | None:
"""Route a document file to the configured ETL service via the unified pipeline.""" """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.etl_pipeline.etl_pipeline_service import EtlPipelineService
from app.services.page_limit_service import PageLimitExceededError, PageLimitService from app.services.page_limit_service import PageLimitExceededError, PageLimitService
mode = ProcessingMode.coerce(ctx.processing_mode)
page_limit_service = PageLimitService(ctx.session) page_limit_service = PageLimitService(ctx.session)
estimated_pages = _estimate_pages_safe(page_limit_service, ctx.file_path) 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( await ctx.task_logger.log_task_progress(
ctx.log_entry, ctx.log_entry,
f"Estimated {estimated_pages} pages for file: {ctx.filename}", 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: 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: except PageLimitExceededError as e:
await ctx.task_logger.log_task_failure( await ctx.task_logger.log_task_failure(
ctx.log_entry, ctx.log_entry,
@ -212,6 +220,8 @@ async def _process_document_upload(ctx: _ProcessingContext) -> Document | None:
"pages_used": e.pages_used, "pages_used": e.pages_used,
"pages_limit": e.pages_limit, "pages_limit": e.pages_limit,
"estimated_pages": estimated_pages, "estimated_pages": estimated_pages,
"billable_pages": billable_pages,
"processing_mode": mode.value,
}, },
) )
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
@ -225,6 +235,7 @@ async def _process_document_upload(ctx: _ProcessingContext) -> Document | None:
file_path=ctx.file_path, file_path=ctx.file_path,
filename=ctx.filename, filename=ctx.filename,
estimated_pages=estimated_pages, estimated_pages=estimated_pages,
processing_mode=mode,
) )
) )
@ -246,7 +257,7 @@ async def _process_document_upload(ctx: _ProcessingContext) -> Document | None:
if result: if result:
await page_limit_service.update_page_usage( 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: if ctx.connector:
await update_document_from_connector(result, ctx.connector, ctx.session) 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", "file_type": "document",
"etl_service": etl_result.etl_service, "etl_service": etl_result.etl_service,
"pages_processed": estimated_pages, "pages_processed": estimated_pages,
"billable_pages": billable_pages,
"processing_mode": mode.value,
}, },
) )
else: else:
@ -290,6 +303,7 @@ async def process_file_in_background(
connector: dict | None = None, connector: dict | None = None,
notification: Notification | None = None, notification: Notification | None = None,
use_vision_llm: bool = False, use_vision_llm: bool = False,
processing_mode: str = "basic",
) -> Document | None: ) -> Document | None:
ctx = _ProcessingContext( ctx = _ProcessingContext(
session=session, session=session,
@ -302,6 +316,7 @@ async def process_file_in_background(
connector=connector, connector=connector,
notification=notification, notification=notification,
use_vision_llm=use_vision_llm, use_vision_llm=use_vision_llm,
processing_mode=processing_mode,
) )
try: try:
@ -353,22 +368,25 @@ async def _extract_file_content(
log_entry: Log, log_entry: Log,
notification: Notification | None, notification: Notification | None,
use_vision_llm: bool = False, 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. Extract markdown content from a file regardless of type.
Returns: 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.etl_pipeline_service import EtlPipelineService
from app.etl_pipeline.file_classifier import ( from app.etl_pipeline.file_classifier import (
FileCategory, FileCategory,
classify_file as etl_classify, classify_file as etl_classify,
) )
mode = ProcessingMode.coerce(processing_mode)
category = etl_classify(filename) category = etl_classify(filename)
estimated_pages = 0 estimated_pages = 0
billable_pages = 0
if notification: if notification:
stage_messages = { stage_messages = {
@ -397,7 +415,8 @@ async def _extract_file_content(
page_limit_service = PageLimitService(session) page_limit_service = PageLimitService(session)
estimated_pages = _estimate_pages_safe(page_limit_service, file_path) 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 vision_llm = None
if use_vision_llm and category == FileCategory.IMAGE: if use_vision_llm and category == FileCategory.IMAGE:
@ -410,21 +429,17 @@ async def _extract_file_content(
file_path=file_path, file_path=file_path,
filename=filename, filename=filename,
estimated_pages=estimated_pages, 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): with contextlib.suppress(Exception):
os.unlink(file_path) os.unlink(file_path)
if not result.markdown_content: if not result.markdown_content:
raise RuntimeError(f"Failed to extract content from file: {filename}") 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( async def process_file_in_background_with_document(
@ -440,12 +455,16 @@ async def process_file_in_background_with_document(
notification: Notification | None = None, notification: Notification | None = None,
should_summarize: bool = False, should_summarize: bool = False,
use_vision_llm: bool = False, use_vision_llm: bool = False,
processing_mode: str = "basic",
) -> Document | None: ) -> Document | None:
""" """
Process file and update existing pending document (2-phase pattern). Process file and update existing pending document (2-phase pattern).
Phase 1 (API layer): Created document with pending status. Phase 1 (API layer): Created document with pending status.
Phase 2 (this function): Process file and update document to ready/failed. 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 ( from app.indexing_pipeline.adapters.file_upload_adapter import (
UploadDocumentAdapter, UploadDocumentAdapter,
@ -458,8 +477,7 @@ async def process_file_in_background_with_document(
doc_id = document.id doc_id = document.id
try: try:
# Step 1: extract content markdown_content, etl_service, billable_pages = await _extract_file_content(
markdown_content, etl_service = await _extract_file_content(
file_path, file_path,
filename, filename,
search_space_id, search_space_id,
@ -469,12 +487,12 @@ async def process_file_in_background_with_document(
log_entry, log_entry,
notification, notification,
use_vision_llm=use_vision_llm, use_vision_llm=use_vision_llm,
processing_mode=processing_mode,
) )
if not markdown_content: if not markdown_content:
raise RuntimeError(f"Failed to extract content from file: {filename}") raise RuntimeError(f"Failed to extract content from file: {filename}")
# Step 2: duplicate check
content_hash = generate_content_hash(markdown_content, search_space_id) content_hash = generate_content_hash(markdown_content, search_space_id)
existing_by_content = await check_duplicate_document(session, content_hash) existing_by_content = await check_duplicate_document(session, content_hash)
if existing_by_content and existing_by_content.id != doc_id: 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 return None
# Step 3: index via pipeline
if notification: if notification:
await NotificationService.document_processing.notify_processing_progress( await NotificationService.document_processing.notify_processing_progress(
session, session,
@ -505,6 +522,14 @@ async def process_file_in_background_with_document(
should_summarize=should_summarize, 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( await task_logger.log_task_success(
log_entry, log_entry,
f"Successfully processed file: {filename}", f"Successfully processed file: {filename}",
@ -512,6 +537,8 @@ async def process_file_in_background_with_document(
"document_id": doc_id, "document_id": doc_id,
"content_hash": content_hash, "content_hash": content_hash,
"file_type": etl_service, "file_type": etl_service,
"billable_pages": billable_pages,
"processing_mode": processing_mode,
}, },
) )
return document return document

View file

@ -70,6 +70,7 @@ class InlineTaskDispatcher:
user_id: str, user_id: str,
should_summarize: bool = False, should_summarize: bool = False,
use_vision_llm: bool = False, use_vision_llm: bool = False,
processing_mode: str = "basic",
) -> None: ) -> None:
from app.tasks.celery_tasks.document_tasks import ( from app.tasks.celery_tasks.document_tasks import (
_process_file_with_document, _process_file_with_document,
@ -84,6 +85,7 @@ class InlineTaskDispatcher:
user_id, user_id,
should_summarize=should_summarize, should_summarize=should_summarize,
use_vision_llm=use_vision_llm, use_vision_llm=use_vision_llm,
processing_mode=processing_mode,
) )
@ -321,7 +323,9 @@ def _mock_etl_parsing(monkeypatch):
# -- LlamaParse mock (external API) -------------------------------- # -- 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) _reject_empty(file_path)
return _MOCK_ETL_MARKDOWN return _MOCK_ETL_MARKDOWN

View file

@ -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.markdown_content == "# OCR text from image"
assert result.etl_service == "DOCLING" assert result.etl_service == "DOCLING"
assert result.content_type == "document" 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"

View file

@ -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"

View file

@ -42,9 +42,7 @@ export async function generateMetadata(props: {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { slug } = await props.params; const { slug } = await props.params;
const page = (source.getPages() as BlogPageItem[]).find( const page = (source.getPages() as BlogPageItem[]).find((p) => p.slugs.join("/") === slug);
(p) => p.slugs.join("/") === slug,
);
if (!page) return {}; if (!page) return {};
@ -72,13 +70,9 @@ export async function generateMetadata(props: {
}; };
} }
export default async function BlogPostPage(props: { export default async function BlogPostPage(props: { params: Promise<{ slug: string }> }) {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params; const { slug } = await props.params;
const page = (source.getPages() as BlogPageItem[]).find( const page = (source.getPages() as BlogPageItem[]).find((p) => p.slugs.join("/") === slug);
(p) => p.slugs.join("/") === slug,
);
if (!page) notFound(); if (!page) notFound();

View file

@ -1,10 +1,10 @@
"use client"; "use client";
import { Container } from "@/components/container";
import { format } from "date-fns"; import { format } from "date-fns";
import FuzzySearch from "fuzzy-search";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import FuzzySearch from "fuzzy-search"; import { Container } from "@/components/container";
import type { BlogEntry } from "./page"; import type { BlogEntry } from "./page";
function truncate(text: string, length: number) { function truncate(text: string, length: number) {
@ -24,8 +24,10 @@ function SearchIcon({ className }: { className?: string }) {
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
aria-hidden aria-hidden="true"
role="img"
> >
<title>Search</title>
<circle cx="11" cy="11" r="8" /> <circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" /> <path d="m21 21-4.3-4.3" />
</svg> </svg>
@ -100,9 +102,7 @@ function MagazineFeatured({ blog }: { blog: BlogEntry }) {
{blog.author} {blog.author}
</span> </span>
<span className="text-white/50">·</span> <span className="text-white/50">·</span>
<time dateTime={blog.date}> <time dateTime={blog.date}>{format(new Date(blog.date), "MMMM d, yyyy")}</time>
{format(new Date(blog.date), "MMMM d, yyyy")}
</time>
</div> </div>
</div> </div>
</div> </div>
@ -124,7 +124,7 @@ function MagazineSearchGrid({
new FuzzySearch(allBlogs, ["title", "description"], { new FuzzySearch(allBlogs, ["title", "description"], {
caseSensitive: false, caseSensitive: false,
}), }),
[allBlogs], [allBlogs]
); );
const [results, setResults] = useState(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" className="h-full w-full object-cover transition duration-300 group-hover/card:scale-105"
/> />
) : ( ) : (
<div className="flex h-full items-center justify-center text-neutral-400"> <div className="flex h-full items-center justify-center text-neutral-400">No image</div>
No image
</div>
)} )}
</div> </div>
<div className="flex flex-1 flex-col p-5"> <div className="flex flex-1 flex-col p-5">
@ -218,9 +216,7 @@ function MagazineCard({ blog }: { blog: BlogEntry }) {
height={24} height={24}
className="h-6 w-6 rounded-full object-cover" className="h-6 w-6 rounded-full object-cover"
/> />
<span className="text-xs text-neutral-600 dark:text-neutral-300"> <span className="text-xs text-neutral-600 dark:text-neutral-300">{blog.author}</span>
{blog.author}
</span>
</div> </div>
</div> </div>
</Link> </Link>

View file

@ -5,8 +5,7 @@ import { BlogWithSearchMagazine } from "./blog-magazine";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Blog | SurfSense - AI Search & Knowledge Management", title: "Blog | SurfSense - AI Search & Knowledge Management",
description: description: "Product updates, tutorials, and tips from the SurfSense team.",
"Product updates, tutorials, and tips from the SurfSense team.",
alternates: { alternates: {
canonical: "https://surfsense.com/blog", canonical: "https://surfsense.com/blog",
}, },
@ -53,9 +52,7 @@ export default async function BlogPage() {
author: page.data.author ?? "SurfSense Team", author: page.data.author ?? "SurfSense Team",
authorAvatar: page.data.authorAvatar ?? "/logo.png", authorAvatar: page.data.authorAvatar ?? "/logo.png",
})) }))
.sort( .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
return <BlogWithSearchMagazine blogs={blogs} />; return <BlogWithSearchMagazine blogs={blogs} />;
} }

View file

@ -3,7 +3,8 @@ import { ContactFormGridWithDetails } from "@/components/contact/contact-form";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Contact | SurfSense", 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: { alternates: {
canonical: "https://surfsense.com/contact", canonical: "https://surfsense.com/contact",
}, },

View file

@ -174,31 +174,31 @@ export function LocalLoginForm() {
<label htmlFor="password" className="block text-sm font-medium text-foreground"> <label htmlFor="password" className="block text-sm font-medium text-foreground">
{t("password")} {t("password")}
</label> </label>
<div className="relative mt-1"> <div className="relative mt-1">
<input <input
id="password" id="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
autoComplete="current-password" autoComplete="current-password"
required required
placeholder="Enter your password" placeholder="Enter your password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => 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 ${ 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 error.title
? "border-destructive focus:border-destructive focus:ring-destructive/40" ? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40" : "border-border focus:border-primary focus:ring-primary/40"
}`} }`}
disabled={isLoggingIn} disabled={isLoggingIn}
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword((prev) => !prev)} onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground" className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? t("hide_password") : t("show_password")} aria-label={showPassword ? t("hide_password") : t("show_password")}
> >
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </button>
</div> </div>
</div> </div>
<button <button

View file

@ -1,17 +1,17 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { HeroSection } from "@/components/homepage/hero-section";
import { AuthRedirect } from "@/components/homepage/auth-redirect"; import { AuthRedirect } from "@/components/homepage/auth-redirect";
import { FeaturesCards } from "@/components/homepage/features-card";
import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid"; import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid";
import { FeaturesCards } from "@/components/homepage/features-card";
import { HeroSection } from "@/components/homepage/hero-section";
const WhySurfSense = dynamic( const WhySurfSense = dynamic(() =>
() => import("@/components/homepage/why-surfsense").then((m) => ({ default: m.WhySurfSense })), import("@/components/homepage/why-surfsense").then((m) => ({ default: m.WhySurfSense }))
); );
const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations")); const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations"));
const CTAHomepage = dynamic( const CTAHomepage = dynamic(() =>
() => import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage })), import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage }))
); );
export default function HomePage() { export default function HomePage() {

View file

@ -1,6 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import PricingBasic from "@/components/pricing/pricing-section"; import PricingBasic from "@/components/pricing/pricing-section";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Pricing | SurfSense - Free AI Search Plans", title: "Pricing | SurfSense - Free AI Search Plans",

View file

@ -38,11 +38,15 @@ import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.ato
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps"; import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
import {
createTokenUsageStore,
type TokenUsageData,
TokenUsageProvider,
} from "@/components/assistant-ui/token-usage-context";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesSync } from "@/hooks/use-messages-sync"; import { useMessagesSync } from "@/hooks/use-messages-sync";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getBearerToken } from "@/lib/auth-utils"; import { getBearerToken } from "@/lib/auth-utils";
import { createTokenUsageStore, TokenUsageProvider, type TokenUsageData } from "@/components/assistant-ui/token-usage-context";
import { convertToThreadMessage } from "@/lib/chat/message-utils"; import { convertToThreadMessage } from "@/lib/chat/message-utils";
import { import {
isPodcastGenerating, isPodcastGenerating,
@ -823,7 +827,14 @@ export default function NewChatPage() {
}); });
} else { } else {
const tcId = `interrupt-${action.name}`; const tcId = `interrupt-${action.name}`;
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args, true); addToolCall(
contentPartsState,
TOOLS_WITH_UI,
tcId,
action.name,
action.args,
true
);
updateToolCall(contentPartsState, tcId, { updateToolCall(contentPartsState, tcId, {
result: { __interrupt__: true, ...interruptData }, result: { __interrupt__: true, ...interruptData },
}); });
@ -872,7 +883,7 @@ export default function NewChatPage() {
const newMsgId = `msg-${savedMessage.id}`; const newMsgId = `msg-${savedMessage.id}`;
tokenUsageStore.rename(assistantMsgId, newMsgId); tokenUsageStore.rename(assistantMsgId, newMsgId);
setMessages((prev) => setMessages((prev) =>
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m)), prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
); );
// Update pending interrupt with the new persisted message ID // Update pending interrupt with the new persisted message ID
@ -1159,7 +1170,14 @@ export default function NewChatPage() {
}); });
} else { } else {
const tcId = `interrupt-${action.name}`; const tcId = `interrupt-${action.name}`;
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args, true); addToolCall(
contentPartsState,
TOOLS_WITH_UI,
tcId,
action.name,
action.args,
true
);
updateToolCall(contentPartsState, tcId, { updateToolCall(contentPartsState, tcId, {
result: { result: {
__interrupt__: true, __interrupt__: true,
@ -1206,7 +1224,7 @@ export default function NewChatPage() {
const newMsgId = `msg-${savedMessage.id}`; const newMsgId = `msg-${savedMessage.id}`;
tokenUsageStore.rename(assistantMsgId, newMsgId); tokenUsageStore.rename(assistantMsgId, newMsgId);
setMessages((prev) => setMessages((prev) =>
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m)), prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
); );
} catch (err) { } catch (err) {
console.error("Failed to persist resumed assistant message:", err); console.error("Failed to persist resumed assistant message:", err);
@ -1512,7 +1530,7 @@ export default function NewChatPage() {
const newMsgId = `msg-${savedMessage.id}`; const newMsgId = `msg-${savedMessage.id}`;
tokenUsageStore.rename(assistantMsgId, newMsgId); tokenUsageStore.rename(assistantMsgId, newMsgId);
setMessages((prev) => setMessages((prev) =>
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m)), prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
); );
trackChatResponseReceived(searchSpaceId, threadId); trackChatResponseReceived(searchSpaceId, threadId);
@ -1617,17 +1635,17 @@ export default function NewChatPage() {
return ( return (
<TokenUsageProvider store={tokenUsageStore}> <TokenUsageProvider store={tokenUsageStore}>
<AssistantRuntimeProvider runtime={runtime}> <AssistantRuntimeProvider runtime={runtime}>
<ThinkingStepsDataUI /> <ThinkingStepsDataUI />
<div key={searchSpaceId} className="flex h-full overflow-hidden"> <div key={searchSpaceId} className="flex h-full overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden"> <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Thread /> <Thread />
</div>
<MobileReportPanel />
<MobileEditorPanel />
<MobileHitlEditPanel />
</div> </div>
<MobileReportPanel /> </AssistantRuntimeProvider>
<MobileEditorPanel />
<MobileHitlEditPanel />
</div>
</AssistantRuntimeProvider>
</TokenUsageProvider> </TokenUsageProvider>
); );
} }

View file

@ -1,13 +1,15 @@
"use client"; "use client";
import { ExternalLink } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { buildIssueUrl } from "@/lib/error-toast";
export default function DashboardError({ export default function DashboardError({
error, error,
reset, reset,
}: { }: {
error: globalThis.Error & { digest?: string }; error: globalThis.Error & { digest?: string; code?: string; requestId?: string };
reset: () => void; reset: () => void;
}) { }) {
useEffect(() => { useEffect(() => {
@ -18,12 +20,24 @@ export default function DashboardError({
.catch(() => {}); .catch(() => {});
}, [error]); }, [error]);
const issueUrl = useMemo(() => buildIssueUrl(error), [error]);
return ( return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center"> <div className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
<h2 className="text-xl font-semibold">Something went wrong</h2> <h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-muted-foreground max-w-md"> <p className="text-muted-foreground max-w-md">
An error occurred in this section. Your dashboard is still available. An error occurred in this section. Your dashboard is still available. If this keeps
happening, please report it so we can fix it.
</p> </p>
{(error.digest || error.code || error.requestId) && (
<div className="rounded-md border bg-muted/50 px-4 py-2 text-xs text-muted-foreground font-mono max-w-md">
{error.code && <span>Code: {error.code}</span>}
{error.requestId && <span className="ml-3">ID: {error.requestId}</span>}
{error.digest && <span className="ml-3">Digest: {error.digest}</span>}
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button" type="button"
@ -38,6 +52,15 @@ export default function DashboardError({
> >
Go to dashboard home Go to dashboard home
</Link> </Link>
<a
href={issueUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
Report Issue
</a>
</div> </div>
</div> </div>
); );

View file

@ -1,12 +1,14 @@
"use client"; "use client";
import { useEffect } from "react"; import { ExternalLink } from "lucide-react";
import { useEffect, useMemo } from "react";
import { buildIssueUrl } from "@/lib/error-toast";
export default function ErrorPage({ export default function ErrorPage({
error, error,
reset, reset,
}: { }: {
error: globalThis.Error & { digest?: string }; error: globalThis.Error & { digest?: string; code?: string; requestId?: string };
reset: () => void; reset: () => void;
}) { }) {
useEffect(() => { useEffect(() => {
@ -17,19 +19,41 @@ export default function ErrorPage({
.catch(() => {}); .catch(() => {});
}, [error]); }, [error]);
const issueUrl = useMemo(() => buildIssueUrl(error), [error]);
return ( return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 text-center"> <div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 text-center px-4">
<h2 className="text-2xl font-semibold">Something went wrong</h2> <h2 className="text-2xl font-semibold">Something went wrong</h2>
<p className="text-muted-foreground max-w-md"> <p className="text-muted-foreground max-w-md">
An unexpected error occurred. Please try again. An unexpected error occurred. Please try again, or report this issue if it persists.
</p> </p>
<button
type="button" {(error.digest || error.code || error.requestId) && (
onClick={reset} <div className="rounded-md border bg-muted/50 px-4 py-2 text-xs text-muted-foreground font-mono max-w-md">
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors" {error.code && <span>Code: {error.code}</span>}
> {error.requestId && <span className="ml-3">ID: {error.requestId}</span>}
Try again {error.digest && <span className="ml-3">Digest: {error.digest}</span>}
</button> </div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={reset}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Try again
</button>
<a
href={issueUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
Report Issue
</a>
</div>
</div> </div>
); );
} }

View file

@ -2,9 +2,32 @@
import "./globals.css"; import "./globals.css";
import posthog from "posthog-js"; import posthog from "posthog-js";
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button"; 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({ export default function GlobalError({
error, error,
reset, reset,
@ -16,13 +39,32 @@ export default function GlobalError({
posthog.captureException(error); posthog.captureException(error);
}, [error]); }, [error]);
const issueUrl = useMemo(() => buildBasicIssueUrl(error), [error]);
return ( return (
<html lang="en"> <html lang="en">
<body> <body>
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4"> <div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4 text-center">
<h2 className="text-xl font-semibold">Something went wrong</h2> <h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-sm text-muted-foreground">An unexpected error occurred.</p> <p className="text-sm text-muted-foreground max-w-md">
<Button onClick={reset}>Try again</Button> An unexpected error occurred. Please try again, or report this issue if it persists.
</p>
{error.digest && (
<p className="text-xs text-muted-foreground font-mono">Digest: {error.digest}</p>
)}
<div className="flex gap-2">
<Button onClick={reset}>Try again</Button>
<a
href={issueUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
>
Report Issue
</a>
</div>
</div> </div>
</body> </body>
</html> </html>

View file

@ -7,13 +7,17 @@ import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvi
import { I18nProvider } from "@/components/providers/I18nProvider"; import { I18nProvider } from "@/components/providers/I18nProvider";
import { PostHogProvider } from "@/components/providers/PostHogProvider"; import { PostHogProvider } from "@/components/providers/PostHogProvider";
import { ZeroProvider } from "@/components/providers/ZeroProvider"; import { ZeroProvider } from "@/components/providers/ZeroProvider";
import {
OrganizationJsonLd,
SoftwareApplicationJsonLd,
WebSiteJsonLd,
} from "@/components/seo/json-ld";
import { ThemeProvider } from "@/components/theme/theme-provider"; import { ThemeProvider } from "@/components/theme/theme-provider";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { LocaleProvider } from "@/contexts/LocaleContext"; import { LocaleProvider } from "@/contexts/LocaleContext";
import { PlatformProvider } from "@/contexts/platform-context"; import { PlatformProvider } from "@/contexts/platform-context";
import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider"; import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { OrganizationJsonLd, SoftwareApplicationJsonLd, WebSiteJsonLd } from "@/components/seo/json-ld";
const roboto = Roboto({ const roboto = Roboto({
subsets: ["latin"], subsets: ["latin"],

View file

@ -3,7 +3,8 @@ import Link from "next/link";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Page Not Found | SurfSense", 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() { export default function NotFound() {

View file

@ -4,13 +4,7 @@ import { Bell, ExternalLink, Info, type LucideIcon, Rocket, Wrench, Zap } from "
import Link from "next/link"; import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Card, CardContent, CardDescription, CardFooter, CardHeader } from "@/components/ui/card";
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import type { AnnouncementCategory } from "@/contracts/types/announcement.types"; import type { AnnouncementCategory } from "@/contracts/types/announcement.types";
import type { AnnouncementWithState } from "@/hooks/use-announcements"; import type { AnnouncementWithState } from "@/hooks/use-announcements";
import { formatRelativeDate } from "@/lib/format-date"; import { formatRelativeDate } from "@/lib/format-date";
@ -66,7 +60,9 @@ export function AnnouncementCard({ announcement }: { announcement: AnnouncementW
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h2 className="text-base font-semibold leading-tight tracking-tight">{announcement.title}</h2> <h2 className="text-base font-semibold leading-tight tracking-tight">
{announcement.title}
</h2>
<Badge variant={config.badgeVariant} className="text-[10px] px-1.5 py-0"> <Badge variant={config.badgeVariant} className="text-[10px] px-1.5 py-0">
{config.label} {config.label}
</Badge> </Badge>

View file

@ -33,11 +33,17 @@ import {
useAllCitationMetadata, useAllCitationMetadata,
} from "@/components/assistant-ui/citation-metadata-context"; } from "@/components/assistant-ui/citation-metadata-context";
import { MarkdownText } from "@/components/assistant-ui/markdown-text"; 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 { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
import type { SerializableCitation } from "@/components/tool-ui/citation"; import type { SerializableCitation } from "@/components/tool-ui/citation";
import {
openSafeNavigationHref,
resolveSafeNavigationHref,
} from "@/components/tool-ui/shared/media";
import { Button } from "@/components/ui/button";
import { import {
Drawer, Drawer,
DrawerContent, DrawerContent,
@ -46,14 +52,11 @@ import {
DrawerTitle, DrawerTitle,
} from "@/components/ui/drawer"; } from "@/components/ui/drawer";
import { DropdownMenuLabel } from "@/components/ui/dropdown-menu"; import { DropdownMenuLabel } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { useComments } from "@/hooks/use-comments"; import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform"; import { useElectronAPI } from "@/hooks/use-platform";
import { useTokenUsage } from "@/components/assistant-ui/token-usage-context";
import { getProviderIcon } from "@/lib/provider-icons"; import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils"; 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. // Captured once at module load — survives client-side navigations that strip the query param.
const IS_QUICK_ASSIST_WINDOW = const IS_QUICK_ASSIST_WINDOW =
@ -440,7 +443,11 @@ const MessageInfoDropdown: FC = () => {
models.map(([model, counts]) => { models.map(([model, counts]) => {
const { name, icon } = resolveModel(model); const { name, icon } = resolveModel(model);
return ( return (
<ActionBarMorePrimitive.Item key={model} className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none" onSelect={(e) => e.preventDefault()}> <ActionBarMorePrimitive.Item
key={model}
className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
onSelect={(e) => e.preventDefault()}
>
<span className="flex items-center gap-1.5 text-xs font-medium"> <span className="flex items-center gap-1.5 text-xs font-medium">
{icon} {icon}
{name} {name}
@ -452,7 +459,10 @@ const MessageInfoDropdown: FC = () => {
); );
}) })
) : ( ) : (
<ActionBarMorePrimitive.Item className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none" onSelect={(e) => e.preventDefault()}> <ActionBarMorePrimitive.Item
className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
onSelect={(e) => e.preventDefault()}
>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{usage.total_tokens.toLocaleString()} tokens {usage.total_tokens.toLocaleString()} tokens
</span> </span>

View file

@ -282,7 +282,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
connector.connector_type === "DROPBOX_CONNECTOR" || connector.connector_type === "DROPBOX_CONNECTOR" ||
connector.connector_type === "ONEDRIVE_CONNECTOR") && ( connector.connector_type === "ONEDRIVE_CONNECTOR") && (
<VisionLLMConfig enabled={enableVisionLlm} onEnabledChange={onEnableVisionLlmChange} /> <VisionLLMConfig
enabled={enableVisionLlm}
onEnabledChange={onEnableVisionLlmChange}
/>
)} )}
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */} {/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */}

View file

@ -168,7 +168,10 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
config.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || config.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
config.connectorType === "DROPBOX_CONNECTOR" || config.connectorType === "DROPBOX_CONNECTOR" ||
config.connectorType === "ONEDRIVE_CONNECTOR") && ( config.connectorType === "ONEDRIVE_CONNECTOR") && (
<VisionLLMConfig enabled={enableVisionLlm} onEnabledChange={onEnableVisionLlmChange} /> <VisionLLMConfig
enabled={enableVisionLlm}
onEnabledChange={onEnableVisionLlmChange}
/>
)} )}
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */} {/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */}

View file

@ -10,11 +10,17 @@ import {
updateConnectorMutationAtom, updateConnectorMutationAtom,
} from "@/atoms/connectors/connector-mutation.atoms"; } from "@/atoms/connectors/connector-mutation.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.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 { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { EnumConnectorName } from "@/contracts/enums/connector"; import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { 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 { authenticatedFetch } from "@/lib/auth-utils";
import { isSelfHosted } from "@/lib/env-config";
import { import {
trackConnectorConnected, trackConnectorConnected,
trackConnectorDeleted, trackConnectorDeleted,
@ -33,12 +39,6 @@ import {
OAUTH_CONNECTORS, OAUTH_CONNECTORS,
OTHER_CONNECTORS, OTHER_CONNECTORS,
} from "../constants/connector-constants"; } 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 { import {
dateRangeSchema, dateRangeSchema,
@ -73,7 +73,6 @@ export const useConnectorDialog = () => {
const { isDesktop } = usePlatform(); const { isDesktop } = usePlatform();
const selfHosted = isSelfHosted(); const selfHosted = isSelfHosted();
// Use global atom for dialog open state so it can be controlled from anywhere // Use global atom for dialog open state so it can be controlled from anywhere
const [isOpen, setIsOpen] = useAtom(connectorDialogOpenAtom); const [isOpen, setIsOpen] = useAtom(connectorDialogOpenAtom);
const [activeTab, setActiveTab] = useState("all"); const [activeTab, setActiveTab] = useState("all");
@ -463,10 +462,16 @@ export const useConnectorDialog = () => {
setConnectingConnectorType(connectorType); setConnectingConnectorType(connectorType);
}, },
[searchSpaceId, selfHosted, isDesktop, setIsOpen, setFolderWatchOpen, setFolderWatchInitialFolder] [
searchSpaceId,
selfHosted,
isDesktop,
setIsOpen,
setFolderWatchOpen,
setFolderWatchInitialFolder,
]
); );
// Handle submitting connect form // Handle submitting connect form
const handleSubmitConnectForm = useCallback( const handleSubmitConnectForm = useCallback(
async ( async (
@ -787,8 +792,8 @@ export const useConnectorDialog = () => {
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined; const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
// Update connector with summary, periodic sync settings, and config changes // Update connector with summary, periodic sync settings, and config changes
if (enableSummary || enableVisionLlm || periodicEnabled || indexingConnectorConfig) { if (enableSummary || enableVisionLlm || periodicEnabled || indexingConnectorConfig) {
const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : undefined; const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : undefined;
await updateConnector({ await updateConnector({
id: indexingConfig.connectorId, id: indexingConfig.connectorId,
data: { data: {

View file

@ -2,12 +2,12 @@
import { Search, Unplug } from "lucide-react"; import { Search, Unplug } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants"; import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping"; import { getDocumentCountForConnector } from "../utils/connector-document-mapping";

View file

@ -60,7 +60,6 @@ import {
} from "@/components/assistant-ui/inline-mention-editor"; } from "@/components/assistant-ui/inline-mention-editor";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { UserMessage } from "@/components/assistant-ui/user-message"; import { UserMessage } from "@/components/assistant-ui/user-message";
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events";
import { import {
DocumentMentionPicker, DocumentMentionPicker,
type DocumentMentionPickerRef, type DocumentMentionPickerRef,
@ -90,6 +89,7 @@ import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsSync } from "@/hooks/use-comments-sync"; import { useCommentsSync } from "@/hooks/use-comments-sync";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform"; import { useElectronAPI } from "@/hooks/use-platform";
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs"; 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))" }} style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
> >
<ThreadScrollToBottom /> <ThreadScrollToBottom />
<AuiIf condition={({ thread }) => !thread.isEmpty}> <AuiIf condition={({ thread }) => !thread.isEmpty}>
<Composer /> <Composer />
</AuiIf> </AuiIf>
</ThreadPrimitive.ViewportFooter> </ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport> </ThreadPrimitive.Viewport>
</ThreadPrimitive.Root> </ThreadPrimitive.Root>

View file

@ -1,13 +1,26 @@
"use client"; "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 { export interface TokenUsageData {
prompt_tokens: number; prompt_tokens: number;
completion_tokens: number; completion_tokens: number;
total_tokens: number; total_tokens: number;
usage?: Record<string, { prompt_tokens: number; completion_tokens: number; total_tokens: number }>; usage?: Record<
model_breakdown?: Record<string, { prompt_tokens: number; completion_tokens: number; total_tokens: number }>; 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; type Listener = () => void;
@ -51,9 +64,10 @@ class TokenUsageStore {
const TokenUsageContext = createContext<TokenUsageStore | null>(null); const TokenUsageContext = createContext<TokenUsageStore | null>(null);
export const TokenUsageProvider: FC<{ store: TokenUsageStore; children: ReactNode }> = ({ store, children }) => ( export const TokenUsageProvider: FC<{ store: TokenUsageStore; children: ReactNode }> = ({
<TokenUsageContext.Provider value={store}>{children}</TokenUsageContext.Provider> store,
); children,
}) => <TokenUsageContext.Provider value={store}>{children}</TokenUsageContext.Provider>;
export function useTokenUsageStore(): TokenUsageStore { export function useTokenUsageStore(): TokenUsageStore {
const store = useContext(TokenUsageContext); const store = useContext(TokenUsageContext);
@ -65,11 +79,11 @@ export function useTokenUsage(messageId: string | undefined): TokenUsageData | u
const store = useContext(TokenUsageContext); const store = useContext(TokenUsageContext);
const getSnapshot = useCallback( const getSnapshot = useCallback(
() => (store && messageId ? store.get(messageId) : undefined), () => (store && messageId ? store.get(messageId) : undefined),
[store, messageId], [store, messageId]
); );
const subscribe = useCallback( const subscribe = useCallback(
(onStoreChange: () => void) => (store ? store.subscribe(onStoreChange) : () => {}), (onStoreChange: () => void) => (store ? store.subscribe(onStoreChange) : () => {}),
[store], [store]
); );
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
} }

View file

@ -6,10 +6,10 @@ import { useEffect, useRef, useState } from "react";
import { clearTargetCommentIdAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { clearTargetCommentIdAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { convertRenderedToDisplay } from "@/lib/comments/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CommentComposer } from "../comment-composer/comment-composer"; import { CommentComposer } from "../comment-composer/comment-composer";
import { CommentActions } from "./comment-actions"; import { CommentActions } from "./comment-actions";
import { convertRenderedToDisplay } from "@/lib/comments/utils";
import type { CommentItemProps } from "./types"; import type { CommentItemProps } from "./types";
function getInitials(name: string | null, email: string): string { function getInitials(name: string | null, email: string): string {
@ -70,7 +70,6 @@ function formatTimestamp(dateString: string): string {
); );
} }
function renderMentions(content: string): React.ReactNode { function renderMentions(content: string): React.ReactNode {
// Match @{DisplayName} format from backend // Match @{DisplayName} format from backend
const mentionPattern = /@\{([^}]+)\}/g; const mentionPattern = /@\{([^}]+)\}/g;

View file

@ -16,9 +16,9 @@ export function ContactFormGridWithDetails() {
<IconMailFilled className="h-6 w-6 text-blue-500" /> <IconMailFilled className="h-6 w-6 text-blue-500" />
</FeatureIconContainer> </FeatureIconContainer>
</div> </div>
<h1 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-center text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300"> <h1 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-center text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
Contact Contact
</h1> </h1>
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 dark:text-neutral-400"> <p className="mt-8 max-w-lg text-center text-base text-neutral-600 dark:text-neutral-400">
We'd love to hear from you! We'd love to hear from you!
</p> </p>

View file

@ -86,8 +86,7 @@ export const DocumentNode = React.memo(function DocumentNode({
const isProcessing = statusState === "pending" || statusState === "processing"; const isProcessing = statusState === "pending" || statusState === "processing";
const isUnavailable = isProcessing || isFailed; const isUnavailable = isProcessing || isFailed;
const isSelectable = !isUnavailable; const isSelectable = !isUnavailable;
const isEditable = const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type) && !isUnavailable;
EDITABLE_DOCUMENT_TYPES.has(doc.document_type) && !isUnavailable;
const handleCheckChange = useCallback(() => { const handleCheckChange = useCallback(() => {
if (isSelectable) { if (isSelectable) {
@ -261,38 +260,38 @@ export const DocumentNode = React.memo(function DocumentNode({
className="w-40" className="w-40"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<DropdownMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}> <DropdownMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
Open Open
</DropdownMenuItem>
{isEditable && (
<DropdownMenuItem onClick={() => onEdit(doc)}>
<PenLine className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem> </DropdownMenuItem>
)} {isEditable && (
<DropdownMenuItem onClick={() => onMove(doc)}> <DropdownMenuItem onClick={() => onEdit(doc)}>
<Move className="mr-2 h-4 w-4" /> <PenLine className="mr-2 h-4 w-4" />
Move to... Edit
</DropdownMenuItem> </DropdownMenuItem>
{onExport && ( )}
<DropdownMenuSub> <DropdownMenuItem onClick={() => onMove(doc)}>
<DropdownMenuSubTrigger disabled={isUnavailable}> <Move className="mr-2 h-4 w-4" />
<Download className="mr-2 h-4 w-4" /> Move to...
Export
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{onVersionHistory && isVersionableType(doc.document_type) && (
<DropdownMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</DropdownMenuItem> </DropdownMenuItem>
)} {onExport && (
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}> <DropdownMenuSub>
<DropdownMenuSubTrigger disabled={isUnavailable}>
<Download className="mr-2 h-4 w-4" />
Export
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{onVersionHistory && isVersionableType(doc.document_type) && (
<DropdownMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
@ -304,38 +303,38 @@ export const DocumentNode = React.memo(function DocumentNode({
{contextMenuOpen && ( {contextMenuOpen && (
<ContextMenuContent className="w-40" onClick={(e) => e.stopPropagation()}> <ContextMenuContent className="w-40" onClick={(e) => e.stopPropagation()}>
<ContextMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}> <ContextMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
Open Open
</ContextMenuItem>
{isEditable && (
<ContextMenuItem onClick={() => onEdit(doc)}>
<PenLine className="mr-2 h-4 w-4" />
Edit
</ContextMenuItem> </ContextMenuItem>
)} {isEditable && (
<ContextMenuItem onClick={() => onMove(doc)}> <ContextMenuItem onClick={() => onEdit(doc)}>
<Move className="mr-2 h-4 w-4" /> <PenLine className="mr-2 h-4 w-4" />
Move to... Edit
</ContextMenuItem> </ContextMenuItem>
{onExport && ( )}
<ContextMenuSub> <ContextMenuItem onClick={() => onMove(doc)}>
<ContextMenuSubTrigger disabled={isUnavailable}> <Move className="mr-2 h-4 w-4" />
<Download className="mr-2 h-4 w-4" /> Move to...
Export
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[180px]">
<ExportContextItems onExport={handleExport} exporting={exporting} />
</ContextMenuSubContent>
</ContextMenuSub>
)}
{onVersionHistory && isVersionableType(doc.document_type) && (
<ContextMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</ContextMenuItem> </ContextMenuItem>
)} {onExport && (
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}> <ContextMenuSub>
<ContextMenuSubTrigger disabled={isUnavailable}>
<Download className="mr-2 h-4 w-4" />
Export
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[180px]">
<ExportContextItems onExport={handleExport} exporting={exporting} />
</ContextMenuSubContent>
</ContextMenuSub>
)}
{onVersionHistory && isVersionableType(doc.document_type) && (
<ContextMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</ContextMenuItem>
)}
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Delete
</ContextMenuItem> </ContextMenuItem>

View file

@ -10,7 +10,6 @@ export function getDocumentTypeIcon(type: string, className?: string): React.Rea
return getConnectorIcon(type, className); return getConnectorIcon(type, className);
} }
export function DocumentTypeChip({ type, className }: { type: string; className?: string }) { export function DocumentTypeChip({ type, className }: { type: string; className?: string }) {
const icon = getDocumentTypeIcon(type, "h-4 w-4"); const icon = getDocumentTypeIcon(type, "h-4 w-4");
const fullLabel = getDocumentTypeLabel(type); const fullLabel = getDocumentTypeLabel(type);

View file

@ -12,10 +12,10 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { getDocumentTypeIcon } from "./DocumentTypeIcon";
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels"; import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
import { cn } from "@/lib/utils";
import { getDocumentTypeIcon } from "./DocumentTypeIcon";
export function DocumentsFilters({ export function DocumentsFilters({
typeCounts: typeCountsRecord, typeCounts: typeCountsRecord,
@ -81,18 +81,18 @@ export function DocumentsFilters({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<ToggleGroupItem <ToggleGroupItem
value="folder" value="folder"
className="h-9 w-9 shrink-0 border-sidebar-border text-muted-foreground hover:text-foreground hover:border-sidebar-border bg-sidebar" className="h-9 w-9 shrink-0 border-sidebar-border text-muted-foreground hover:text-foreground hover:border-sidebar-border bg-sidebar"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
onCreateFolder(); onCreateFolder();
}} }}
> >
<FolderPlus size={14} /> <FolderPlus size={14} />
</ToggleGroupItem> </ToggleGroupItem>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>New folder</TooltipContent> <TooltipContent>New folder</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{onToggleAiSort && ( {onToggleAiSort && (
<Tooltip> <Tooltip>
@ -114,9 +114,9 @@ export function DocumentsFilters({
aria-label={aiSortEnabled ? "Disable AI sort" : "Enable AI sort"} aria-label={aiSortEnabled ? "Disable AI sort" : "Enable AI sort"}
aria-pressed={aiSortEnabled} aria-pressed={aiSortEnabled}
> >
{aiSortBusy ? ( {aiSortBusy ? (
<Spinner size="xs" /> <Spinner size="xs" />
) : aiSortEnabled ? ( ) : aiSortEnabled ? (
<IconBinaryTreeFilled size={16} /> <IconBinaryTreeFilled size={16} />
) : ( ) : (
<IconBinaryTree size={16} /> <IconBinaryTree size={16} />

View file

@ -247,10 +247,8 @@ export function FolderTreeView({
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] { function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
const key = parentId ?? "root"; const key = parentId ?? "root";
const childFolders = (foldersByParent[key] ?? []).slice().sort((a, b) => { const childFolders = (foldersByParent[key] ?? []).slice().sort((a, b) => {
const aIsDate = const aIsDate = a.metadata?.ai_sort === true && a.metadata?.ai_sort_level === 2;
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 bIsDate =
b.metadata?.ai_sort === true && b.metadata?.ai_sort_level === 2;
if (aIsDate && bIsDate) { if (aIsDate && bIsDate) {
return b.name.localeCompare(a.name); return b.name.localeCompare(a.name);
} }

View file

@ -16,9 +16,7 @@ const FENCED_OR_INLINE_CODE = /(```[\s\S]*?```|`[^`\n]+`)/g;
// - <!-- PageBreak --> becomes a thematic break (---) // - <!-- PageBreak --> becomes a thematic break (---)
// - All other HTML comments are removed // - All other HTML comments are removed
function stripHtmlComments(md: string): string { function stripHtmlComments(md: string): string {
return md return md.replace(/<!--\s*PageBreak\s*-->/gi, "\n---\n").replace(/<!--[\s\S]*?-->/g, "");
.replace(/<!--\s*PageBreak\s*-->/gi, "\n---\n")
.replace(/<!--[\s\S]*?-->/g, "");
} }
// Convert <figure>...</figure> blocks to plain text blockquotes. // Convert <figure>...</figure> blocks to plain text blockquotes.

View file

@ -5,8 +5,8 @@ import { useQuery } from "@tanstack/react-query";
import { motion, useMotionValue, useSpring } from "motion/react"; import { motion, useMotionValue, useSpring } from "motion/react";
import * as React from "react"; import * as React from "react";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Per-digit scrolling wheel // Per-digit scrolling wheel
@ -249,10 +249,7 @@ function NavbarGitHubStars({
const { data: stars = 0, isLoading } = useQuery({ const { data: stars = 0, isLoading } = useQuery({
queryKey: cacheKeys.github.repoStars(username, repo), queryKey: cacheKeys.github.repoStars(username, repo),
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
const res = await fetch( const res = await fetch(`https://api.github.com/repos/${username}/${repo}`, { signal });
`https://api.github.com/repos/${username}/${repo}`,
{ signal },
);
const data = await res.json(); const data = await res.json();
if (data && typeof data.stargazers_count === "number") { if (data && typeof data.stargazers_count === "number") {
return data.stargazers_count as number; return data.stargazers_count as number;

View file

@ -153,13 +153,14 @@ export function HeroSection() {
</h1> </h1>
<div className="mt-4 flex w-full flex-col items-start justify-between gap-4 md:mt-12 md:flex-row md:items-end md:gap-10"> <div className="mt-4 flex w-full flex-col items-start justify-between gap-4 md:mt-12 md:flex-row md:items-end md:gap-10">
<div> <div>
<p <p
className={cn( className={cn(
"relative mb-8 max-w-2xl text-left text-sm tracking-wide text-neutral-600 antialiased sm:text-base md:text-xl dark:text-neutral-400" "relative mb-8 max-w-2xl text-left text-sm tracking-wide text-neutral-600 antialiased sm:text-base md:text-xl dark:text-neutral-400"
)} )}
> >
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,
</p> Claude AI, and any AI model for free.
</p>
<div className="relative mb-4 flex w-full flex-col justify-center gap-y-2 sm:flex-row sm:justify-start sm:space-y-0 sm:space-x-4"> <div className="relative mb-4 flex w-full flex-col justify-center gap-y-2 sm:flex-row sm:justify-start sm:space-y-0 sm:space-x-4">
<DownloadButton /> <DownloadButton />

View file

@ -10,8 +10,8 @@ import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface NavItem { interface NavItem {
name: string; name: string;
link: string; link: string;
} }
interface NavbarProps { interface NavbarProps {
@ -20,15 +20,15 @@ interface NavbarProps {
} }
interface DesktopNavProps { interface DesktopNavProps {
navItems: NavItem[]; navItems: NavItem[];
isScrolled: boolean; isScrolled: boolean;
scrolledBgClassName?: string; scrolledBgClassName?: string;
} }
interface MobileNavProps { interface MobileNavProps {
navItems: NavItem[]; navItems: NavItem[];
isScrolled: boolean; isScrolled: boolean;
scrolledBgClassName?: string; scrolledBgClassName?: string;
} }
export const Navbar = ({ scrolledBgClassName }: NavbarProps = {}) => { export const Navbar = ({ scrolledBgClassName }: NavbarProps = {}) => {

View file

@ -167,7 +167,6 @@ export function DocumentsSidebar({
setFolderWatchOpen(true); setFolderWatchOpen(true);
}, [setWatchInitialFolder, setFolderWatchOpen]); }, [setWatchInitialFolder, setFolderWatchOpen]);
const refreshWatchedIds = useCallback(async () => { const refreshWatchedIds = useCallback(async () => {
if (!electronAPI?.getWatchedFolders) return; if (!electronAPI?.getWatchedFolders) return;
const api = electronAPI; const api = electronAPI;
@ -675,10 +674,10 @@ export function DocumentsSidebar({
function collectSubtreeDocs(parentId: number): DocumentNodeDoc[] { function collectSubtreeDocs(parentId: number): DocumentNodeDoc[] {
const directDocs = (treeDocuments ?? []).filter( const directDocs = (treeDocuments ?? []).filter(
(d) => (d) =>
d.folderId === parentId && d.folderId === parentId &&
d.status?.state !== "pending" && d.status?.state !== "pending" &&
d.status?.state !== "processing" && d.status?.state !== "processing" &&
d.status?.state !== "failed" d.status?.state !== "failed"
); );
const childFolders = foldersByParent[String(parentId)] ?? []; const childFolders = foldersByParent[String(parentId)] ?? [];
const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id)); const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id));
@ -1123,16 +1122,14 @@ export function DocumentsSidebar({
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Enable AI File Sorting?</AlertDialogTitle> <AlertDialogTitle>Enable AI File Sorting?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
All documents in this search space will be organized into folders by All documents in this search space will be organized into folders by connector type,
connector type, date, and AI-generated categories. New documents will date, and AI-generated categories. New documents will also be sorted automatically.
also be sorted automatically. You can disable this at any time. You can disable this at any time.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmEnableAiSort}> <AlertDialogAction onClick={handleConfirmEnableAiSort}>Enable</AlertDialogAction>
Enable
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View file

@ -22,8 +22,6 @@ import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; 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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -57,6 +55,8 @@ import { useDebouncedValue } from "@/hooks/use-debounced-value";
import type { InboxItem } from "@/hooks/use-inbox"; import type { InboxItem } from "@/hooks/use-inbox";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { notificationsApiService } from "@/lib/apis/notifications-api.service"; 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 { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";

View file

@ -5,7 +5,6 @@ import { useCallback, useEffect } from "react";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events"; import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events";
interface SidebarSlideOutPanelProps { interface SidebarSlideOutPanelProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;

View file

@ -67,13 +67,7 @@ export const DocumentMentionPicker = forwardRef<
DocumentMentionPickerRef, DocumentMentionPickerRef,
DocumentMentionPickerProps DocumentMentionPickerProps
>(function DocumentMentionPicker( >(function DocumentMentionPicker(
{ { searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" },
searchSpaceId,
onSelectionChange,
onDone,
initialSelectedDocuments = [],
externalSearch = "",
},
ref ref
) { ) {
// Debounced search value to minimize API calls and prevent race conditions // Debounced search value to minimize API calls and prevent race conditions

View file

@ -1,6 +1,5 @@
"use client"; "use client";
import type React from "react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import {
Bot, Bot,
@ -11,12 +10,13 @@ import {
ChevronUp, ChevronUp,
Edit3, Edit3,
ImageIcon, ImageIcon,
ScanEye,
Layers, Layers,
Plus, Plus,
ScanEye,
Search, Search,
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import type React from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -178,8 +178,7 @@ function formatProviderName(provider: string): string {
const key = provider.toUpperCase(); const key = provider.toUpperCase();
return ( return (
PROVIDER_NAMES[key] ?? PROVIDER_NAMES[key] ??
provider.charAt(0).toUpperCase() + provider.charAt(0).toUpperCase() + provider.slice(1).toLowerCase().replace(/_/g, " ")
provider.slice(1).toLowerCase().replace(/_/g, " ")
); );
} }
@ -202,14 +201,12 @@ interface ConfigBase {
function filterAndScore<T extends ConfigBase>( function filterAndScore<T extends ConfigBase>(
configs: T[], configs: T[],
selectedProvider: string, selectedProvider: string,
searchQuery: string, searchQuery: string
): T[] { ): T[] {
let result = configs; let result = configs;
if (selectedProvider !== "all") { if (selectedProvider !== "all") {
result = result.filter( result = result.filter((c) => c.provider.toUpperCase() === selectedProvider);
(c) => c.provider.toUpperCase() === selectedProvider,
);
} }
if (!searchQuery.trim()) return result; if (!searchQuery.trim()) return result;
@ -218,9 +215,7 @@ function filterAndScore<T extends ConfigBase>(
const tokens = normalized.split(/\s+/).filter(Boolean); const tokens = normalized.split(/\s+/).filter(Boolean);
const scored = result.map((c) => { const scored = result.map((c) => {
const aggregate = normalizeText( const aggregate = normalizeText([c.name, c.model_name, c.provider].join(" "));
[c.name, c.model_name, c.provider].join(" "),
);
let score = 0; let score = 0;
if (aggregate.includes(normalized)) score += 5; if (aggregate.includes(normalized)) score += 5;
for (const token of tokens) { for (const token of tokens) {
@ -244,20 +239,11 @@ interface DisplayItem {
// ─── Component ────────────────────────────────────────────────────── // ─── Component ──────────────────────────────────────────────────────
interface ModelSelectorProps { interface ModelSelectorProps {
onEditLLM: ( onEditLLM: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void;
config: NewLLMConfigPublic | GlobalNewLLMConfig,
isGlobal: boolean,
) => void;
onAddNewLLM: (provider?: string) => void; onAddNewLLM: (provider?: string) => void;
onEditImage?: ( onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void;
config: ImageGenerationConfig | GlobalImageGenConfig,
isGlobal: boolean,
) => void;
onAddNewImage?: (provider?: string) => void; onAddNewImage?: (provider?: string) => void;
onEditVision?: ( onEditVision?: (config: VisionLLMConfig | GlobalVisionLLMConfig, isGlobal: boolean) => void;
config: VisionLLMConfig | GlobalVisionLLMConfig,
isGlobal: boolean,
) => void;
onAddNewVision?: (provider?: string) => void; onAddNewVision?: (provider?: string) => void;
className?: string; className?: string;
} }
@ -272,9 +258,7 @@ export function ModelSelector({
className, className,
}: ModelSelectorProps) { }: ModelSelectorProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"llm" | "image" | "vision">( const [activeTab, setActiveTab] = useState<"llm" | "image" | "vision">("llm");
"llm",
);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selectedProvider, setSelectedProvider] = useState<string>("all"); const [selectedProvider, setSelectedProvider] = useState<string>("all");
const [focusedIndex, setFocusedIndex] = useState(-1); const [focusedIndex, setFocusedIndex] = useState(-1);
@ -292,18 +276,21 @@ export function ModelSelector({
setModelScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); setModelScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []); }, []);
const handleSidebarScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { const handleSidebarScroll = useCallback(
const el = e.currentTarget; (e: React.UIEvent<HTMLDivElement>) => {
if (isMobile) { const el = e.currentTarget;
const atStart = el.scrollLeft <= 2; if (isMobile) {
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2; const atStart = el.scrollLeft <= 2;
setSidebarScrollPos(atStart ? "top" : atEnd ? "bottom" : "middle"); const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
} else { setSidebarScrollPos(atStart ? "top" : atEnd ? "bottom" : "middle");
const atTop = el.scrollTop <= 2; } else {
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; const atTop = el.scrollTop <= 2;
setSidebarScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
} setSidebarScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, [isMobile]); }
},
[isMobile]
);
// Reset search + provider when tab changes // Reset search + provider when tab changes
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger // biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger
@ -344,22 +331,18 @@ export function ModelSelector({
}, [open, isMobile, activeTab]); }, [open, isMobile, activeTab]);
// ─── Data ─── // ─── Data ───
const { data: llmUserConfigs, isLoading: llmUserLoading } = const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
useAtomValue(newLLMConfigsAtom);
const { data: llmGlobalConfigs, isLoading: llmGlobalLoading } = const { data: llmGlobalConfigs, isLoading: llmGlobalLoading } =
useAtomValue(globalNewLLMConfigsAtom); useAtomValue(globalNewLLMConfigsAtom);
const { data: preferences, isLoading: prefsLoading } = const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom);
useAtomValue(llmPreferencesAtom);
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const { mutateAsync: updatePreferences } = useAtomValue( const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
updateLLMPreferencesMutationAtom,
);
const { data: imageGlobalConfigs, isLoading: imageGlobalLoading } = const { data: imageGlobalConfigs, isLoading: imageGlobalLoading } =
useAtomValue(globalImageGenConfigsAtom); useAtomValue(globalImageGenConfigsAtom);
const { data: imageUserConfigs, isLoading: imageUserLoading } = const { data: imageUserConfigs, isLoading: imageUserLoading } = useAtomValue(imageGenConfigsAtom);
useAtomValue(imageGenConfigsAtom); const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } = useAtomValue(
const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } = globalVisionLLMConfigsAtom
useAtomValue(globalVisionLLMConfigsAtom); );
const { data: visionUserConfigs, isLoading: visionUserLoading } = const { data: visionUserConfigs, isLoading: visionUserLoading } =
useAtomValue(visionLLMConfigsAtom); useAtomValue(visionLLMConfigsAtom);
@ -382,9 +365,7 @@ export function ModelSelector({
}, [preferences, llmGlobalConfigs, llmUserConfigs]); }, [preferences, llmGlobalConfigs, llmUserConfigs]);
const isLLMAutoMode = const isLLMAutoMode =
currentLLMConfig && currentLLMConfig && "is_auto_mode" in currentLLMConfig && currentLLMConfig.is_auto_mode;
"is_auto_mode" in currentLLMConfig &&
currentLLMConfig.is_auto_mode;
const currentImageConfig = useMemo(() => { const currentImageConfig = useMemo(() => {
if (!preferences) return null; if (!preferences) return null;
@ -398,9 +379,7 @@ export function ModelSelector({
}, [preferences, imageGlobalConfigs, imageUserConfigs]); }, [preferences, imageGlobalConfigs, imageUserConfigs]);
const isImageAutoMode = const isImageAutoMode =
currentImageConfig && currentImageConfig && "is_auto_mode" in currentImageConfig && currentImageConfig.is_auto_mode;
"is_auto_mode" in currentImageConfig &&
currentImageConfig.is_auto_mode;
const currentVisionConfig = useMemo(() => { const currentVisionConfig = useMemo(() => {
if (!preferences) return null; if (!preferences) return null;
@ -420,83 +399,47 @@ export function ModelSelector({
// ─── Filtered configs (separate global / user for section headers) ─── // ─── Filtered configs (separate global / user for section headers) ───
const filteredLLMGlobal = useMemo( const filteredLLMGlobal = useMemo(
() => () => filterAndScore(llmGlobalConfigs ?? [], selectedProvider, searchQuery),
filterAndScore(llmGlobalConfigs ?? [], selectedProvider, searchQuery), [llmGlobalConfigs, selectedProvider, searchQuery]
[llmGlobalConfigs, selectedProvider, searchQuery],
); );
const filteredLLMUser = useMemo( const filteredLLMUser = useMemo(
() => () => filterAndScore(llmUserConfigs ?? [], selectedProvider, searchQuery),
filterAndScore(llmUserConfigs ?? [], selectedProvider, searchQuery), [llmUserConfigs, selectedProvider, searchQuery]
[llmUserConfigs, selectedProvider, searchQuery],
); );
const filteredImageGlobal = useMemo( const filteredImageGlobal = useMemo(
() => () => filterAndScore(imageGlobalConfigs ?? [], selectedProvider, searchQuery),
filterAndScore( [imageGlobalConfigs, selectedProvider, searchQuery]
imageGlobalConfigs ?? [],
selectedProvider,
searchQuery,
),
[imageGlobalConfigs, selectedProvider, searchQuery],
); );
const filteredImageUser = useMemo( const filteredImageUser = useMemo(
() => () => filterAndScore(imageUserConfigs ?? [], selectedProvider, searchQuery),
filterAndScore( [imageUserConfigs, selectedProvider, searchQuery]
imageUserConfigs ?? [],
selectedProvider,
searchQuery,
),
[imageUserConfigs, selectedProvider, searchQuery],
); );
const filteredVisionGlobal = useMemo( const filteredVisionGlobal = useMemo(
() => () => filterAndScore(visionGlobalConfigs ?? [], selectedProvider, searchQuery),
filterAndScore( [visionGlobalConfigs, selectedProvider, searchQuery]
visionGlobalConfigs ?? [],
selectedProvider,
searchQuery,
),
[visionGlobalConfigs, selectedProvider, searchQuery],
); );
const filteredVisionUser = useMemo( const filteredVisionUser = useMemo(
() => () => filterAndScore(visionUserConfigs ?? [], selectedProvider, searchQuery),
filterAndScore( [visionUserConfigs, selectedProvider, searchQuery]
visionUserConfigs ?? [],
selectedProvider,
searchQuery,
),
[visionUserConfigs, selectedProvider, searchQuery],
); );
// Combined display list for keyboard navigation // Combined display list for keyboard navigation
const currentDisplayItems: DisplayItem[] = useMemo(() => { const currentDisplayItems: DisplayItem[] = useMemo(() => {
const toItems = ( const toItems = (configs: ConfigBase[], isGlobal: boolean): DisplayItem[] =>
configs: ConfigBase[],
isGlobal: boolean,
): DisplayItem[] =>
configs.map((c) => ({ configs.map((c) => ({
config: c as ConfigBase & Record<string, unknown>, config: c as ConfigBase & Record<string, unknown>,
isGlobal, isGlobal,
isAutoMode: isAutoMode:
isGlobal && isGlobal && "is_auto_mode" in c && !!(c as Record<string, unknown>).is_auto_mode,
"is_auto_mode" in c &&
!!(c as Record<string, unknown>).is_auto_mode,
})); }));
switch (activeTab) { switch (activeTab) {
case "llm": case "llm":
return [ return [...toItems(filteredLLMGlobal, true), ...toItems(filteredLLMUser, false)];
...toItems(filteredLLMGlobal, true),
...toItems(filteredLLMUser, false),
];
case "image": case "image":
return [ return [...toItems(filteredImageGlobal, true), ...toItems(filteredImageUser, false)];
...toItems(filteredImageGlobal, true),
...toItems(filteredImageUser, false),
];
case "vision": case "vision":
return [ return [...toItems(filteredVisionGlobal, true), ...toItems(filteredVisionUser, false)];
...toItems(filteredVisionGlobal, true),
...toItems(filteredVisionUser, false),
];
} }
}, [ }, [
activeTab, activeTab,
@ -513,19 +456,10 @@ export function ModelSelector({
const configuredProviderSet = useMemo(() => { const configuredProviderSet = useMemo(() => {
const configs = const configs =
activeTab === "llm" activeTab === "llm"
? [ ? [...(llmGlobalConfigs ?? []), ...(llmUserConfigs ?? [])]
...(llmGlobalConfigs ?? []),
...(llmUserConfigs ?? []),
]
: activeTab === "image" : activeTab === "image"
? [ ? [...(imageGlobalConfigs ?? []), ...(imageUserConfigs ?? [])]
...(imageGlobalConfigs ?? []), : [...(visionGlobalConfigs ?? []), ...(visionUserConfigs ?? [])];
...(imageUserConfigs ?? []),
]
: [
...(visionGlobalConfigs ?? []),
...(visionUserConfigs ?? []),
];
const set = new Set<string>(); const set = new Set<string>();
for (const c of configs) { for (const c of configs) {
if (c.provider) set.add(c.provider.toUpperCase()); 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 // Show only providers valid for the active tab; configured ones first
const activeProviders = useMemo(() => { const activeProviders = useMemo(() => {
const tabKeys = PROVIDER_KEYS_BY_TAB[activeTab] ?? LLM_PROVIDER_KEYS; const tabKeys = PROVIDER_KEYS_BY_TAB[activeTab] ?? LLM_PROVIDER_KEYS;
const configured = tabKeys.filter((p) => const configured = tabKeys.filter((p) => configuredProviderSet.has(p));
configuredProviderSet.has(p), const unconfigured = tabKeys.filter((p) => !configuredProviderSet.has(p));
);
const unconfigured = tabKeys.filter(
(p) => !configuredProviderSet.has(p),
);
return ["all", ...configured, ...unconfigured]; return ["all", ...configured, ...unconfigured];
}, [activeTab, configuredProviderSet]); }, [activeTab, configuredProviderSet]);
const providerModelCounts = useMemo(() => { const providerModelCounts = useMemo(() => {
const allConfigs = const allConfigs =
activeTab === "llm" activeTab === "llm"
? [ ? [...(llmGlobalConfigs ?? []), ...(llmUserConfigs ?? [])]
...(llmGlobalConfigs ?? []),
...(llmUserConfigs ?? []),
]
: activeTab === "image" : activeTab === "image"
? [ ? [...(imageGlobalConfigs ?? []), ...(imageUserConfigs ?? [])]
...(imageGlobalConfigs ?? []), : [...(visionGlobalConfigs ?? []), ...(visionUserConfigs ?? [])];
...(imageUserConfigs ?? []),
]
: [
...(visionGlobalConfigs ?? []),
...(visionUserConfigs ?? []),
];
const counts: Record<string, number> = { all: allConfigs.length }; const counts: Record<string, number> = { all: allConfigs.length };
for (const c of allConfigs) { for (const c of allConfigs) {
const p = c.provider.toUpperCase(); const p = c.provider.toUpperCase();
@ -607,7 +528,7 @@ export function ModelSelector({
toast.error("Failed to switch model"); toast.error("Failed to switch model");
} }
}, },
[currentLLMConfig, searchSpaceId, updatePreferences], [currentLLMConfig, searchSpaceId, updatePreferences]
); );
const handleSelectImage = useCallback( const handleSelectImage = useCallback(
@ -631,7 +552,7 @@ export function ModelSelector({
toast.error("Failed to switch image model"); toast.error("Failed to switch image model");
} }
}, },
[currentImageConfig, searchSpaceId, updatePreferences], [currentImageConfig, searchSpaceId, updatePreferences]
); );
const handleSelectVision = useCallback( const handleSelectVision = useCallback(
@ -655,16 +576,14 @@ export function ModelSelector({
toast.error("Failed to switch vision model"); toast.error("Failed to switch vision model");
} }
}, },
[currentVisionConfig, searchSpaceId, updatePreferences], [currentVisionConfig, searchSpaceId, updatePreferences]
); );
const handleSelectItem = useCallback( const handleSelectItem = useCallback(
(item: DisplayItem) => { (item: DisplayItem) => {
switch (activeTab) { switch (activeTab) {
case "llm": case "llm":
handleSelectLLM( handleSelectLLM(item.config as NewLLMConfigPublic | GlobalNewLLMConfig);
item.config as NewLLMConfigPublic | GlobalNewLLMConfig,
);
break; break;
case "image": case "image":
handleSelectImage(item.config.id); handleSelectImage(item.config.id);
@ -674,7 +593,7 @@ export function ModelSelector({
break; break;
} }
}, },
[activeTab, handleSelectLLM, handleSelectImage, handleSelectVision], [activeTab, handleSelectLLM, handleSelectImage, handleSelectVision]
); );
const handleEditItem = useCallback( const handleEditItem = useCallback(
@ -683,26 +602,17 @@ export function ModelSelector({
setOpen(false); setOpen(false);
switch (activeTab) { switch (activeTab) {
case "llm": case "llm":
onEditLLM( onEditLLM(item.config as NewLLMConfigPublic | GlobalNewLLMConfig, item.isGlobal);
item.config as NewLLMConfigPublic | GlobalNewLLMConfig,
item.isGlobal,
);
break; break;
case "image": case "image":
onEditImage?.( onEditImage?.(item.config as ImageGenerationConfig | GlobalImageGenConfig, item.isGlobal);
item.config as ImageGenerationConfig | GlobalImageGenConfig,
item.isGlobal,
);
break; break;
case "vision": case "vision":
onEditVision?.( onEditVision?.(item.config as VisionLLMConfig | GlobalVisionLLMConfig, item.isGlobal);
item.config as VisionLLMConfig | GlobalVisionLLMConfig,
item.isGlobal,
);
break; break;
} }
}, },
[activeTab, onEditLLM, onEditImage, onEditVision], [activeTab, onEditLLM, onEditImage, onEditVision]
); );
// ─── Keyboard navigation ─── // ─── Keyboard navigation ───
@ -713,8 +623,7 @@ export function ModelSelector({
useEffect(() => { useEffect(() => {
if (focusedIndex < 0 || !modelListRef.current) return; if (focusedIndex < 0 || !modelListRef.current) return;
const items = const items = modelListRef.current.querySelectorAll("[data-model-index]");
modelListRef.current.querySelectorAll("[data-model-index]");
items[focusedIndex]?.scrollIntoView({ items[focusedIndex]?.scrollIntoView({
block: "nearest", block: "nearest",
behavior: "smooth", behavior: "smooth",
@ -734,13 +643,11 @@ export function ModelSelector({
if (e.key === "ArrowLeft") { if (e.key === "ArrowLeft") {
next = idx > 0 ? idx - 1 : providers.length - 1; next = idx > 0 ? idx - 1 : providers.length - 1;
} else { } else {
next = next = idx < providers.length - 1 ? idx + 1 : 0;
idx < providers.length - 1 ? idx + 1 : 0;
} }
setSelectedProvider(providers[next]); setSelectedProvider(providers[next]);
if (providerSidebarRef.current) { if (providerSidebarRef.current) {
const buttons = const buttons = providerSidebarRef.current.querySelectorAll("button");
providerSidebarRef.current.querySelectorAll("button");
buttons[next]?.scrollIntoView({ buttons[next]?.scrollIntoView({
block: "nearest", block: "nearest",
inline: "nearest", inline: "nearest",
@ -755,15 +662,11 @@ export function ModelSelector({
switch (e.key) { switch (e.key) {
case "ArrowDown": case "ArrowDown":
e.preventDefault(); e.preventDefault();
setFocusedIndex((prev) => setFocusedIndex((prev) => (prev < count - 1 ? prev + 1 : 0));
prev < count - 1 ? prev + 1 : 0,
);
break; break;
case "ArrowUp": case "ArrowUp":
e.preventDefault(); e.preventDefault();
setFocusedIndex((prev) => setFocusedIndex((prev) => (prev > 0 ? prev - 1 : count - 1));
prev > 0 ? prev - 1 : count - 1,
);
break; break;
case "Enter": case "Enter":
e.preventDefault(); e.preventDefault();
@ -781,13 +684,7 @@ export function ModelSelector({
break; break;
} }
}, },
[ [currentDisplayItems, focusedIndex, activeProviders, selectedProvider, handleSelectItem]
currentDisplayItems,
focusedIndex,
activeProviders,
selectedProvider,
handleSelectItem,
],
); );
// ─── Render: Provider sidebar ─── // ─── Render: Provider sidebar ───
@ -798,7 +695,7 @@ export function ModelSelector({
<div <div
className={cn( className={cn(
"shrink-0 border-border/50 flex", "shrink-0 border-border/50 flex",
isMobile ? "flex-row items-center border-b border-border/40" : "flex-col w-10 border-r", isMobile ? "flex-row items-center border-b border-border/40" : "flex-col w-10 border-r"
)} )}
> >
{!isMobile && sidebarScrollPos !== "top" && ( {!isMobile && sidebarScrollPos !== "top" && (
@ -817,29 +714,29 @@ export function ModelSelector({
className={cn( className={cn(
isMobile 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-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 ? { style={
maskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`, isMobile
WebkitMaskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`, ? {
} : { maskImage: `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 right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${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) => { {activeProviders.map((provider, idx) => {
const isAll = provider === "all"; const isAll = provider === "all";
const isActive = selectedProvider === provider; const isActive = selectedProvider === provider;
const count = providerModelCounts[provider] || 0; const count = providerModelCounts[provider] || 0;
const isConfigured = const isConfigured = isAll || configuredProviderSet.has(provider);
isAll || configuredProviderSet.has(provider);
// Separator between configured and unconfigured providers // Separator between configured and unconfigured providers
// idx 0 is "all", configured run from 1..configuredCount, unconfigured start at configuredCount+1 // idx 0 is "all", configured run from 1..configuredCount, unconfigured start at configuredCount+1
const showSeparator = const showSeparator = !isAll && idx === configuredCount + 1 && configuredCount > 0;
!isAll &&
idx === configuredCount + 1 &&
configuredCount > 0;
return ( return (
<Fragment key={provider}> <Fragment key={provider}>
@ -853,20 +750,16 @@ export function ModelSelector({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
onClick={() => onClick={() => setSelectedProvider(provider)}
setSelectedProvider(provider)
}
tabIndex={-1} tabIndex={-1}
className={cn( className={cn(
"relative flex items-center justify-center rounded-md transition-all duration-150", "relative flex items-center justify-center rounded-md transition-all duration-150",
isMobile isMobile ? "p-2 shrink-0" : "p-1.5 w-full",
? "p-2 shrink-0"
: "p-1.5 w-full",
isActive isActive
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: isConfigured : isConfigured
? "hover:bg-accent/60 text-muted-foreground hover:text-foreground" ? "hover:bg-accent/60 text-muted-foreground hover:text-foreground"
: "opacity-50 hover:opacity-80 hover:bg-accent/40 text-muted-foreground", : "opacity-50 hover:opacity-80 hover:bg-accent/40 text-muted-foreground"
)} )}
> >
{isAll ? ( {isAll ? (
@ -878,19 +771,9 @@ export function ModelSelector({
)} )}
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent <TooltipContent side={isMobile ? "bottom" : "right"}>
side={ {isAll ? "All Models" : formatProviderName(provider)}
isMobile ? "bottom" : "right" {isConfigured ? ` (${count})` : " (not configured)"}
}
>
{isAll
? "All Models"
: formatProviderName(
provider,
)}
{isConfigured
? ` (${count})`
: " (not configured)"}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</Fragment> </Fragment>
@ -927,8 +810,7 @@ export function ModelSelector({
const { config, isAutoMode } = item; const { config, isAutoMode } = item;
const isSelected = getSelectedId() === config.id; const isSelected = getSelectedId() === config.id;
const isFocused = focusedIndex === index; const isFocused = focusedIndex === index;
const hasCitations = const hasCitations = "citations_enabled" in config && !!config.citations_enabled;
"citations_enabled" in config && !!config.citations_enabled;
return ( return (
<div <div
@ -938,19 +820,23 @@ export function ModelSelector({
tabIndex={isMobile ? -1 : 0} tabIndex={isMobile ? -1 : 0}
aria-selected={isSelected} aria-selected={isSelected}
onClick={() => handleSelectItem(item)} onClick={() => handleSelectItem(item)}
onKeyDown={isMobile ? undefined : (e) => { onKeyDown={
if (e.key === "Enter" || e.key === " ") { isMobile
e.preventDefault(); ? undefined
handleSelectItem(item); : (e) => {
} if (e.key === "Enter" || e.key === " ") {
}} e.preventDefault();
handleSelectItem(item);
}
}
}
onMouseEnter={() => setFocusedIndex(index)} onMouseEnter={() => setFocusedIndex(index)}
className={cn( className={cn(
"group flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer", "group flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer",
"transition-all duration-150 mx-2", "transition-all duration-150 mx-2",
"hover:bg-accent/40", "hover:bg-accent/40",
isSelected && "bg-primary/6 dark:bg-primary/8", isSelected && "bg-primary/6 dark:bg-primary/8",
isFocused && "bg-accent/50", isFocused && "bg-accent/50"
)} )}
> >
{/* Provider icon */} {/* Provider icon */}
@ -964,9 +850,7 @@ export function ModelSelector({
{/* Model info */} {/* Model info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="font-medium text-sm truncate"> <span className="font-medium text-sm truncate">{config.name}</span>
{config.name}
</span>
{isAutoMode && ( {isAutoMode && (
<Badge <Badge
variant="secondary" variant="secondary"
@ -978,9 +862,7 @@ export function ModelSelector({
</div> </div>
<div className="flex items-center gap-1.5 mt-0.5"> <div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-muted-foreground truncate"> <span className="text-xs text-muted-foreground truncate">
{isAutoMode {isAutoMode ? "Auto Mode" : (config.model_name as string)}
? "Auto Mode"
: (config.model_name as string)}
</span> </span>
{!isAutoMode && hasCitations && ( {!isAutoMode && hasCitations && (
<Badge <Badge
@ -1005,9 +887,7 @@ export function ModelSelector({
<Edit3 className="size-3.5 text-muted-foreground" /> <Edit3 className="size-3.5 text-muted-foreground" />
</Button> </Button>
)} )}
{isSelected && ( {isSelected && <Check className="size-4 text-primary shrink-0" />}
<Check className="size-4 text-primary shrink-0" />
)}
</div> </div>
</div> </div>
); );
@ -1021,11 +901,7 @@ export function ModelSelector({
const userStartIdx = globalItems.length; const userStartIdx = globalItems.length;
const addHandler = const addHandler =
activeTab === "llm" activeTab === "llm" ? onAddNewLLM : activeTab === "image" ? onAddNewImage : onAddNewVision;
? onAddNewLLM
: activeTab === "image"
? onAddNewImage
: onAddNewVision;
const addLabel = const addLabel =
activeTab === "llm" activeTab === "llm"
? "Add Model" ? "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]", "flex items-center justify-center gap-1.5 text-sm font-medium transition-all duration-200 border-b-[1.5px]",
activeTab === value activeTab === value
? "border-foreground dark:border-white text-foreground" ? "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"
)} )}
> >
<Icon className="size-3.5" /> <Icon className="size-3.5" />
@ -1076,14 +952,7 @@ export function ModelSelector({
</div> </div>
{/* Two-pane layout */} {/* Two-pane layout */}
<div <div className={cn("flex", isMobile ? "flex-col h-[60vh]" : "flex-row h-[380px]")}>
className={cn(
"flex",
isMobile
? "flex-col h-[60vh]"
: "flex-row h-[380px]",
)}
>
{/* Provider sidebar */} {/* Provider sidebar */}
{renderProviderSidebar()} {renderProviderSidebar()}
@ -1096,9 +965,7 @@ export function ModelSelector({
ref={searchInputRef} ref={searchInputRef}
placeholder="Search models" placeholder="Search models"
value={searchQuery} value={searchQuery}
onChange={(e) => onChange={(e) => setSearchQuery(e.target.value)}
setSearchQuery(e.target.value)
}
onKeyDown={isMobile ? undefined : handleKeyDown} onKeyDown={isMobile ? undefined : handleKeyDown}
role="combobox" role="combobox"
aria-expanded={true} aria-expanded={true}
@ -1106,7 +973,7 @@ export function ModelSelector({
className={cn( className={cn(
"w-full pl-8 pr-3 py-2.5 text-sm bg-transparent", "w-full pl-8 pr-3 py-2.5 text-sm bg-transparent",
"focus:outline-none", "focus:outline-none",
"placeholder:text-muted-foreground", "placeholder:text-muted-foreground"
)} )}
/> />
</div> </div>
@ -1117,13 +984,9 @@ export function ModelSelector({
{getProviderIcon(selectedProvider, { {getProviderIcon(selectedProvider, {
className: "size-4", className: "size-4",
})} })}
<span className="text-sm font-medium"> <span className="text-sm font-medium">{formatProviderName(selectedProvider)}</span>
{formatProviderName(selectedProvider)}
</span>
<span className="text-xs text-muted-foreground ml-auto"> <span className="text-xs text-muted-foreground ml-auto">
{configuredProviderSet.has( {configuredProviderSet.has(selectedProvider)
selectedProvider,
)
? `${providerModelCounts[selectedProvider] || 0} models` ? `${providerModelCounts[selectedProvider] || 0} models`
: "Not configured"} : "Not configured"}
</span> </span>
@ -1144,30 +1007,18 @@ export function ModelSelector({
> >
{currentDisplayItems.length === 0 ? ( {currentDisplayItems.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center gap-3 px-4"> <div className="flex-1 flex flex-col items-center justify-center gap-3 px-4">
{selectedProvider !== "all" && {selectedProvider !== "all" && !configuredProviderSet.has(selectedProvider) ? (
!configuredProviderSet.has(
selectedProvider,
) ? (
<> <>
<div className="opacity-40"> <div className="opacity-40">
{getProviderIcon( {getProviderIcon(selectedProvider, {
selectedProvider, className: "size-10",
{ })}
className:
"size-10",
},
)}
</div> </div>
<p className="text-sm font-medium text-muted-foreground"> <p className="text-sm font-medium text-muted-foreground">
No{" "} No {formatProviderName(selectedProvider)} models configured
{formatProviderName(
selectedProvider,
)}{" "}
models configured
</p> </p>
<p className="text-xs text-muted-foreground/60 text-center"> <p className="text-xs text-muted-foreground/60 text-center">
Add a model with this Add a model with this provider to get started
provider to get started
</p> </p>
{addHandler && ( {addHandler && (
<Button <Button
@ -1186,12 +1037,9 @@ export function ModelSelector({
) : searchQuery ? ( ) : searchQuery ? (
<> <>
<Search className="size-8 text-muted-foreground" /> <Search className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">No models found</p>
No models found
</p>
<p className="text-xs text-muted-foreground/60"> <p className="text-xs text-muted-foreground/60">
Try a different search Try a different search term
term
</p> </p>
</> </>
) : ( ) : (
@ -1212,29 +1060,18 @@ export function ModelSelector({
<div className="flex items-center gap-2 px-3 py-1.5 text-[12px] font-semibold text-muted-foreground tracking-wider"> <div className="flex items-center gap-2 px-3 py-1.5 text-[12px] font-semibold text-muted-foreground tracking-wider">
Global Models Global Models
</div> </div>
{globalItems.map((item, i) => {globalItems.map((item, i) => renderModelCard(item, globalStartIdx + i))}
renderModelCard(
item,
globalStartIdx + i,
),
)}
</> </>
)} )}
{globalItems.length > 0 && {globalItems.length > 0 && userItems.length > 0 && (
userItems.length > 0 && ( <div className="my-1.5 mx-4 h-px bg-border/60" />
<div className="my-1.5 mx-4 h-px bg-border/60" /> )}
)}
{userItems.length > 0 && ( {userItems.length > 0 && (
<> <>
<div className="flex items-center gap-2 px-3 py-1.5 text-[12px] font-semibold text-muted-foreground tracking-wider"> <div className="flex items-center gap-2 px-3 py-1.5 text-[12px] font-semibold text-muted-foreground tracking-wider">
Your Configurations Your Configurations
</div> </div>
{userItems.map((item, i) => {userItems.map((item, i) => renderModelCard(item, userStartIdx + i))}
renderModelCard(
item,
userStartIdx + i,
),
)}
</> </>
)} )}
</> </>
@ -1254,9 +1091,7 @@ export function ModelSelector({
}} }}
> >
<Plus className="size-4 text-primary" /> <Plus className="size-4 text-primary" />
<span className="text-sm font-medium"> <span className="text-sm font-medium">{addLabel}</span>
{addLabel}
</span>
</Button> </Button>
</div> </div>
)} )}
@ -1275,18 +1110,13 @@ export function ModelSelector({
aria-expanded={open} aria-expanded={open}
className={cn( 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", "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 ? ( {isLoading ? (
<> <>
<Spinner <Spinner size="sm" className="text-muted-foreground" />
size="sm" <span className="text-muted-foreground hidden md:inline">Loading</span>
className="text-muted-foreground"
/>
<span className="text-muted-foreground hidden md:inline">
Loading
</span>
</> </>
) : ( ) : (
<> <>
@ -1303,9 +1133,7 @@ export function ModelSelector({
) : ( ) : (
<> <>
<Bot className="size-4 text-muted-foreground" /> <Bot className="size-4 text-muted-foreground" />
<span className="text-muted-foreground hidden md:inline"> <span className="text-muted-foreground hidden md:inline">Select Model</span>
Select Model
</span>
</> </>
)} )}
<div className="h-4 w-px bg-border/60 dark:bg-white/10 mx-0.5" /> <div className="h-4 w-px bg-border/60 dark:bg-white/10 mx-0.5" />
@ -1352,9 +1180,7 @@ export function ModelSelector({
<DrawerHeader className="pb-0"> <DrawerHeader className="pb-0">
<DrawerTitle>Select Model</DrawerTitle> <DrawerTitle>Select Model</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">{renderContent()}</div>
{renderContent()}
</div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
); );

View file

@ -182,7 +182,9 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
onMouseEnter={() => setHighlightedIndex(createPromptIndex)} onMouseEnter={() => setHighlightedIndex(createPromptIndex)}
className={cn( 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", "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"
)} )}
> >
<span className="shrink-0"> <span className="shrink-0">

View file

@ -1,7 +1,8 @@
"use client"; "use client";
import React, { useRef, useEffect, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { IconPlus } from "@tabler/icons-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 { Pricing } from "@/components/pricing";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -79,10 +80,15 @@ const faqData: FAQSection[] = [
title: "Pages & Billing", title: "Pages & Billing",
items: [ items: [
{ {
question: "What exactly is a \"page\" in SurfSense?", question: 'What exactly is a "page" in SurfSense?',
answer: 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.", "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?", question: "How does the Pay As You Go plan work?",
answer: answer:
@ -111,7 +117,7 @@ const faqData: FAQSection[] = [
{ {
question: "How are pages consumed?", question: "How are pages consumed?",
answer: 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?", question: "Do connectors like Slack, Notion, or Gmail use pages?",
@ -132,13 +138,7 @@ const faqData: FAQSection[] = [
}, },
]; ];
const GridLineHorizontal = ({ const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => {
className,
offset,
}: {
className?: string;
offset?: string;
}) => {
return ( return (
<div <div
style={ style={
@ -162,19 +162,13 @@ const GridLineHorizontal = ({
"mask-exclude", "mask-exclude",
"z-30", "z-30",
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]", "dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
className, className
)} )}
/> />
); );
}; };
const GridLineVertical = ({ const GridLineVertical = ({ className, offset }: { className?: string; offset?: string }) => {
className,
offset,
}: {
className?: string;
offset?: string;
}) => {
return ( return (
<div <div
style={ style={
@ -197,7 +191,7 @@ const GridLineVertical = ({
"mask-exclude", "mask-exclude",
"z-30", "z-30",
"dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]", "dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
className, className
)} )}
/> />
); );
@ -209,10 +203,7 @@ function PricingFAQ() {
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if ( if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setActiveId(null); setActiveId(null);
} }
} }
@ -232,21 +223,15 @@ function PricingFAQ() {
Frequently Asked Questions Frequently Asked Questions
</h2> </h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground"> <p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
Everything you need to know about SurfSense pages and billing. Everything you need to know about SurfSense pages and billing. Can&apos;t find what you
Can&apos;t find what you need? Reach out at{" "} need? Reach out at{" "}
<a <a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
href="mailto:rohan@surfsense.com"
className="text-blue-500 underline"
>
rohan@surfsense.com rohan@surfsense.com
</a> </a>
</p> </p>
</div> </div>
<div <div ref={containerRef} className="relative mt-16 flex w-full flex-col gap-12 px-4 md:px-8">
ref={containerRef}
className="relative mt-16 flex w-full flex-col gap-12 px-4 md:px-8"
>
{faqData.map((section) => ( {faqData.map((section) => (
<div key={section.title + "faq"}> <div key={section.title + "faq"}>
<h3 className="mb-6 text-lg font-medium text-neutral-800 dark:text-neutral-200"> <h3 className="mb-6 text-lg font-medium text-neutral-800 dark:text-neutral-200">
@ -264,30 +249,19 @@ function PricingFAQ() {
"relative rounded-lg transition-all duration-200", "relative rounded-lg transition-all duration-200",
isActive 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" ? "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 && ( {isActive && (
<div className="absolute inset-0"> <div className="absolute inset-0">
<GridLineHorizontal <GridLineHorizontal className="-top-[2px]" offset="100px" />
className="-top-[2px]" <GridLineHorizontal className="-bottom-[2px]" offset="100px" />
offset="100px" <GridLineVertical className="-left-[2px]" offset="100px" />
/> <GridLineVertical className="-right-[2px] left-auto" offset="100px" />
<GridLineHorizontal
className="-bottom-[2px]"
offset="100px"
/>
<GridLineVertical
className="-left-[2px]"
offset="100px"
/>
<GridLineVertical
className="-right-[2px] left-auto"
offset="100px"
/>
</div> </div>
)} )}
<button <button
type="button"
onClick={() => toggleQuestion(id)} onClick={() => toggleQuestion(id)}
className="flex w-full items-center justify-between px-4 py-4 text-left" className="flex w-full items-center justify-between px-4 py-4 text-left"
> >

View file

@ -80,9 +80,7 @@ export function PublicChatSnapshotRow({
size="icon" size="icon"
className={cn( className={cn(
"absolute right-0 h-6 w-6 shrink-0 hover:bg-transparent", "absolute right-0 h-6 w-6 shrink-0 hover:bg-transparent",
dropdownOpen dropdownOpen ? "opacity-100" : "sm:opacity-0 sm:group-hover:opacity-100"
? "opacity-100"
: "sm:opacity-0 sm:group-hover:opacity-100"
)} )}
> >
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" /> <MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
@ -100,10 +98,7 @@ export function PublicChatSnapshotRow({
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
{canDelete && ( {canDelete && (
<DropdownMenuItem <DropdownMenuItem onClick={() => onDelete(snapshot)} disabled={isDeleting}>
onClick={() => onDelete(snapshot)}
disabled={isDeleting}
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -27,18 +27,13 @@ export function BreadcrumbNav({ items, className }: BreadcrumbNavProps) {
const isLast = index === items.length - 1; const isLast = index === items.length - 1;
return ( return (
<li key={item.href} className="flex items-center gap-1.5"> <li key={item.href} className="flex items-center gap-1.5">
{index > 0 && ( {index > 0 && <ChevronRight className="h-3.5 w-3.5 shrink-0" aria-hidden />}
<ChevronRight className="h-3.5 w-3.5 shrink-0" aria-hidden />
)}
{isLast ? ( {isLast ? (
<span className="font-medium text-foreground" aria-current="page"> <span className="font-medium text-foreground" aria-current="page">
{item.name} {item.name}
</span> </span>
) : ( ) : (
<Link <Link href={item.href} className="transition-colors hover:text-foreground">
href={item.href}
className="transition-colors hover:text-foreground"
>
{item.name} {item.name}
</Link> </Link>
)} )}

View file

@ -4,10 +4,8 @@ interface JsonLdProps {
export function JsonLd({ data }: JsonLdProps) { export function JsonLd({ data }: JsonLdProps) {
return ( return (
<script // biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD structured data requires dangerouslySetInnerHTML for script injection
type="application/ld+json" <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
); );
} }
@ -20,12 +18,9 @@ export function OrganizationJsonLd() {
name: "SurfSense", name: "SurfSense",
url: "https://surfsense.com", url: "https://surfsense.com",
logo: "https://surfsense.com/logo.png", logo: "https://surfsense.com/logo.png",
description: description:
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.", "Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.",
sameAs: [ sameAs: ["https://github.com/MODSetter/SurfSense", "https://discord.gg/Cg2M4GUJ"],
"https://github.com/MODSetter/SurfSense",
"https://discord.gg/Cg2M4GUJ",
],
contactPoint: { contactPoint: {
"@type": "ContactPoint", "@type": "ContactPoint",
email: "rohan@surfsense.com", email: "rohan@surfsense.com",
@ -44,8 +39,8 @@ export function WebSiteJsonLd() {
"@type": "WebSite", "@type": "WebSite",
name: "SurfSense", name: "SurfSense",
url: "https://surfsense.com", url: "https://surfsense.com",
description: description:
"Open source NotebookLM alternative for teams with no data limits. Free ChatGPT, Claude AI, and any AI model.", "Open source NotebookLM alternative for teams with no data limits. Free ChatGPT, Claude AI, and any AI model.",
potentialAction: { potentialAction: {
"@type": "SearchAction", "@type": "SearchAction",
target: { target: {
@ -74,22 +69,22 @@ export function SoftwareApplicationJsonLd() {
priceCurrency: "USD", priceCurrency: "USD",
description: "Free plan with 500 pages included", description: "Free plan with 500 pages included",
}, },
description: description:
"Open source NotebookLM alternative with free access to ChatGPT, Claude AI, and any model. Connect Slack, Google Drive, Notion, Confluence, GitHub, and dozens more data sources.", "Open source NotebookLM alternative with free access to ChatGPT, Claude AI, and any model. Connect Slack, Google Drive, Notion, Confluence, GitHub, and dozens more data sources.",
url: "https://surfsense.com", url: "https://surfsense.com",
downloadUrl: "https://github.com/MODSetter/SurfSense/releases", downloadUrl: "https://github.com/MODSetter/SurfSense/releases",
featureList: [ featureList: [
"Free access to ChatGPT, Claude AI, and any AI model", "Free access to ChatGPT, Claude AI, and any AI model",
"AI-powered semantic search across all connected tools", "AI-powered semantic search across all connected tools",
"Federated search across Slack, Google Drive, Notion, Confluence, GitHub", "Federated search across Slack, Google Drive, Notion, Confluence, GitHub",
"No data limits with open source self-hosting", "No data limits with open source self-hosting",
"Real-time collaborative team chats", "Real-time collaborative team chats",
"Document Q&A with citations", "Document Q&A with citations",
"Report generation", "Report generation",
"Podcast and video generation from sources", "Podcast and video generation from sources",
"Enterprise knowledge management", "Enterprise knowledge management",
"Self-hostable and privacy-focused", "Self-hostable and privacy-focused",
], ],
}} }}
/> />
); );
@ -141,11 +136,7 @@ export function ArticleJsonLd({
); );
} }
export function BreadcrumbJsonLd({ export function BreadcrumbJsonLd({ items }: { items: { name: string; url: string }[] }) {
items,
}: {
items: { name: string; url: string }[];
}) {
return ( return (
<JsonLd <JsonLd
data={{ data={{
@ -162,11 +153,7 @@ export function BreadcrumbJsonLd({
); );
} }
export function FAQJsonLd({ export function FAQJsonLd({ questions }: { questions: { question: string; answer: string }[] }) {
questions,
}: {
questions: { question: string; answer: string }[];
}) {
return ( return (
<JsonLd <JsonLd
data={{ data={{

View file

@ -145,18 +145,14 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
<form onSubmit={onSubmit} className="space-y-6"> <form onSubmit={onSubmit} className="space-y-6">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="search-space-name"> <Label htmlFor="search-space-name">{t("general_name_label")}</Label>
{t("general_name_label")}
</Label>
<Input <Input
id="search-space-name" id="search-space-name"
placeholder={t("general_name_placeholder")} placeholder={t("general_name_placeholder")}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t("general_name_description")}</p>
{t("general_name_description")}
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -170,9 +166,7 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t("general_description_description")}</p>
{t("general_description_description")}
</p>
</div> </div>
</div> </div>
@ -189,28 +183,28 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
</div> </div>
</form> </form>
<div className="border-t pt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="border-t pt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1"> <div className="space-y-1">
<Label>Export knowledge base</Label> <Label>Export knowledge base</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Download all documents in this search space as a ZIP of markdown files. Download all documents in this search space as a ZIP of markdown files.
</p> </p>
</div>
<Button
type="button"
variant="secondary"
size="sm"
disabled={isExporting}
onClick={handleExportKB}
className="relative w-fit shrink-0"
>
<span className={isExporting ? "opacity-0" : ""}>
<FolderArchive className="h-3 w-3 opacity-60" />
</span>
<span className={isExporting ? "opacity-0" : ""}>Export</span>
{isExporting && <Spinner size="sm" className="absolute" />}
</Button>
</div> </div>
<Button
type="button"
variant="secondary"
size="sm"
disabled={isExporting}
onClick={handleExportKB}
className="relative w-fit shrink-0"
>
<span className={isExporting ? "opacity-0" : ""}>
<FolderArchive className="h-3 w-3 opacity-60" />
</span>
<span className={isExporting ? "opacity-0" : ""}>Export</span>
{isExporting && <Spinner size="sm" className="absolute" />}
</Button>
</div>
</div> </div>
); );
} }

View file

@ -6,10 +6,10 @@ import {
Bot, Bot,
CircleCheck, CircleCheck,
CircleDashed, CircleDashed,
ScanEye,
FileText, FileText,
ImageIcon, ImageIcon,
RefreshCw, RefreshCw,
ScanEye,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";

View file

@ -554,9 +554,7 @@ function RolesContent({
> >
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" /> <IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">{config.label}</span>
{config.label}
</span>
</div> </div>
<div className="flex flex-wrap justify-end gap-1"> <div className="flex flex-wrap justify-end gap-1">
{actions.map((action) => ( {actions.map((action) => (
@ -685,7 +683,10 @@ function PermissionsEditor({
onClick={() => toggleCategoryExpanded(category)} onClick={() => toggleCategoryExpanded(category)}
> >
<div <div
className={cn("transition-transform duration-200", isExpanded && "rotate-180")} className={cn(
"transition-transform duration-200",
isExpanded && "rotate-180"
)}
> >
<svg <svg
className="h-4 w-4 text-muted-foreground" className="h-4 w-4 text-muted-foreground"

View file

@ -7,9 +7,9 @@ import {
Brain, Brain,
CircleUser, CircleUser,
Earth, Earth,
ScanEye,
ImageIcon, ImageIcon,
ListChecks, ListChecks,
ScanEye,
UserKey, UserKey,
} from "lucide-react"; } from "lucide-react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";

View file

@ -201,7 +201,9 @@ export function ModelConfigDialog({
onSubmit={handleSubmit} onSubmit={handleSubmit}
mode="create" mode="create"
formId="model-config-form" formId="model-config-form"
initialData={defaultProvider ? { provider: defaultProvider as LiteLLMProvider } : undefined} initialData={
defaultProvider ? { provider: defaultProvider as LiteLLMProvider } : undefined
}
/> />
) : isGlobal && config ? ( ) : isGlobal && config ? (
<div className="space-y-6"> <div className="space-y-6">

View file

@ -1,7 +1,16 @@
"use client"; "use client";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { ChevronDown, Dot, File as FileIcon, FolderOpen, Upload, X } from "lucide-react"; import {
ChevronDown,
Crown,
Dot,
File as FileIcon,
FolderOpen,
Upload,
X,
Zap,
} from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -25,6 +34,7 @@ import {
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import type { ProcessingMode } from "@/contracts/types/document.types";
import { useElectronAPI } from "@/hooks/use-platform"; import { useElectronAPI } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { import {
@ -137,6 +147,7 @@ export function DocumentUploadTab({
const [accordionValue, setAccordionValue] = useState<string>(""); const [accordionValue, setAccordionValue] = useState<string>("");
const [shouldSummarize, setShouldSummarize] = useState(false); const [shouldSummarize, setShouldSummarize] = useState(false);
const [useVisionLlm, setUseVisionLlm] = useState(false); const [useVisionLlm, setUseVisionLlm] = useState(false);
const [processingMode, setProcessingMode] = useState<ProcessingMode>("basic");
const [uploadDocumentMutation] = useAtom(uploadDocumentMutationAtom); const [uploadDocumentMutation] = useAtom(uploadDocumentMutationAtom);
const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation; const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation;
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -363,6 +374,7 @@ export function DocumentUploadTab({
root_folder_id: rootFolderId, root_folder_id: rootFolderId,
enable_summary: shouldSummarize, enable_summary: shouldSummarize,
use_vision_llm: useVisionLlm, use_vision_llm: useVisionLlm,
processing_mode: processingMode,
} }
); );
@ -410,6 +422,7 @@ export function DocumentUploadTab({
search_space_id: Number(searchSpaceId), search_space_id: Number(searchSpaceId),
should_summarize: shouldSummarize, should_summarize: shouldSummarize,
use_vision_llm: useVisionLlm, use_vision_llm: useVisionLlm,
processing_mode: processingMode,
}, },
{ {
onSuccess: () => { onSuccess: () => {
@ -533,35 +546,29 @@ export function DocumentUploadTab({
</button> </button>
) )
) : ( ) : (
<div <button
role="button" type="button"
tabIndex={0} tabIndex={0}
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent outline-none select-none" className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent outline-none select-none"
onClick={() => { onClick={() => {
if (!isElectron) fileInputRef.current?.click();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!isElectron) fileInputRef.current?.click(); if (!isElectron) fileInputRef.current?.click();
} }}
}}
>
<Upload className="h-10 w-10 text-muted-foreground" />
<div className="text-center space-y-1.5">
<p className="text-base font-medium">
{isElectron ? t("select_files_or_folder") : t("tap_select_files_or_folder")}
</p>
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
</div>
<fieldset
className="w-full mt-1 border-none p-0 m-0"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
> >
{renderBrowseButton({ fullWidth: true })} <Upload className="h-10 w-10 text-muted-foreground" />
</fieldset> <div className="text-center space-y-1.5">
</div> <p className="text-base font-medium">
{isElectron ? t("select_files_or_folder") : t("tap_select_files_or_folder")}
</p>
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
</div>
<fieldset
className="w-full mt-1 border-none p-0 m-0"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{renderBrowseButton({ fullWidth: true })}
</fieldset>
</button>
)} )}
</div> </div>
@ -709,6 +716,46 @@ export function DocumentUploadTab({
<Switch checked={useVisionLlm} onCheckedChange={setUseVisionLlm} /> <Switch checked={useVisionLlm} onCheckedChange={setUseVisionLlm} />
</div> </div>
<div className="space-y-1.5">
<p className="font-medium text-sm px-1">{t("processing_mode")}</p>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setProcessingMode("basic")}
className={`flex items-start gap-2.5 rounded-lg border p-3 text-left transition-colors ${
processingMode === "basic"
? "border-primary bg-primary/5"
: "border-border hover:border-muted-foreground/50"
}`}
>
<Zap
className={`h-4 w-4 mt-0.5 shrink-0 ${processingMode === "basic" ? "text-primary" : "text-muted-foreground"}`}
/>
<div className="space-y-0.5 min-w-0">
<p className="font-medium text-sm">{t("basic_mode")}</p>
<p className="text-xs text-muted-foreground">{t("basic_mode_desc")}</p>
</div>
</button>
<button
type="button"
onClick={() => setProcessingMode("premium")}
className={`flex items-start gap-2.5 rounded-lg border p-3 text-left transition-colors ${
processingMode === "premium"
? "border-amber-500 bg-amber-500/5"
: "border-border hover:border-muted-foreground/50"
}`}
>
<Crown
className={`h-4 w-4 mt-0.5 shrink-0 ${processingMode === "premium" ? "text-amber-500" : "text-muted-foreground"}`}
/>
<div className="space-y-0.5 min-w-0">
<p className="font-medium text-sm">{t("premium_mode")}</p>
<p className="text-xs text-muted-foreground">{t("premium_mode_desc")}</p>
</div>
</button>
</div>
</div>
<Button <Button
className="w-full" className="w-full"
onClick={handleUpload} onClick={handleUpload}

View file

@ -15,9 +15,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
interface ConfluenceAccount { interface ConfluenceAccount {
id: number; id: number;
@ -36,7 +36,7 @@ type CreateConfluencePageInterruptContext = {
accounts?: ConfluenceAccount[]; accounts?: ConfluenceAccount[];
spaces?: ConfluenceSpace[]; spaces?: ConfluenceSpace[];
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -6,9 +6,9 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
type DeleteConfluencePageInterruptContext = { type DeleteConfluencePageInterruptContext = {
account?: { account?: {
@ -26,7 +26,7 @@ type DeleteConfluencePageInterruptContext = {
indexed_at?: string; indexed_at?: string;
}; };
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -8,9 +8,9 @@ import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
type UpdateConfluencePageInterruptContext = { type UpdateConfluencePageInterruptContext = {
account?: { account?: {
@ -29,7 +29,7 @@ type UpdateConfluencePageInterruptContext = {
indexed_at?: string; indexed_at?: string;
}; };
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -16,8 +16,8 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl"; import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface DropboxAccount { interface DropboxAccount {
id: number; id: number;
@ -36,7 +36,7 @@ type DropboxCreateFileContext = {
parent_folders?: Record<number, Array<{ folder_path: string; name: string }>>; parent_folders?: Record<number, Array<{ folder_path: string; name: string }>>;
supported_types?: SupportedType[]; supported_types?: SupportedType[];
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";
@ -57,7 +57,11 @@ interface AuthErrorResult {
connector_type?: string; connector_type?: string;
} }
type CreateDropboxFileResult = InterruptResult<DropboxCreateFileContext> | SuccessResult | ErrorResult | AuthErrorResult; type CreateDropboxFileResult =
| InterruptResult<DropboxCreateFileContext>
| SuccessResult
| ErrorResult
| AuthErrorResult;
function isErrorResult(result: unknown): result is ErrorResult { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (

View file

@ -7,8 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl"; import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface DropboxAccount { interface DropboxAccount {
id: number; id: number;
@ -28,7 +28,7 @@ type DropboxTrashFileContext = {
account?: DropboxAccount; account?: DropboxAccount;
file?: DropboxFile; file?: DropboxFile;
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -9,8 +9,8 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { connectorsApiService } from "@/lib/apis/connectors-api.service"; import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl"; import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
function ParamEditor({ function ParamEditor({
params, params,
@ -216,11 +216,7 @@ function GenericApprovalCard({
</Button> </Button>
)} )}
{isMCPTool && ( {isMCPTool && (
<Button <Button size="sm" className="rounded-lg" onClick={handleAlwaysAllow}>
size="sm"
className="rounded-lg"
onClick={handleAlwaysAllow}
>
Always Allow Always Allow
</Button> </Button>
)} )}

View file

@ -16,9 +16,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
interface GmailAccount { interface GmailAccount {
id: number; id: number;
@ -30,7 +30,7 @@ interface GmailAccount {
type GmailCreateDraftContext = { type GmailCreateDraftContext = {
accounts?: GmailAccount[]; accounts?: GmailAccount[];
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -16,9 +16,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
interface GmailAccount { interface GmailAccount {
id: number; id: number;
@ -30,7 +30,7 @@ interface GmailAccount {
type GmailSendEmailContext = { type GmailSendEmailContext = {
accounts?: GmailAccount[]; accounts?: GmailAccount[];
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -6,9 +6,9 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
interface GmailAccount { interface GmailAccount {
id: number; id: number;
@ -31,7 +31,7 @@ type GmailTrashEmailContext = {
account?: GmailAccount; account?: GmailAccount;
email?: GmailMessage; email?: GmailMessage;
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -9,9 +9,9 @@ import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
interface GmailAccount { interface GmailAccount {
id: number; id: number;
@ -36,7 +36,7 @@ type GmailUpdateDraftContext = {
draft_id?: string; draft_id?: string;
existing_body?: string; existing_body?: string;
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -16,9 +16,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
interface GoogleCalendarAccount { interface GoogleCalendarAccount {
id: number; id: number;
@ -37,7 +37,7 @@ type CalendarCreateEventContext = {
calendars?: CalendarEntry[]; calendars?: CalendarEntry[];
timezone?: string; timezone?: string;
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -6,9 +6,9 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
interface GoogleCalendarAccount { interface GoogleCalendarAccount {
id: number; id: number;
@ -33,7 +33,7 @@ type CalendarDeleteEventContext = {
account?: GoogleCalendarAccount; account?: GoogleCalendarAccount;
event?: CalendarEvent; event?: CalendarEvent;
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -16,9 +16,9 @@ import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
interface GoogleCalendarAccount { interface GoogleCalendarAccount {
id: number; id: number;
@ -43,7 +43,7 @@ type CalendarUpdateEventContext = {
account?: GoogleCalendarAccount; account?: GoogleCalendarAccount;
event?: CalendarEvent; event?: CalendarEvent;
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -16,8 +16,8 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl"; import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface GoogleDriveAccount { interface GoogleDriveAccount {
id: number; id: number;
@ -30,7 +30,7 @@ type DriveCreateFileContext = {
supported_types?: string[]; supported_types?: string[];
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>; parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -7,8 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl"; import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface GoogleDriveAccount { interface GoogleDriveAccount {
id: number; id: number;
@ -27,7 +27,7 @@ type DriveTrashFileContext = {
account?: GoogleDriveAccount; account?: GoogleDriveAccount;
file?: GoogleDriveFile; file?: GoogleDriveFile;
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -15,9 +15,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
interface JiraAccount { interface JiraAccount {
id: number; id: number;
@ -48,7 +48,7 @@ type CreateJiraIssueInterruptContext = {
issue_types?: JiraIssueType[]; issue_types?: JiraIssueType[];
priorities?: JiraPriority[]; priorities?: JiraPriority[];
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -6,9 +6,9 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
interface JiraAccount { interface JiraAccount {
id: number; id: number;
@ -29,7 +29,7 @@ type DeleteJiraIssueInterruptContext = {
account?: JiraAccount; account?: JiraAccount;
issue?: JiraIssue; issue?: JiraIssue;
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -16,9 +16,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
interface JiraIssue { interface JiraIssue {
issue_id: string; issue_id: string;
@ -50,7 +50,7 @@ type UpdateJiraIssueInterruptContext = {
issue?: JiraIssue; issue?: JiraIssue;
priorities?: JiraPriority[]; priorities?: JiraPriority[];
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -18,8 +18,8 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl"; import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface LinearLabel { interface LinearLabel {
id: string; id: string;
@ -69,7 +69,7 @@ interface LinearWorkspace {
type LinearCreateIssueContext = { type LinearCreateIssueContext = {
workspaces?: LinearWorkspace[]; workspaces?: LinearWorkspace[];
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";
@ -91,7 +91,11 @@ interface AuthErrorResult {
connector_type: string; connector_type: string;
} }
type CreateLinearIssueResult = InterruptResult<LinearCreateIssueContext> | SuccessResult | ErrorResult | AuthErrorResult; type CreateLinearIssueResult =
| InterruptResult<LinearCreateIssueContext>
| SuccessResult
| ErrorResult
| AuthErrorResult;
function isErrorResult(result: unknown): result is ErrorResult { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (

View file

@ -7,8 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl"; import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
type LinearDeleteIssueContext = { type LinearDeleteIssueContext = {
workspace?: { id: number; organization_name: string }; workspace?: { id: number; organization_name: string };
@ -21,7 +21,7 @@ type LinearDeleteIssueContext = {
indexed_at?: string; indexed_at?: string;
}; };
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -18,8 +18,8 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl"; import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface LinearLabel { interface LinearLabel {
id: string; id: string;
@ -72,7 +72,7 @@ type LinearUpdateIssueContext = {
labels: LinearLabel[]; labels: LinearLabel[];
}; };
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -16,8 +16,8 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl"; import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
type NotionCreatePageContext = { type NotionCreatePageContext = {
accounts?: Array<{ accounts?: Array<{
@ -37,7 +37,7 @@ type NotionCreatePageContext = {
}> }>
>; >;
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";
@ -61,7 +61,11 @@ interface AuthErrorResult {
connector_type: string; connector_type: string;
} }
type CreateNotionPageResult = InterruptResult<NotionCreatePageContext> | SuccessResult | ErrorResult | AuthErrorResult; type CreateNotionPageResult =
| InterruptResult<NotionCreatePageContext>
| SuccessResult
| ErrorResult
| AuthErrorResult;
function isAuthErrorResult(result: unknown): result is AuthErrorResult { function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return ( return (

View file

@ -7,8 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl"; import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
type NotionDeletePageContext = { type NotionDeletePageContext = {
account?: { account?: {
@ -23,7 +23,7 @@ type NotionDeletePageContext = {
document_id?: number; document_id?: number;
indexed_at?: string; indexed_at?: string;
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -9,8 +9,8 @@ import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl"; import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
type NotionUpdatePageContext = { type NotionUpdatePageContext = {
account?: { account?: {
@ -25,7 +25,7 @@ type NotionUpdatePageContext = {
document_id?: number; document_id?: number;
indexed_at?: string; indexed_at?: string;
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -16,8 +16,8 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl"; import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface OneDriveAccount { interface OneDriveAccount {
id: number; id: number;
@ -30,7 +30,7 @@ type OneDriveCreateFileContext = {
accounts?: OneDriveAccount[]; accounts?: OneDriveAccount[];
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>; parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";
@ -51,7 +51,11 @@ interface AuthErrorResult {
connector_type?: string; connector_type?: string;
} }
type CreateOneDriveFileResult = InterruptResult<OneDriveCreateFileContext> | SuccessResult | ErrorResult | AuthErrorResult; type CreateOneDriveFileResult =
| InterruptResult<OneDriveCreateFileContext>
| SuccessResult
| ErrorResult
| AuthErrorResult;
function isErrorResult(result: unknown): result is ErrorResult { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (

View file

@ -7,8 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl"; import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface OneDriveAccount { interface OneDriveAccount {
id: number; id: number;
@ -28,7 +28,7 @@ type OneDriveTrashFileContext = {
account?: OneDriveAccount; account?: OneDriveAccount;
file?: OneDriveFile; file?: OneDriveFile;
error?: string; error?: string;
} };
interface SuccessResult { interface SuccessResult {
status: "success"; status: "success";

View file

@ -144,11 +144,14 @@ export const createDocumentResponse = z.object({
/** /**
* Upload documents * Upload documents
*/ */
export const processingModeEnum = z.enum(["basic", "premium"]);
export const uploadDocumentRequest = z.object({ export const uploadDocumentRequest = z.object({
files: z.array(z.instanceof(File)), files: z.array(z.instanceof(File)),
search_space_id: z.number(), search_space_id: z.number(),
should_summarize: z.boolean().default(false), should_summarize: z.boolean().default(false),
use_vision_llm: z.boolean().default(false), use_vision_llm: z.boolean().default(false),
processing_mode: processingModeEnum.default("basic"),
}); });
export const uploadDocumentResponse = z.object({ export const uploadDocumentResponse = z.object({
@ -362,3 +365,4 @@ export type GetSurfsenseDocsResponse = z.infer<typeof getSurfsenseDocsResponse>;
export type GetDocumentChunksRequest = z.infer<typeof getDocumentChunksRequest>; export type GetDocumentChunksRequest = z.infer<typeof getDocumentChunksRequest>;
export type GetDocumentChunksResponse = z.infer<typeof getDocumentChunksResponse>; export type GetDocumentChunksResponse = z.infer<typeof getDocumentChunksResponse>;
export type ChunkRead = z.infer<typeof chunkRead>; export type ChunkRead = z.infer<typeof chunkRead>;
export type ProcessingMode = z.infer<typeof processingModeEnum>;

View file

@ -1,6 +1,13 @@
import type { ZodType } from "zod"; import type { ZodType } from "zod";
import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils"; import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils";
import { AppError, AuthenticationError, AuthorizationError, NotFoundError } from "../error"; import {
AbortedError,
AppError,
AuthenticationError,
AuthorizationError,
NetworkError,
NotFoundError,
} from "../error";
enum ResponseType { enum ResponseType {
JSON = "json", JSON = "json",
@ -137,12 +144,21 @@ class BaseApiService {
throw new AppError("Failed to parse response", response.status, response.statusText); throw new AppError("Failed to parse response", response.status, response.statusText);
} }
// Extract structured fields from new envelope or legacy shape
const envelope = typeof data === "object" && data?.error;
const errorMessage: string =
envelope?.message ??
(typeof data === "object" && typeof data?.detail === "string" ? data.detail : "");
const errorCode: string | undefined = envelope?.code;
const requestId: string | undefined =
envelope?.request_id ?? response.headers.get("X-Request-ID") ?? undefined;
const reportUrl: string | undefined = envelope?.report_url;
// Handle 401 - try to refresh token first (only once) // Handle 401 - try to refresh token first (only once)
if (response.status === 401) { if (response.status === 401) {
if (!options?._isRetry) { if (!options?._isRetry) {
const newToken = await refreshAccessToken(); const newToken = await refreshAccessToken();
if (newToken) { if (newToken) {
// Retry the request with the new token
return this.request(url, responseSchema, { return this.request(url, responseSchema, {
...mergedOptions, ...mergedOptions,
headers: { headers: {
@ -153,34 +169,37 @@ class BaseApiService {
} as RequestOptions & { responseType?: R }); } as RequestOptions & { responseType?: R });
} }
} }
// Refresh failed or retry failed, redirect to login
handleUnauthorized(); handleUnauthorized();
throw new AuthenticationError( throw new AuthenticationError(
typeof data === "object" && "detail" in data errorMessage || "You are not authenticated. Please login again.",
? data.detail
: "You are not authenticated. Please login again.",
response.status, response.status,
response.statusText response.statusText
); );
} }
// For fastapi errors response // Map status to typed error
if (typeof data === "object" && "detail" in data) {
throw new AppError(data.detail, response.status, response.statusText);
}
switch (response.status) { switch (response.status) {
case 403: case 403:
throw new AuthorizationError( throw new AuthorizationError(
"You don't have permission to access this resource.", errorMessage || "You don't have permission to access this resource.",
response.status, response.status,
response.statusText response.statusText
); );
case 404: case 404:
throw new NotFoundError("Resource not found", response.status, response.statusText); throw new NotFoundError(
// Add more cases as needed errorMessage || "Resource not found",
response.status,
response.statusText
);
default: default:
throw new AppError("Something went wrong", response.status, response.statusText); throw new AppError(
errorMessage || "Something went wrong",
response.status,
response.statusText,
errorCode,
requestId,
reportUrl
);
} }
} }
@ -231,6 +250,16 @@ class BaseApiService {
return data; return data;
} catch (error) { } catch (error) {
// Normalize browser-level fetch failures before anything else
if (error instanceof DOMException && error.name === "AbortError") {
throw new AbortedError();
}
if (error instanceof TypeError && !(error instanceof AppError)) {
throw new NetworkError(
"Unable to connect to the server. Check your internet connection and try again."
);
}
console.error("Request failed:", JSON.stringify(error)); console.error("Request failed:", JSON.stringify(error));
if (!(error instanceof AuthenticationError)) { if (!(error instanceof AuthenticationError)) {
import("posthog-js") import("posthog-js")
@ -241,11 +270,12 @@ class BaseApiService {
...(error instanceof AppError && { ...(error instanceof AppError && {
status_code: error.status, status_code: error.status,
status_text: error.statusText, status_text: error.statusText,
error_code: error.code,
request_id: error.requestId,
}), }),
}); });
}) })
.catch(() => { .catch(() => {
// PostHog is not available in the current environment
console.error("Failed to capture exception in PostHog"); console.error("Failed to capture exception in PostHog");
}); });
} }

View file

@ -127,7 +127,8 @@ class DocumentsApiService {
throw new ValidationError(`Invalid request: ${errorMessage}`); throw new ValidationError(`Invalid request: ${errorMessage}`);
} }
const { files, search_space_id, should_summarize, use_vision_llm } = parsedRequest.data; const { files, search_space_id, should_summarize, use_vision_llm, processing_mode } =
parsedRequest.data;
const UPLOAD_BATCH_SIZE = 5; const UPLOAD_BATCH_SIZE = 5;
const batches: File[][] = []; const batches: File[][] = [];
@ -147,6 +148,7 @@ class DocumentsApiService {
formData.append("search_space_id", String(search_space_id)); formData.append("search_space_id", String(search_space_id));
formData.append("should_summarize", String(should_summarize)); formData.append("should_summarize", String(should_summarize));
formData.append("use_vision_llm", String(use_vision_llm)); formData.append("use_vision_llm", String(use_vision_llm));
formData.append("processing_mode", processing_mode);
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 120_000); const timeoutId = setTimeout(() => controller.abort(), 120_000);
@ -444,6 +446,7 @@ class DocumentsApiService {
root_folder_id?: number | null; root_folder_id?: number | null;
enable_summary?: boolean; enable_summary?: boolean;
use_vision_llm?: boolean; use_vision_llm?: boolean;
processing_mode?: "basic" | "premium";
}, },
signal?: AbortSignal signal?: AbortSignal
): Promise<{ message: string; status: string; root_folder_id: number; file_count: number }> => { ): Promise<{ message: string; status: string; root_folder_id: number; file_count: number }> => {
@ -459,6 +462,7 @@ class DocumentsApiService {
} }
formData.append("enable_summary", String(metadata.enable_summary ?? false)); formData.append("enable_summary", String(metadata.enable_summary ?? false));
formData.append("use_vision_llm", String(metadata.use_vision_llm ?? false)); formData.append("use_vision_llm", String(metadata.use_vision_llm ?? false));
formData.append("processing_mode", metadata.processing_mode ?? "basic");
const totalSize = files.reduce((acc, f) => acc + f.size, 0); const totalSize = files.reduce((acc, f) => acc + f.size, 0);
const timeoutMs = Math.min(Math.max((totalSize / (1024 * 1024)) * 5000, 30_000), 600_000); const timeoutMs = Math.min(Math.max((totalSize / (1024 * 1024)) * 5000, 30_000), 600_000);

View file

@ -39,19 +39,20 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
content = [{ type: "text", text: String(msg.content) }]; content = [{ type: "text", text: String(msg.content) }];
} }
const metadata = (msg.author_id || msg.token_usage) const metadata =
? { msg.author_id || msg.token_usage
custom: { ? {
...(msg.author_id && { custom: {
author: { ...(msg.author_id && {
displayName: msg.author_display_name ?? null, author: {
avatarUrl: msg.author_avatar_url ?? null, displayName: msg.author_display_name ?? null,
}, avatarUrl: msg.author_avatar_url ?? null,
}), },
...(msg.token_usage && { usage: msg.token_usage }), }),
}, ...(msg.token_usage && { usage: msg.token_usage }),
} },
: undefined; }
: undefined;
return { return {
id: `msg-${msg.id}`, id: `msg-${msg.id}`,

View file

@ -149,7 +149,7 @@ export function addToolCall(
toolCallId: string, toolCallId: string,
toolName: string, toolName: string,
args: Record<string, unknown>, args: Record<string, unknown>,
force = false, force = false
): void { ): void {
if (force || toolsWithUI.has(toolName)) { if (force || toolsWithUI.has(toolName)) {
state.contentParts.push({ state.contentParts.push({
@ -241,11 +241,19 @@ export type SSEEvent =
| { | {
type: "data-token-usage"; type: "data-token-usage";
data: { data: {
usage: Record<string, { prompt_tokens: number; completion_tokens: number; total_tokens: number }>; usage: Record<
string,
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
>;
prompt_tokens: number; prompt_tokens: number;
completion_tokens: number; completion_tokens: number;
total_tokens: number; total_tokens: number;
call_details: Array<{ model: string; prompt_tokens: number; completion_tokens: number; total_tokens: number }>; call_details: Array<{
model: string;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
}>;
}; };
} }
| { type: "error"; errorText: string }; | { type: "error"; errorText: string };

View file

@ -30,7 +30,10 @@ export interface TokenUsageSummary {
prompt_tokens: number; prompt_tokens: number;
completion_tokens: number; completion_tokens: number;
total_tokens: number; total_tokens: number;
model_breakdown?: Record<string, { prompt_tokens: number; completion_tokens: number; total_tokens: number }> | null; model_breakdown?: Record<
string,
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
> | null;
} }
export interface MessageRecord { export interface MessageRecord {

View file

@ -0,0 +1,72 @@
import { toast } from "sonner";
import { AbortedError, AppError, AuthenticationError, SURFSENSE_ISSUES_URL } from "./error";
/**
* Build a GitHub issue URL pre-filled with diagnostic context.
* Avoids including PII only structural metadata.
*/
export function buildIssueUrl(error: unknown): string {
const params = new URLSearchParams();
const lines: string[] = ["## Bug Report", "", "**Describe what happened:**", "", ""];
if (error instanceof AppError) {
lines.push("## Diagnostics (auto-filled)", "");
if (error.code) lines.push(`- **Error code:** \`${error.code}\``);
if (error.requestId) lines.push(`- **Request ID:** \`${error.requestId}\``);
if (error.status) lines.push(`- **HTTP status:** ${error.status}`);
lines.push(`- **Message:** ${error.message}`);
} else if (error instanceof Error) {
lines.push("## Diagnostics (auto-filled)", "");
lines.push(`- **Error:** ${error.message}`);
}
lines.push(`- **Timestamp:** ${new Date().toISOString()}`);
lines.push(
`- **Page:** \`${typeof window !== "undefined" ? window.location.pathname : "unknown"}\``
);
lines.push(
`- **User Agent:** \`${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}\``
);
params.set("body", lines.join("\n"));
params.set("labels", "bug");
return `${SURFSENSE_ISSUES_URL}/new?${params.toString()}`;
}
/**
* Show a standardized error toast with a "Report Issue" action.
*
* Suppressed for:
* - AbortedError (user-initiated cancellation)
* - AuthenticationError (handled by redirect)
*/
export function showErrorToast(error: unknown, fallbackMessage?: string) {
if (error instanceof AbortedError || error instanceof AuthenticationError) {
return;
}
const message =
error instanceof AppError
? error.message
: error instanceof Error
? error.message
: (fallbackMessage ?? "An unexpected error occurred.");
const code = error instanceof AppError ? error.code : undefined;
const requestId = error instanceof AppError ? error.requestId : undefined;
const descParts: string[] = [];
if (code) descParts.push(`Error: ${code}`);
if (requestId) descParts.push(`ID: ${requestId}`);
toast.error(message, {
description: descParts.length > 0 ? descParts.join(" | ") : undefined,
duration: 8000,
action: {
label: "Report Issue",
onClick: () => window.open(buildIssueUrl(error), "_blank"),
},
});
}

View file

@ -1,40 +1,61 @@
export const SURFSENSE_ISSUES_URL = "https://github.com/MODSetter/SurfSense/issues";
export class AppError extends Error { export class AppError extends Error {
status?: number; status?: number;
statusText?: string; statusText?: string;
constructor(message: string, status?: number, statusText?: string) { code?: string;
requestId?: string;
reportUrl?: string;
constructor(
message: string,
status?: number,
statusText?: string,
code?: string,
requestId?: string,
reportUrl?: string
) {
super(message); super(message);
this.name = this.constructor.name; // User friendly this.name = this.constructor.name;
this.status = status; this.status = status;
this.statusText = statusText; // Dev friendly this.statusText = statusText;
this.code = code;
this.requestId = requestId;
this.reportUrl = reportUrl ?? SURFSENSE_ISSUES_URL;
} }
} }
export class NetworkError extends AppError { export class NetworkError extends AppError {
constructor(message: string, status?: number, statusText?: string) { constructor(message: string, status?: number, statusText?: string) {
super(message, status, statusText); super(message, status, statusText, "NETWORK_ERROR");
}
}
export class AbortedError extends AppError {
constructor(message = "Request was cancelled.") {
super(message, undefined, undefined, "REQUEST_ABORTED");
} }
} }
export class ValidationError extends AppError { export class ValidationError extends AppError {
constructor(message: string, status?: number, statusText?: string) { constructor(message: string, status?: number, statusText?: string) {
super(message, status, statusText); super(message, status, statusText, "VALIDATION_ERROR");
} }
} }
export class AuthenticationError extends AppError { export class AuthenticationError extends AppError {
constructor(message: string, status?: number, statusText?: string) { constructor(message: string, status?: number, statusText?: string) {
super(message, status, statusText); super(message, status, statusText, "UNAUTHORIZED");
} }
} }
export class AuthorizationError extends AppError { export class AuthorizationError extends AppError {
constructor(message: string, status?: number, statusText?: string) { constructor(message: string, status?: number, statusText?: string) {
super(message, status, statusText); super(message, status, statusText, "FORBIDDEN");
} }
} }
export class NotFoundError extends AppError { export class NotFoundError extends AppError {
constructor(message: string, status?: number, statusText?: string) { constructor(message: string, status?: number, statusText?: string) {
super(message, status, statusText); super(message, status, statusText, "NOT_FOUND");
} }
} }

View file

@ -17,6 +17,7 @@ export interface FolderSyncParams {
excludePatterns: string[]; excludePatterns: string[];
fileExtensions: string[]; fileExtensions: string[];
enableSummary: boolean; enableSummary: boolean;
processingMode?: "basic" | "premium";
rootFolderId?: number | null; rootFolderId?: number | null;
onProgress?: (progress: FolderSyncProgress) => void; onProgress?: (progress: FolderSyncProgress) => void;
signal?: AbortSignal; signal?: AbortSignal;
@ -62,6 +63,7 @@ async function uploadBatchesWithConcurrency(
searchSpaceId: number; searchSpaceId: number;
rootFolderId: number | null; rootFolderId: number | null;
enableSummary: boolean; enableSummary: boolean;
processingMode?: "basic" | "premium";
signal?: AbortSignal; signal?: AbortSignal;
onBatchComplete?: (filesInBatch: number) => void; onBatchComplete?: (filesInBatch: number) => void;
} }
@ -99,6 +101,7 @@ async function uploadBatchesWithConcurrency(
relative_paths: batch.map((e) => e.relativePath), relative_paths: batch.map((e) => e.relativePath),
root_folder_id: resolvedRootFolderId, root_folder_id: resolvedRootFolderId,
enable_summary: params.enableSummary, enable_summary: params.enableSummary,
processing_mode: params.processingMode,
}, },
params.signal params.signal
); );
@ -145,6 +148,7 @@ export async function uploadFolderScan(params: FolderSyncParams): Promise<number
excludePatterns, excludePatterns,
fileExtensions, fileExtensions,
enableSummary, enableSummary,
processingMode,
signal, signal,
} = params; } = params;
let rootFolderId = params.rootFolderId ?? null; let rootFolderId = params.rootFolderId ?? null;
@ -190,6 +194,7 @@ export async function uploadFolderScan(params: FolderSyncParams): Promise<number
searchSpaceId, searchSpaceId,
rootFolderId: rootFolderId ?? null, rootFolderId: rootFolderId ?? null,
enableSummary, enableSummary,
processingMode,
signal, signal,
onBatchComplete: (count) => { onBatchComplete: (count) => {
uploaded += count; uploaded += count;

View file

@ -1,8 +1,8 @@
export { isInterruptResult } from "./types";
export type { export type {
HitlDecision, HitlDecision,
InterruptActionRequest, InterruptActionRequest,
InterruptResult, InterruptResult,
InterruptReviewConfig, InterruptReviewConfig,
} from "./types"; } from "./types";
export { isInterruptResult } from "./types";
export { useHitlDecision } from "./use-hitl-decision"; export { useHitlDecision } from "./use-hitl-decision";

View file

@ -10,9 +10,7 @@ import type { HitlDecision } from "./types";
export function useHitlDecision() { export function useHitlDecision() {
const dispatch = useCallback((decisions: HitlDecision[]) => { const dispatch = useCallback((decisions: HitlDecision[]) => {
window.dispatchEvent( window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } }));
new CustomEvent("hitl-decision", { detail: { decisions } }),
);
}, []); }, []);
return { dispatch }; return { dispatch };

View file

@ -1,4 +1,5 @@
import { QueryClient } from "@tanstack/react-query"; import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { showErrorToast } from "../error-toast";
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -7,4 +8,16 @@ export const queryClient = new QueryClient({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}, },
}, },
queryCache: new QueryCache({
onError: (error, query) => {
if (query.meta?.suppressGlobalErrorToast) return;
showErrorToast(error);
},
}),
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
if (mutation.meta?.suppressGlobalErrorToast) return;
showErrorToast(error);
},
}),
}); });

View file

@ -399,7 +399,12 @@
"uploading_folder": "Uploading folder…", "uploading_folder": "Uploading folder…",
"upload_folder_button": "Upload Folder ({count} {count, plural, one {file} other {files}})", "upload_folder_button": "Upload Folder ({count} {count, plural, one {file} other {files}})",
"select_files_or_folder": "Select files or folder", "select_files_or_folder": "Select files or folder",
"tap_select_files_or_folder": "Tap to select files or folder" "tap_select_files_or_folder": "Tap to select files or folder",
"processing_mode": "Processing Mode",
"basic_mode": "Basic",
"basic_mode_desc": "Standard processing (1 credit per page)",
"premium_mode": "Premium",
"premium_mode_desc": "Enhanced for financial, medical & legal docs (10 credits per page)"
}, },
"add_webpage": { "add_webpage": {
"title": "Add Webpages for Crawling", "title": "Add Webpages for Crawling",

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