This commit is contained in:
Manoj Aggarwal 2026-01-16 11:21:01 -08:00
commit aa90da602b
26 changed files with 406 additions and 234 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"> <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.""" """Check if an enum type exists in the database."""
conn = op.get_bind() conn = op.get_bind()
result = conn.execute( result = conn.execute(
sa.text( sa.text("SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = :enum_name)"),
"SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = :enum_name)"
),
{"enum_name": enum_name}, {"enum_name": enum_name},
) )
return result.scalar() 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.""" """Check if an enum type exists in the database."""
conn = op.get_bind() conn = op.get_bind()
result = conn.execute( result = conn.execute(
sa.text( sa.text("SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = :enum_name)"),
"SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = :enum_name)"
),
{"enum_name": enum_name}, {"enum_name": enum_name},
) )
return result.scalar() 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.""" """Check if an enum type exists in the database."""
conn = op.get_bind() conn = op.get_bind()
result = conn.execute( result = conn.execute(
sa.text( sa.text("SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = :enum_name)"),
"SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = :enum_name)"
),
{"enum_name": enum_name}, {"enum_name": enum_name},
) )
return result.scalar() return result.scalar()

View file

@ -1,96 +0,0 @@
"""allow_multiple_connectors_with_unique_names
Revision ID: 5263aa4e7f94
Revises: a1b2c3d4e5f6
Create Date: 2026-01-13 12:23:31.481643
"""
from collections.abc import Sequence
from sqlalchemy import text
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '5263aa4e7f94'
down_revision: str | None = 'a1b2c3d4e5f6'
branch_labels: str | Sequence[str] | None = None
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("""
SELECT 1 FROM information_schema.table_constraints
WHERE table_name='search_source_connectors'
AND constraint_type='UNIQUE'
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'
)
# Check if new constraint already exists before creating
new_constraint_exists = connection.execute(
text("""
SELECT 1 FROM information_schema.table_constraints
WHERE table_name='search_source_connectors'
AND constraint_type='UNIQUE'
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']
)
def downgrade() -> None:
"""Downgrade schema."""
connection = op.get_bind()
# Check if new constraint exists before dropping
new_constraint_exists = connection.execute(
text("""
SELECT 1 FROM information_schema.table_constraints
WHERE table_name='search_source_connectors'
AND constraint_type='UNIQUE'
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'
)
# Only restore old constraint if it doesn't exist
old_constraint_exists = connection.execute(
text("""
SELECT 1 FROM information_schema.table_constraints
WHERE table_name='search_source_connectors'
AND constraint_type='UNIQUE'
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']
)

View file

@ -5,6 +5,7 @@ Revises: 63
Create Date: 2026-01-09 15:19:51.827647 Create Date: 2026-01-09 15:19:51.827647
""" """
from collections.abc import Sequence from collections.abc import Sequence
from alembic import op from alembic import op

View file

@ -4,8 +4,8 @@ This migration adds:
- display_name column for user's full name from OAuth - display_name column for user's full name from OAuth
- avatar_url column for user's profile picture URL from OAuth - avatar_url column for user's profile picture URL from OAuth
Revision ID: 62 Revision ID: 64
Revises: 61 Revises: 63
""" """
from collections.abc import Sequence from collections.abc import Sequence
@ -13,8 +13,8 @@ from collections.abc import Sequence
from alembic import op from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "62" revision: str = "64"
down_revision: str | None = "61" down_revision: str | None = "63"
branch_labels: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None

View file

@ -1,15 +1,15 @@
"""Add author_id column to new_chat_messages table """Add author_id column to new_chat_messages table
Revision ID: 63 Revision ID: 65
Revises: 62 Revises: 64
""" """
from collections.abc import Sequence from collections.abc import Sequence
from alembic import op from alembic import op
revision: str = "63" revision: str = "65"
down_revision: str | None = "62" down_revision: str | None = "64"
branch_labels: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None
@ -37,6 +37,10 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
"""Remove author_id column from new_chat_messages table.""" """Remove author_id column from new_chat_messages table."""
op.execute("DROP INDEX IF EXISTS ix_new_chat_messages_author_id") op.execute(
op.execute("ALTER TABLE new_chat_messages DROP COLUMN IF EXISTS author_id") """
DROP INDEX IF EXISTS ix_new_chat_messages_author_id;
ALTER TABLE new_chat_messages
DROP COLUMN IF EXISTS author_id;
"""
)

View file

@ -18,7 +18,9 @@ logger = logging.getLogger(__name__)
class MCPClient: class MCPClient:
"""Client for communicating with an MCP server.""" """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. """Initialize MCP client.
Args: Args:
@ -47,15 +49,13 @@ class MCPClient:
# Create server parameters with env # Create server parameters with env
server_params = StdioServerParameters( server_params = StdioServerParameters(
command=self.command, command=self.command, args=self.args, env=server_env
args=self.args,
env=server_env
) )
# Spawn server process and create session # Spawn server process and create session
# Note: Cannot combine these context managers because ClientSession # Note: Cannot combine these context managers because ClientSession
# needs the read/write streams from stdio_client # 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: async with ClientSession(read, write) as session:
# Initialize the connection # Initialize the connection
await session.initialize() await session.initialize()
@ -85,7 +85,9 @@ class MCPClient:
""" """
if not self.session: 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: try:
# Call tools/list RPC method # Call tools/list RPC method
@ -93,11 +95,15 @@ class MCPClient:
tools = [] tools = []
for tool in response.tools: for tool in response.tools:
tools.append({ tools.append(
{
"name": tool.name, "name": tool.name,
"description": tool.description or "", "description": tool.description or "",
"input_schema": tool.inputSchema if hasattr(tool, "inputSchema") else {}, "input_schema": tool.inputSchema
}) if hasattr(tool, "inputSchema")
else {},
}
)
logger.info("Listed %d tools from MCP server", len(tools)) logger.info("Listed %d tools from MCP server", len(tools))
return tools return tools
@ -121,10 +127,14 @@ class MCPClient:
""" """
if not self.session: 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: 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 # Call tools/call RPC method
response = await self.session.call_tool(tool_name, arguments=arguments) response = await self.session.call_tool(tool_name, arguments=arguments)
@ -147,12 +157,17 @@ class MCPClient:
# Handle validation errors from MCP server responses # Handle validation errors from MCP server responses
# Some MCP servers (like server-memory) return extra fields not in their schema # Some MCP servers (like server-memory) return extra fields not in their schema
if "Invalid structured content" in str(e): 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 # Try to extract result from error message or return a success message
return "Operation completed (server returned unexpected format)" return "Operation completed (server returned unexpected format)"
raise raise
except (ValueError, TypeError, AttributeError, KeyError) as e: 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}" return f"Error calling tool: {e!s}"

View file

@ -21,7 +21,8 @@ logger = logging.getLogger(__name__)
def _create_dynamic_input_model_from_schema( 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]: ) -> type[BaseModel]:
"""Create a Pydantic model from MCP tool's JSON schema. """Create a Pydantic model from MCP tool's JSON schema.
@ -49,7 +50,10 @@ def _create_dynamic_input_model_from_schema(
from pydantic import Field from pydantic import Field
if is_required: if is_required:
field_definitions[param_name] = (AnyType, Field(..., description=param_description)) field_definitions[param_name] = (
AnyType,
Field(..., description=param_description),
)
else: else:
field_definitions[param_name] = ( field_definitions[param_name] = (
AnyType | None, AnyType | None,
@ -114,7 +118,8 @@ async def _create_mcp_tool_from_definition(
async def load_mcp_tools( async def load_mcp_tools(
session: AsyncSession, search_space_id: int, session: AsyncSession,
search_space_id: int,
) -> list[StructuredTool]: ) -> list[StructuredTool]:
"""Load all MCP tools from user's active MCP server connectors. """Load all MCP tools from user's active MCP server connectors.
@ -156,7 +161,9 @@ async def load_mcp_tools(
env = server_config.get("env", {}) env = server_config.get("env", {})
if not command: if not command:
logger.warning(f"MCP connector {connector.id} server config missing command, skipping") logger.warning(
f"MCP connector {connector.id} missing command, skipping"
)
continue continue
# Create MCP client # Create MCP client
@ -174,7 +181,9 @@ async def load_mcp_tools(
# Create LangChain tools from definitions # Create LangChain tools from definitions
for tool_def in tool_definitions: for tool_def in tool_definitions:
try: 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) tools.append(tool)
except Exception as e: except Exception as e:
logger.exception( logger.exception(

View file

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

View file

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

View file

@ -2,11 +2,14 @@
File document processors for different ETL services (Unstructured, LlamaCloud, Docling). File document processors for different ETL services (Unstructured, LlamaCloud, Docling).
""" """
import asyncio
import contextlib import contextlib
import logging import logging
import ssl
import warnings import warnings
from logging import ERROR, getLogger from logging import ERROR, getLogger
import httpx
from fastapi import HTTPException from fastapi import HTTPException
from langchain_core.documents import Document as LangChainDocument from langchain_core.documents import Document as LangChainDocument
from litellm import atranscription from litellm import atranscription
@ -31,6 +34,122 @@ from .base import (
) )
from .markdown_processor import add_received_markdown_file_document 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( async def add_received_file_document_using_unstructured(
session: AsyncSession, session: AsyncSession,
@ -819,24 +938,18 @@ async def process_file_in_background(
"file_type": "document", "file_type": "document",
"etl_service": "LLAMACLOUD", "etl_service": "LLAMACLOUD",
"processing_stage": "parsing", "processing_stage": "parsing",
"estimated_pages": estimated_pages_before,
}, },
) )
from llama_cloud_services import LlamaParse # Parse file with retry logic for SSL/connection errors (common with large files)
from llama_cloud_services.parse.utils import ResultType result = await parse_with_llamacloud_retry(
file_path=file_path,
# Create LlamaParse parser instance estimated_pages=estimated_pages_before,
parser = LlamaParse( task_logger=task_logger,
api_key=app_config.LLAMA_CLOUD_API_KEY, log_entry=log_entry,
num_workers=1, # Use single worker for file processing
verbose=True,
language="en",
result_type=ResultType.MD,
) )
# Parse the file asynchronously
result = await parser.aparse(file_path)
# Clean up the temp file # Clean up the temp file
import os import os

View file

@ -175,6 +175,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944 }, { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944 },
] ]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 },
]
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.7.0" version = "0.7.0"
@ -1568,16 +1577,17 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.115.9" version = "0.128.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "starlette" }, { name = "starlette" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ab/dd/d854f85e70f7341b29e3fda754f2833aec197bd355f805238758e3bcd8ed/fastapi-0.115.9.tar.gz", hash = "sha256:9d7da3b196c5eed049bc769f9475cd55509a112fbe031c0ef2f53768ae68d13f", size = 293774 } sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/b6/7517af5234378518f27ad35a7b24af9591bc500b8c1780929c1295999eb6/fastapi-0.115.9-py3-none-any.whl", hash = "sha256:4a439d7923e4de796bcc88b64e9754340fcd1574673cbd865ba8a99fe0d28c56", size = 94919 }, { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094 },
] ]
[[package]] [[package]]
@ -3482,6 +3492,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/92/9a45c91089c3cf690b5badd4be81e392ff086ccca8a1d4e3a08463d8a966/matplotlib-3.10.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4f23ffe95c5667ef8a2b56eea9b53db7f43910fa4a2d5472ae0f72b64deab4d5", size = 8139044 }, { url = "https://files.pythonhosted.org/packages/1b/92/9a45c91089c3cf690b5badd4be81e392ff086ccca8a1d4e3a08463d8a966/matplotlib-3.10.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4f23ffe95c5667ef8a2b56eea9b53db7f43910fa4a2d5472ae0f72b64deab4d5", size = 8139044 },
] ]
[[package]]
name = "mcp"
version = "1.25.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076 },
]
[[package]] [[package]]
name = "mdurl" name = "mdurl"
version = "0.1.2" version = "0.1.2"
@ -6382,15 +6417,29 @@ wheels = [
] ]
[[package]] [[package]]
name = "starlette" name = "sse-starlette"
version = "0.45.3" version = "3.1.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
{ name = "starlette" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ff/fb/2984a686808b89a6781526129a4b51266f678b2d2b97ab2d325e56116df8/starlette-0.45.3.tar.gz", hash = "sha256:2cbcba2a75806f8a41c722141486f37c28e30a0921c5f6fe4346cb0dcee1302f", size = 2574076 } sdist = { url = "https://files.pythonhosted.org/packages/62/08/8f554b0e5bad3e4e880521a1686d96c05198471eed860b0eb89b57ea3636/sse_starlette-3.1.1.tar.gz", hash = "sha256:bffa531420c1793ab224f63648c059bcadc412bf9fdb1301ac8de1cf9a67b7fb", size = 24306 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/61/f2b52e107b1fc8944b33ef56bf6ac4ebbe16d91b94d2b87ce013bf63fb84/starlette-0.45.3-py3-none-any.whl", hash = "sha256:dfb6d332576f136ec740296c7e8bb8c8a7125044e7c6da30744718880cdd059d", size = 71507 }, { url = "https://files.pythonhosted.org/packages/e3/31/4c281581a0f8de137b710a07f65518b34bcf333b201cfa06cfda9af05f8a/sse_starlette-3.1.1-py3-none-any.whl", hash = "sha256:bb38f71ae74cfd86b529907a9fda5632195dfa6ae120f214ea4c890c7ee9d436", size = 12442 },
]
[[package]]
name = "starlette"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033 },
] ]
[[package]] [[package]]
@ -6443,6 +6492,7 @@ dependencies = [
{ name = "litellm" }, { name = "litellm" },
{ name = "llama-cloud-services" }, { name = "llama-cloud-services" },
{ name = "markdownify" }, { name = "markdownify" },
{ name = "mcp" },
{ name = "notion-client" }, { name = "notion-client" },
{ name = "numpy" }, { name = "numpy" },
{ name = "pgvector" }, { name = "pgvector" },
@ -6457,6 +6507,8 @@ dependencies = [
{ name = "slack-sdk" }, { name = "slack-sdk" },
{ name = "soundfile" }, { name = "soundfile" },
{ name = "spacy" }, { name = "spacy" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "static-ffmpeg" }, { name = "static-ffmpeg" },
{ name = "tavily-python" }, { name = "tavily-python" },
{ name = "trafilatura" }, { name = "trafilatura" },
@ -6505,6 +6557,7 @@ requires-dist = [
{ name = "litellm", specifier = ">=1.80.10" }, { name = "litellm", specifier = ">=1.80.10" },
{ name = "llama-cloud-services", specifier = ">=0.6.25" }, { name = "llama-cloud-services", specifier = ">=0.6.25" },
{ name = "markdownify", specifier = ">=0.14.1" }, { name = "markdownify", specifier = ">=0.14.1" },
{ name = "mcp", specifier = ">=1.25.0" },
{ name = "notion-client", specifier = ">=2.3.0" }, { name = "notion-client", specifier = ">=2.3.0" },
{ name = "numpy", specifier = ">=1.24.0" }, { name = "numpy", specifier = ">=1.24.0" },
{ name = "pgvector", specifier = ">=0.3.6" }, { name = "pgvector", specifier = ">=0.3.6" },
@ -6519,6 +6572,8 @@ requires-dist = [
{ name = "slack-sdk", specifier = ">=3.34.0" }, { name = "slack-sdk", specifier = ">=3.34.0" },
{ name = "soundfile", specifier = ">=0.13.1" }, { name = "soundfile", specifier = ">=0.13.1" },
{ name = "spacy", specifier = ">=3.8.7" }, { name = "spacy", specifier = ">=3.8.7" },
{ name = "sse-starlette", specifier = ">=3.1.1,<3.1.2" },
{ name = "starlette", specifier = ">=0.40.0,<0.51.0" },
{ name = "static-ffmpeg", specifier = ">=2.13" }, { name = "static-ffmpeg", specifier = ">=2.13" },
{ name = "tavily-python", specifier = ">=0.3.2" }, { name = "tavily-python", specifier = ">=0.3.2" },
{ name = "trafilatura", specifier = ">=2.0.0" }, { name = "trafilatura", specifier = ">=2.0.0" },

View file

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

View file

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

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { ArrowLeft, ChevronRight, X } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { ArrowLeft, ChevronRight, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; 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 { useCallback, useState } from "react";
import { ApiKeyContent } from "./components/ApiKeyContent"; import { ApiKeyContent } from "./components/ApiKeyContent";
import { ProfileContent } from "./components/ProfileContent"; import { ProfileContent } from "./components/ProfileContent";
import { UserSettingsSidebar, type SettingsNavItem } from "./components/UserSettingsSidebar"; import { type SettingsNavItem, UserSettingsSidebar } from "./components/UserSettingsSidebar";
export default function UserSettingsPage() { export default function UserSettingsPage() {
const t = useTranslations("userSettings"); const t = useTranslations("userSettings");

View file

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

View file

@ -96,35 +96,37 @@ const DocumentUploadPopupContent: FC<{
return ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <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> <DialogTitle className="sr-only">Upload Document</DialogTitle>
{/* Fixed Header */} {/* Scrollable container for mobile */}
<div className="flex-shrink-0 px-4 sm:px-12 pt-6 sm:pt-10 transition-shadow duration-200 relative z-10"> <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 */} {/* Upload header */}
<div className="flex items-center gap-2 sm:gap-4 mb-2 sm:mb-6"> <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"> <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-5 sm:size-7 text-primary" /> <Upload className="size-4 sm:size-7 text-primary" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0 pr-8 sm:pr-0">
<h2 className="text-lg sm:text-2xl font-semibold tracking-tight">Upload Documents</h2> <h2 className="text-base sm:text-2xl font-semibold tracking-tight">
<p className="text-xs sm:text-base text-muted-foreground mt-0.5 sm:mt-1"> 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 Upload and sync your documents to your search space
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* Scrollable Content */} {/* Content */}
<div className="flex-1 min-h-0 relative overflow-hidden"> <div className="px-4 sm:px-12 pb-4 sm:pb-16">
<div className="h-full overflow-y-auto">
<div className="px-6 sm:px-12 pb-5 sm:pb-16">
<DocumentUploadTab searchSpaceId={searchSpaceId} onSuccess={handleSuccess} /> <DocumentUploadTab searchSpaceId={searchSpaceId} onSuccess={handleSuccess} />
</div> </div>
</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" /> {/* Bottom fade shadow - hidden on very small screens */}
</div> <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> </DialogContent>
</Dialog> </Dialog>
); );

View file

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

@ -110,6 +110,11 @@ const FILE_TYPE_CONFIG: Record<string, Record<string, string[]>> = {
const cardClass = "border border-border bg-slate-400/5 dark:bg-white/5"; 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({ export function DocumentUploadTab({
searchSpaceId, searchSpaceId,
onSuccess, onSuccess,
@ -134,15 +139,40 @@ export function DocumentUploadTab({
[acceptedFileTypes] [acceptedFileTypes]
); );
const onDrop = useCallback((acceptedFiles: File[]) => { const onDrop = useCallback(
setFiles((prev) => [...prev, ...acceptedFiles]); (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({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop, onDrop,
accept: acceptedFileTypes, accept: acceptedFileTypes,
maxSize: 50 * 1024 * 1024, maxSize: 50 * 1024 * 1024, // 50MB per file
noClick: false, noClick: false,
disabled: files.length >= MAX_FILES,
}); });
// Handle file input click to prevent event bubbling that might reopen dialog // Handle file input click to prevent event bubbling that might reopen dialog
@ -160,6 +190,15 @@ export function DocumentUploadTab({
const totalFileSize = files.reduce((total, file) => total + file.size, 0); 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 // Track accordion state changes
const handleAccordionChange = useCallback( const handleAccordionChange = useCallback(
(value: string) => { (value: string) => {
@ -210,7 +249,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"> <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" /> <Info className="h-4 w-4 shrink-0 mt-0.5" />
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-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> </AlertDescription>
</Alert> </Alert>
@ -221,7 +261,11 @@ export function DocumentUploadTab({
<CardContent className="p-4 sm:p-10 relative z-10"> <CardContent className="p-4 sm:p-10 relative z-10">
<div <div
{...getRootProps()} {...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 <input
{...getInputProps()} {...getInputProps()}
@ -229,7 +273,19 @@ export function DocumentUploadTab({
className="hidden" className="hidden"
onClick={handleFileInputClick} 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 <motion.div
initial={{ opacity: 0, scale: 0.8 }} initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
@ -245,8 +301,14 @@ export function DocumentUploadTab({
<p className="text-sm sm:text-lg font-medium">{t("drag_drop")}</p> <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> <p className="text-xs sm:text-sm text-muted-foreground mt-1">{t("or_browse")}</p>
</div> </div>
{files.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("remaining_capacity", { files: remainingFiles, sizeMB: remainingSizeMB })}
</p>
)}
</div> </div>
)} )}
{!isFileCountLimitReached && (
<div className="mt-2 sm:mt-4"> <div className="mt-2 sm:mt-4">
<Button <Button
variant="outline" variant="outline"
@ -261,6 +323,7 @@ export function DocumentUploadTab({
{t("browse_files")} {t("browse_files")}
</Button> </Button>
</div> </div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

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

View file

@ -378,6 +378,7 @@
"title": "Upload Documents", "title": "Upload Documents",
"subtitle": "Upload your files to make them searchable and accessible through AI-powered conversations.", "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.", "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", "drop_files": "Drop files here",
"drag_drop": "Drag & drop files here", "drag_drop": "Drag & drop files here",
"or_browse": "or click to browse", "or_browse": "or click to browse",
@ -393,7 +394,14 @@
"upload_error": "Upload Error", "upload_error": "Upload Error",
"upload_error_desc": "Error uploading files", "upload_error_desc": "Error uploading files",
"supported_file_types": "Supported File Types", "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": { "add_webpage": {
"title": "Add Webpages for Crawling", "title": "Add Webpages for Crawling",

View file

@ -363,6 +363,7 @@
"title": "上传文档", "title": "上传文档",
"subtitle": "上传您的文件,使其可通过 AI 对话进行搜索和访问。", "subtitle": "上传您的文件,使其可通过 AI 对话进行搜索和访问。",
"file_size_limit": "最大文件大小:每个文件 50MB。支持的格式因您的 ETL 服务配置而异。", "file_size_limit": "最大文件大小:每个文件 50MB。支持的格式因您的 ETL 服务配置而异。",
"upload_limits": "上传限制:最多 {maxFiles} 个文件,总大小不超过 {maxSizeMB}MB。",
"drop_files": "放下文件到这里", "drop_files": "放下文件到这里",
"drag_drop": "拖放文件到这里", "drag_drop": "拖放文件到这里",
"or_browse": "或点击浏览", "or_browse": "或点击浏览",
@ -378,7 +379,14 @@
"upload_error": "上传错误", "upload_error": "上传错误",
"upload_error_desc": "上传文件时出错", "upload_error_desc": "上传文件时出错",
"supported_file_types": "支持的文件类型", "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": { "add_webpage": {
"title": "添加网页爬取", "title": "添加网页爬取",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Before After
Before After