Merge remote-tracking branch 'upstream/dev' into feat/replace-logs

This commit is contained in:
Anish Sarkar 2026-01-15 15:52:47 +05:30
commit ab63b23f0a
24 changed files with 343 additions and 134 deletions

View file

@ -1,5 +1,5 @@
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="readme_banner" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
![new_header](https://github.com/user-attachments/assets/e236b764-0ddc-42ff-a1f1-8fbb3d2e0e65)
<div align="center">

View file

@ -24,9 +24,7 @@ def enum_exists(enum_name: str) -> bool:
"""Check if an enum type exists in the database."""
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = :enum_name)"
),
sa.text("SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = :enum_name)"),
{"enum_name": enum_name},
)
return result.scalar()

View file

@ -22,9 +22,7 @@ def enum_exists(enum_name: str) -> bool:
"""Check if an enum type exists in the database."""
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = :enum_name)"
),
sa.text("SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = :enum_name)"),
{"enum_name": enum_name},
)
return result.scalar()

View file

@ -197,9 +197,7 @@ def enum_exists(enum_name: str) -> bool:
"""Check if an enum type exists in the database."""
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = :enum_name)"
),
sa.text("SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = :enum_name)"),
{"enum_name": enum_name},
)
return result.scalar()

View file

@ -5,13 +5,14 @@ Revises: 61
Create Date: 2026-01-09 15:19:51.827647
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '62'
down_revision: str | None = '61'
revision: str = "62"
down_revision: str | None = "61"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

View file

@ -5,6 +5,7 @@ Revises: 62
Create Date: 2026-01-13 12:23:31.481643
"""
from collections.abc import Sequence
from sqlalchemy import text
@ -12,8 +13,8 @@ from sqlalchemy import text
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '63'
down_revision: str | None = '62'
revision: str = "63"
down_revision: str | None = "62"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
@ -21,7 +22,7 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
connection = op.get_bind()
# Check if old constraint exists before trying to drop it
old_constraint_exists = connection.execute(
text("""
@ -31,14 +32,14 @@ def upgrade() -> None:
AND constraint_name='uq_searchspace_user_connector_type'
""")
).scalar()
if old_constraint_exists:
op.drop_constraint(
'uq_searchspace_user_connector_type',
'search_source_connectors',
type_='unique'
"uq_searchspace_user_connector_type",
"search_source_connectors",
type_="unique",
)
# Check if new constraint already exists before creating it
new_constraint_exists = connection.execute(
text("""
@ -48,19 +49,19 @@ def upgrade() -> None:
AND constraint_name='uq_searchspace_user_connector_type_name'
""")
).scalar()
if not new_constraint_exists:
op.create_unique_constraint(
'uq_searchspace_user_connector_type_name',
'search_source_connectors',
['search_space_id', 'user_id', 'connector_type', 'name']
"uq_searchspace_user_connector_type_name",
"search_source_connectors",
["search_space_id", "user_id", "connector_type", "name"],
)
def downgrade() -> None:
"""Downgrade schema."""
connection = op.get_bind()
# Check if new constraint exists before trying to drop it
new_constraint_exists = connection.execute(
text("""
@ -70,14 +71,14 @@ def downgrade() -> None:
AND constraint_name='uq_searchspace_user_connector_type_name'
""")
).scalar()
if new_constraint_exists:
op.drop_constraint(
'uq_searchspace_user_connector_type_name',
'search_source_connectors',
type_='unique'
"uq_searchspace_user_connector_type_name",
"search_source_connectors",
type_="unique",
)
# Check if old constraint already exists before creating it
old_constraint_exists = connection.execute(
text("""
@ -87,10 +88,10 @@ def downgrade() -> None:
AND constraint_name='uq_searchspace_user_connector_type'
""")
).scalar()
if not old_constraint_exists:
op.create_unique_constraint(
'uq_searchspace_user_connector_type',
'search_source_connectors',
['search_space_id', 'user_id', 'connector_type']
"uq_searchspace_user_connector_type",
"search_source_connectors",
["search_space_id", "user_id", "connector_type"],
)

View file

@ -44,4 +44,3 @@ def downgrade() -> None:
DROP COLUMN IF EXISTS author_id;
"""
)

View file

@ -18,7 +18,9 @@ logger = logging.getLogger(__name__)
class MCPClient:
"""Client for communicating with an MCP server."""
def __init__(self, command: str, args: list[str], env: dict[str, str] | None = None):
def __init__(
self, command: str, args: list[str], env: dict[str, str] | None = None
):
"""Initialize MCP client.
Args:
@ -44,18 +46,16 @@ class MCPClient:
# Merge env vars with current environment
server_env = os.environ.copy()
server_env.update(self.env)
# Create server parameters with env
server_params = StdioServerParameters(
command=self.command,
args=self.args,
env=server_env
command=self.command, args=self.args, env=server_env
)
# Spawn server process and create session
# Note: Cannot combine these context managers because ClientSession
# needs the read/write streams from stdio_client
async with stdio_client(server=server_params) as (read, write):
async with stdio_client(server=server_params) as (read, write): # noqa: SIM117
async with ClientSession(read, write) as session:
# Initialize the connection
await session.initialize()
@ -85,7 +85,9 @@ class MCPClient:
"""
if not self.session:
raise RuntimeError("Not connected to MCP server. Use 'async with client.connect():'")
raise RuntimeError(
"Not connected to MCP server. Use 'async with client.connect():'"
)
try:
# Call tools/list RPC method
@ -93,11 +95,15 @@ class MCPClient:
tools = []
for tool in response.tools:
tools.append({
"name": tool.name,
"description": tool.description or "",
"input_schema": tool.inputSchema if hasattr(tool, "inputSchema") else {},
})
tools.append(
{
"name": tool.name,
"description": tool.description or "",
"input_schema": tool.inputSchema
if hasattr(tool, "inputSchema")
else {},
}
)
logger.info("Listed %d tools from MCP server", len(tools))
return tools
@ -121,10 +127,14 @@ class MCPClient:
"""
if not self.session:
raise RuntimeError("Not connected to MCP server. Use 'async with client.connect():'")
raise RuntimeError(
"Not connected to MCP server. Use 'async with client.connect():'"
)
try:
logger.info("Calling MCP tool '%s' with arguments: %s", tool_name, arguments)
logger.info(
"Calling MCP tool '%s' with arguments: %s", tool_name, arguments
)
# Call tools/call RPC method
response = await self.session.call_tool(tool_name, arguments=arguments)
@ -147,12 +157,17 @@ class MCPClient:
# Handle validation errors from MCP server responses
# Some MCP servers (like server-memory) return extra fields not in their schema
if "Invalid structured content" in str(e):
logger.warning("MCP server returned data not matching its schema, but continuing: %s", e)
logger.warning(
"MCP server returned data not matching its schema, but continuing: %s",
e,
)
# Try to extract result from error message or return a success message
return "Operation completed (server returned unexpected format)"
raise
except (ValueError, TypeError, AttributeError, KeyError) as e:
logger.error("Failed to call MCP tool '%s': %s", tool_name, e, exc_info=True)
logger.error(
"Failed to call MCP tool '%s': %s", tool_name, e, exc_info=True
)
return f"Error calling tool: {e!s}"

View file

@ -21,7 +21,8 @@ logger = logging.getLogger(__name__)
def _create_dynamic_input_model_from_schema(
tool_name: str, input_schema: dict[str, Any],
tool_name: str,
input_schema: dict[str, Any],
) -> type[BaseModel]:
"""Create a Pydantic model from MCP tool's JSON schema.
@ -41,15 +42,18 @@ def _create_dynamic_input_model_from_schema(
for param_name, param_schema in properties.items():
param_description = param_schema.get("description", "")
is_required = param_name in required_fields
# Use Any type for complex schemas to preserve structure
# This allows the MCP server to do its own validation
from typing import Any as AnyType
from pydantic import Field
if is_required:
field_definitions[param_name] = (AnyType, Field(..., description=param_description))
field_definitions[param_name] = (
AnyType,
Field(..., description=param_description),
)
else:
field_definitions[param_name] = (
AnyType | None,
@ -88,7 +92,7 @@ async def _create_mcp_tool_from_definition(
async def mcp_tool_call(**kwargs) -> str:
"""Execute the MCP tool call via the client."""
logger.info(f"MCP tool '{tool_name}' called with params: {kwargs}")
try:
# Connect to server and call tool
async with mcp_client.connect():
@ -114,7 +118,8 @@ async def _create_mcp_tool_from_definition(
async def load_mcp_tools(
session: AsyncSession, search_space_id: int,
session: AsyncSession,
search_space_id: int,
) -> list[StructuredTool]:
"""Load all MCP tools from user's active MCP server connectors.
@ -150,7 +155,9 @@ async def load_mcp_tools(
env = server_config.get("env", {})
if not command:
logger.warning(f"MCP connector {connector.id} missing command, skipping")
logger.warning(
f"MCP connector {connector.id} missing command, skipping"
)
continue
# Create MCP client
@ -168,7 +175,9 @@ async def load_mcp_tools(
# Create LangChain tools from definitions
for tool_def in tool_definitions:
try:
tool = await _create_mcp_tool_from_definition(tool_def, mcp_client)
tool = await _create_mcp_tool_from_definition(
tool_def, mcp_client
)
tools.append(tool)
except Exception as e:
logger.exception(

View file

@ -283,7 +283,8 @@ async def build_tools_async(
):
try:
mcp_tools = await load_mcp_tools(
dependencies["db_session"], dependencies["search_space_id"],
dependencies["db_session"],
dependencies["search_space_id"],
)
tools.extend(mcp_tools)
logging.info(

View file

@ -23,7 +23,9 @@ class SearchSourceConnectorBase(BaseModel):
@field_validator("config")
@classmethod
def validate_config_for_connector_type(
cls, config: dict[str, Any], values: dict[str, Any],
cls,
config: dict[str, Any],
values: dict[str, Any],
) -> dict[str, Any]:
connector_type = values.data.get("connector_type")
return validate_connector_config(connector_type, config)

View file

@ -2,11 +2,14 @@
File document processors for different ETL services (Unstructured, LlamaCloud, Docling).
"""
import asyncio
import contextlib
import logging
import ssl
import warnings
from logging import ERROR, getLogger
import httpx
from fastapi import HTTPException
from langchain_core.documents import Document as LangChainDocument
from litellm import atranscription
@ -32,6 +35,122 @@ from .base import (
)
from .markdown_processor import add_received_markdown_file_document
# Constants for LlamaCloud retry configuration
LLAMACLOUD_MAX_RETRIES = 3
LLAMACLOUD_BASE_DELAY = 5 # Base delay in seconds for exponential backoff
LLAMACLOUD_RETRYABLE_EXCEPTIONS = (
ssl.SSLError,
httpx.ConnectError,
httpx.ConnectTimeout,
httpx.ReadTimeout,
httpx.WriteTimeout,
ConnectionError,
TimeoutError,
)
async def parse_with_llamacloud_retry(
file_path: str,
estimated_pages: int,
task_logger: TaskLoggingService | None = None,
log_entry: Log | None = None,
):
"""
Parse a file with LlamaCloud with retry logic for transient SSL/connection errors.
Args:
file_path: Path to the file to parse
estimated_pages: Estimated number of pages for timeout calculation
task_logger: Optional task logger for progress updates
log_entry: Optional log entry for progress updates
Returns:
LlamaParse result object
Raises:
Exception: If all retries fail
"""
from llama_cloud_services import LlamaParse
from llama_cloud_services.parse.utils import ResultType
# Calculate timeouts based on estimated pages
# Base timeout of 300 seconds + 30 seconds per page for large documents
base_timeout = 300
per_page_timeout = 30
job_timeout = base_timeout + (estimated_pages * per_page_timeout)
# Create custom httpx client with larger timeouts for file uploads
# The SSL error often occurs during large file uploads, so we need generous timeouts
custom_timeout = httpx.Timeout(
connect=60.0, # 60 seconds to establish connection
read=300.0, # 5 minutes to read response
write=300.0, # 5 minutes to write/upload (important for large files)
pool=60.0, # 60 seconds to acquire connection from pool
)
last_exception = None
for attempt in range(1, LLAMACLOUD_MAX_RETRIES + 1):
try:
# Create a fresh httpx client for each attempt
async with httpx.AsyncClient(timeout=custom_timeout) as custom_client:
# Create LlamaParse parser instance with optimized settings
parser = LlamaParse(
api_key=app_config.LLAMA_CLOUD_API_KEY,
num_workers=1, # Use single worker for file processing
verbose=True,
language="en",
result_type=ResultType.MD,
# Timeout settings for large files
max_timeout=max(2000, job_timeout), # Overall max timeout
job_timeout_in_seconds=job_timeout,
job_timeout_extra_time_per_page_in_seconds=per_page_timeout,
# Use our custom client with larger timeouts
custom_client=custom_client,
)
# Parse the file asynchronously
result = await parser.aparse(file_path)
return result
except LLAMACLOUD_RETRYABLE_EXCEPTIONS as e:
last_exception = e
error_type = type(e).__name__
if attempt < LLAMACLOUD_MAX_RETRIES:
# Calculate exponential backoff delay
delay = LLAMACLOUD_BASE_DELAY * (2 ** (attempt - 1))
if task_logger and log_entry:
await task_logger.log_task_progress(
log_entry,
f"LlamaCloud upload failed (attempt {attempt}/{LLAMACLOUD_MAX_RETRIES}), retrying in {delay}s",
{
"error_type": error_type,
"error_message": str(e)[:200],
"attempt": attempt,
"retry_delay": delay,
},
)
else:
logging.warning(
f"LlamaCloud upload failed (attempt {attempt}/{LLAMACLOUD_MAX_RETRIES}): {error_type}. "
f"Retrying in {delay}s..."
)
await asyncio.sleep(delay)
else:
logging.error(
f"LlamaCloud upload failed after {LLAMACLOUD_MAX_RETRIES} attempts: {error_type} - {e}"
)
except Exception:
# Non-retryable exception, raise immediately
raise
# All retries exhausted
raise last_exception or RuntimeError("LlamaCloud parsing failed after all retries")
async def add_received_file_document_using_unstructured(
session: AsyncSession,
@ -890,24 +1009,18 @@ async def process_file_in_background(
"file_type": "document",
"etl_service": "LLAMACLOUD",
"processing_stage": "parsing",
"estimated_pages": estimated_pages_before,
},
)
from llama_cloud_services import LlamaParse
from llama_cloud_services.parse.utils import ResultType
# Create LlamaParse parser instance
parser = LlamaParse(
api_key=app_config.LLAMA_CLOUD_API_KEY,
num_workers=1, # Use single worker for file processing
verbose=True,
language="en",
result_type=ResultType.MD,
# Parse file with retry logic for SSL/connection errors (common with large files)
result = await parse_with_llamacloud_retry(
file_path=file_path,
estimated_pages=estimated_pages_before,
task_logger=task_logger,
log_entry=log_entry,
)
# Parse the file asynchronously
result = await parser.aparse(file_path)
# Clean up the temp file
import os

View file

@ -120,4 +120,3 @@ export function ApiKeyContent({ onMenuClick }: ApiKeyContentProps) {
</motion.div>
);
}

View file

@ -6,8 +6,8 @@ import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

View file

@ -1,7 +1,7 @@
"use client";
import { ArrowLeft, ChevronRight, X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { ArrowLeft, ChevronRight, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
@ -152,4 +152,3 @@ export function UserSettingsSidebar({
</>
);
}

View file

@ -7,7 +7,7 @@ import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { ApiKeyContent } from "./components/ApiKeyContent";
import { ProfileContent } from "./components/ProfileContent";
import { UserSettingsSidebar, type SettingsNavItem } from "./components/UserSettingsSidebar";
import { type SettingsNavItem, UserSettingsSidebar } from "./components/UserSettingsSidebar";
export default function UserSettingsPage() {
const t = useTranslations("userSettings");

View file

@ -16,4 +16,3 @@ export const updateUserMutationAtom = atomWithMutation((get) => {
},
};
});

View file

@ -93,35 +93,37 @@ const DocumentUploadPopupContent: FC<{
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl w-[95vw] sm:w-full max-h-[calc(100vh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-4 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5">
<DialogContent className="max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5">
<DialogTitle className="sr-only">Upload Document</DialogTitle>
{/* Fixed Header */}
<div className="flex-shrink-0 px-4 sm:px-12 pt-6 sm:pt-10 transition-shadow duration-200 relative z-10">
{/* Upload header */}
<div className="flex items-center gap-2 sm:gap-4 mb-2 sm:mb-6">
<div className="flex h-10 w-10 sm:h-14 sm:w-14 items-center justify-center rounded-lg sm:rounded-xl bg-primary/10 border border-primary/20 flex-shrink-0">
<Upload className="size-5 sm:size-7 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-lg sm:text-2xl font-semibold tracking-tight">Upload Documents</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-0.5 sm:mt-1">
Upload and sync your documents to your search space
</p>
{/* Scrollable container for mobile */}
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain">
{/* Header - scrolls with content on mobile */}
<div className="sticky top-0 z-20 bg-muted px-4 sm:px-12 pt-4 sm:pt-10 pb-2 sm:pb-0">
{/* Upload header */}
<div className="flex items-center gap-2 sm:gap-4 mb-2 sm:mb-6">
<div className="flex h-9 w-9 sm:h-14 sm:w-14 items-center justify-center rounded-lg sm:rounded-xl bg-primary/10 border border-primary/20 flex-shrink-0">
<Upload className="size-4 sm:size-7 text-primary" />
</div>
<div className="flex-1 min-w-0 pr-8 sm:pr-0">
<h2 className="text-base sm:text-2xl font-semibold tracking-tight">
Upload Documents
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-0.5 sm:mt-1 line-clamp-1 sm:line-clamp-none">
Upload and sync your documents to your search space
</p>
</div>
</div>
</div>
{/* Content */}
<div className="px-4 sm:px-12 pb-4 sm:pb-16">
<DocumentUploadTab searchSpaceId={searchSpaceId} onSuccess={handleSuccess} />
</div>
</div>
{/* Scrollable Content */}
<div className="flex-1 min-h-0 relative overflow-hidden">
<div className="h-full overflow-y-auto">
<div className="px-6 sm:px-12 pb-5 sm:pb-16">
<DocumentUploadTab searchSpaceId={searchSpaceId} onSuccess={handleSuccess} />
</div>
</div>
{/* Bottom fade shadow */}
<div className="absolute bottom-0 left-0 right-0 h-2 sm:h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
</div>
{/* Bottom fade shadow - hidden on very small screens */}
<div className="hidden sm:block absolute bottom-0 left-0 right-0 h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
</DialogContent>
</Dialog>
);

View file

@ -36,11 +36,7 @@ import {
newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import {
ComposerAddAttachment,
ComposerAttachments,
} from "@/components/assistant-ui/attachment";
import { UserMessage } from "@/components/assistant-ui/user-message";
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import {
InlineMentionEditor,
@ -53,6 +49,7 @@ import {
} from "@/components/assistant-ui/thinking-steps";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { UserMessage } from "@/components/assistant-ui/user-message";
import {
DocumentMentionPicker,
type DocumentMentionPickerRef,
@ -636,7 +633,6 @@ const AssistantActionBar: FC = () => {
);
};
const EditComposer: FC = () => {
return (
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2 py-3">

View file

@ -109,6 +109,11 @@ const FILE_TYPE_CONFIG: Record<string, Record<string, string[]>> = {
const cardClass = "border border-border bg-slate-400/5 dark:bg-white/5";
// Upload limits
const MAX_FILES = 10;
const MAX_TOTAL_SIZE_MB = 200;
const MAX_TOTAL_SIZE_BYTES = MAX_TOTAL_SIZE_MB * 1024 * 1024;
export function DocumentUploadTab({
searchSpaceId,
onSuccess,
@ -132,15 +137,40 @@ export function DocumentUploadTab({
[acceptedFileTypes]
);
const onDrop = useCallback((acceptedFiles: File[]) => {
setFiles((prev) => [...prev, ...acceptedFiles]);
}, []);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
setFiles((prev) => {
const newFiles = [...prev, ...acceptedFiles];
// Check file count limit
if (newFiles.length > MAX_FILES) {
toast.error(t("max_files_exceeded"), {
description: t("max_files_exceeded_desc", { max: MAX_FILES }),
});
return prev;
}
// Check total size limit
const newTotalSize = newFiles.reduce((sum, file) => sum + file.size, 0);
if (newTotalSize > MAX_TOTAL_SIZE_BYTES) {
toast.error(t("max_size_exceeded"), {
description: t("max_size_exceeded_desc", { max: MAX_TOTAL_SIZE_MB }),
});
return prev;
}
return newFiles;
});
},
[t]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: acceptedFileTypes,
maxSize: 50 * 1024 * 1024,
maxSize: 50 * 1024 * 1024, // 50MB per file
noClick: false,
disabled: files.length >= MAX_FILES,
});
// Handle file input click to prevent event bubbling that might reopen dialog
@ -158,6 +188,15 @@ export function DocumentUploadTab({
const totalFileSize = files.reduce((total, file) => total + file.size, 0);
// Check if limits are reached
const isFileCountLimitReached = files.length >= MAX_FILES;
const isSizeLimitReached = totalFileSize >= MAX_TOTAL_SIZE_BYTES;
const remainingFiles = MAX_FILES - files.length;
const remainingSizeMB = Math.max(
0,
(MAX_TOTAL_SIZE_BYTES - totalFileSize) / (1024 * 1024)
).toFixed(1);
// Track accordion state changes
const handleAccordionChange = useCallback(
(value: string) => {
@ -208,7 +247,8 @@ export function DocumentUploadTab({
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
{t("file_size_limit")}
{t("file_size_limit")}{" "}
{t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })}
</AlertDescription>
</Alert>
@ -219,7 +259,11 @@ export function DocumentUploadTab({
<CardContent className="p-4 sm:p-10 relative z-10">
<div
{...getRootProps()}
className="flex flex-col items-center justify-center min-h-[200px] sm:min-h-[300px] border-2 border-dashed border-border rounded-lg hover:border-primary/50 transition-colors cursor-pointer"
className={`flex flex-col items-center justify-center min-h-[200px] sm:min-h-[300px] border-2 border-dashed rounded-lg transition-colors ${
isFileCountLimitReached || isSizeLimitReached
? "border-destructive/50 bg-destructive/5 cursor-not-allowed"
: "border-border hover:border-primary/50 cursor-pointer"
}`}
>
<input
{...getInputProps()}
@ -227,7 +271,19 @@ export function DocumentUploadTab({
className="hidden"
onClick={handleFileInputClick}
/>
{isDragActive ? (
{isFileCountLimitReached ? (
<div className="flex flex-col items-center gap-2 sm:gap-4 text-center px-4">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-destructive/70" />
<div>
<p className="text-sm sm:text-lg font-medium text-destructive">
{t("file_limit_reached")}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
{t("file_limit_reached_desc", { max: MAX_FILES })}
</p>
</div>
</div>
) : isDragActive ? (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
@ -243,22 +299,29 @@ export function DocumentUploadTab({
<p className="text-sm sm:text-lg font-medium">{t("drag_drop")}</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">{t("or_browse")}</p>
</div>
{files.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("remaining_capacity", { files: remainingFiles, sizeMB: remainingSizeMB })}
</p>
)}
</div>
)}
{!isFileCountLimitReached && (
<div className="mt-2 sm:mt-4">
<Button
variant="outline"
size="sm"
className="text-xs sm:text-sm"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
fileInputRef.current?.click();
}}
>
{t("browse_files")}
</Button>
</div>
)}
<div className="mt-2 sm:mt-4">
<Button
variant="outline"
size="sm"
className="text-xs sm:text-sm"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
fileInputRef.current?.click();
}}
>
{t("browse_files")}
</Button>
</div>
</div>
</CardContent>
</Card>

View file

@ -1,7 +1,7 @@
import {
getMeResponse,
updateUserResponse,
type UpdateUserRequest,
updateUserResponse,
} from "@/contracts/types/user.types";
import { baseApiService } from "./base-api.service";

View file

@ -378,6 +378,7 @@
"title": "Upload Documents",
"subtitle": "Upload your files to make them searchable and accessible through AI-powered conversations.",
"file_size_limit": "Maximum file size: 50MB per file. Supported formats vary based on your ETL service configuration.",
"upload_limits": "Upload limit: {maxFiles} files, {maxSizeMB}MB total.",
"drop_files": "Drop files here",
"drag_drop": "Drag & drop files here",
"or_browse": "or click to browse",
@ -393,7 +394,14 @@
"upload_error": "Upload Error",
"upload_error_desc": "Error uploading files",
"supported_file_types": "Supported File Types",
"file_types_desc": "These file types are supported based on your current ETL service configuration."
"file_types_desc": "These file types are supported based on your current ETL service configuration.",
"max_files_exceeded": "File Limit Exceeded",
"max_files_exceeded_desc": "You can upload a maximum of {max} files at a time.",
"max_size_exceeded": "Size Limit Exceeded",
"max_size_exceeded_desc": "Total file size cannot exceed {max}MB.",
"file_limit_reached": "Maximum Files Reached",
"file_limit_reached_desc": "Remove some files to add more (max {max} files).",
"remaining_capacity": "{files} files remaining • {sizeMB}MB available"
},
"add_webpage": {
"title": "Add Webpages for Crawling",

View file

@ -363,6 +363,7 @@
"title": "上传文档",
"subtitle": "上传您的文件,使其可通过 AI 对话进行搜索和访问。",
"file_size_limit": "最大文件大小:每个文件 50MB。支持的格式因您的 ETL 服务配置而异。",
"upload_limits": "上传限制:最多 {maxFiles} 个文件,总大小不超过 {maxSizeMB}MB。",
"drop_files": "放下文件到这里",
"drag_drop": "拖放文件到这里",
"or_browse": "或点击浏览",
@ -378,7 +379,14 @@
"upload_error": "上传错误",
"upload_error_desc": "上传文件时出错",
"supported_file_types": "支持的文件类型",
"file_types_desc": "根据您当前的 ETL 服务配置支持这些文件类型。"
"file_types_desc": "根据您当前的 ETL 服务配置支持这些文件类型。",
"max_files_exceeded": "超过文件数量限制",
"max_files_exceeded_desc": "一次最多只能上传 {max} 个文件。",
"max_size_exceeded": "超过文件大小限制",
"max_size_exceeded_desc": "文件总大小不能超过 {max}MB。",
"file_limit_reached": "已达到最大文件数量",
"file_limit_reached_desc": "移除一些文件以添加更多(最多 {max} 个文件)。",
"remaining_capacity": "剩余 {files} 个文件名额 • 可用 {sizeMB}MB"
},
"add_webpage": {
"title": "添加网页爬取",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Before After
Before After