mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/replace-logs
This commit is contained in:
commit
ab63b23f0a
24 changed files with 343 additions and 134 deletions
|
|
@ -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>
|
||||
|
||||

|
||||
|
||||
|
||||
<div align="center">
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -44,4 +44,3 @@ def downgrade() -> None:
|
|||
DROP COLUMN IF EXISTS author_id;
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -120,4 +120,3 @@ export function ApiKeyContent({ onMenuClick }: ApiKeyContentProps) {
|
|||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -16,4 +16,3 @@ export const updateUserMutationAtom = atomWithMutation((get) => {
|
|||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
getMeResponse,
|
||||
updateUserResponse,
|
||||
type UpdateUserRequest,
|
||||
updateUserResponse,
|
||||
} from "@/contracts/types/user.types";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 |
Loading…
Add table
Add a link
Reference in a new issue