From a3c89df63c531e52072ffcec1412e7fa9ed9bc06 Mon Sep 17 00:00:00 2001 From: zhizhi <928570418@qq.com> Date: Wed, 4 Mar 2026 10:22:02 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20FastAPI=20application=20facto?= =?UTF-8?q?ry=20and=20unit=20tests:=20Implemented=20a=20new=20app=5Ffactor?= =?UTF-8?q?y=20module=20for=20creating=20FastAPI=20applications=20with=20c?= =?UTF-8?q?ommon=20configurations=20and=20exception=20handling.=20Added=20?= =?UTF-8?q?comprehensive=20unit=20tests=20to=20validate=20the=20functional?= =?UTF-8?q?ity=20of=20the=20create=5Fapp=20and=20register=5Fexception=5Fha?= =?UTF-8?q?ndlers=20functions.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/app_factory.py | 108 +++ backend/apps/config_app.py | 42 +- backend/apps/dify_app.py | 31 +- backend/apps/northbound_base_app.py | 40 +- backend/apps/runtime_app.py | 48 +- backend/consts/const.py | 1 + backend/consts/error_code.py | 219 ++++++ backend/consts/error_message.py | 148 ++++ backend/consts/exceptions.py | 115 ++- backend/consts/model.py | 1 + backend/database/db_models.py | 10 +- backend/middleware/__init__.py | 1 + backend/middleware/exception_handler.py | 176 +++++ backend/services/agent_service.py | 22 +- backend/services/agent_version_service.py | 7 +- backend/services/dify_service.py | 52 +- docker/init.sql | 6 +- ...ngroup_permission_to_ag_tenant_agent_t.sql | 10 + ...tance_id_seq_and_agent_relation_id_seq.sql | 14 + .../app/[locale]/agents/AgentVersionCard.tsx | 37 +- .../components/agentConfig/ToolManagement.tsx | 48 +- .../agentInfo/AgentGenerateDetail.tsx | 154 ++-- .../components/agentManage/AgentList.tsx | 28 +- .../[locale]/space/components/AgentCard.tsx | 2 +- frontend/app/[locale]/space/page.tsx | 4 +- .../components/UserManageComp.tsx | 6 +- frontend/const/errorCode.ts | 207 ++++++ frontend/const/errorMessage.ts | 212 ++++++ frontend/const/errorMessageI18n.ts | 238 ++++++ frontend/hooks/agent/useSaveGuard.ts | 1 + frontend/hooks/auth/useAuthenticationState.ts | 6 + frontend/hooks/useErrorHandler.ts | 167 +++++ frontend/hooks/useKnowledgeBaseSelector.ts | 18 +- frontend/public/locales/en/common.json | 162 +++- frontend/public/locales/zh/common.json | 177 ++++- frontend/services/agentConfigService.ts | 4 +- frontend/services/api.ts | 50 +- frontend/services/knowledgeBaseService.ts | 55 +- frontend/stores/agentConfigStore.ts | 60 +- frontend/types/agentConfig.ts | 2 + test/backend/app/test_app_factory.py | 701 ++++++++++++++++++ test/backend/app/test_config_app.py | 73 +- test/backend/app/test_dify_app.py | 551 +++++++++++++- test/backend/app/test_northbound_base_app.py | 23 +- test/backend/consts/test_error_code.py | 353 +++++++++ test/backend/consts/test_error_message.py | 257 +++++++ test/backend/consts/test_exceptions.py | 195 +++++ .../middleware/test_exception_handler.py | 537 ++++++++++++++ test/backend/services/test_agent_service.py | 602 ++++++++++++++- test/backend/services/test_dify_service.py | 204 ++++- 50 files changed, 5789 insertions(+), 396 deletions(-) create mode 100644 backend/apps/app_factory.py create mode 100644 backend/consts/error_code.py create mode 100644 backend/consts/error_message.py create mode 100644 backend/middleware/__init__.py create mode 100644 backend/middleware/exception_handler.py create mode 100644 docker/sql/v1.8.0.2_0227_add_ingroup_permission_to_ag_tenant_agent_t.sql create mode 100644 docker/sql/v1.8.0.2_0302_add_tool_instance_id_seq_and_agent_relation_id_seq.sql create mode 100644 frontend/const/errorCode.ts create mode 100644 frontend/const/errorMessage.ts create mode 100644 frontend/const/errorMessageI18n.ts create mode 100644 frontend/hooks/useErrorHandler.ts create mode 100644 test/backend/app/test_app_factory.py create mode 100644 test/backend/consts/test_error_code.py create mode 100644 test/backend/consts/test_error_message.py create mode 100644 test/backend/consts/test_exceptions.py create mode 100644 test/backend/middleware/test_exception_handler.py diff --git a/backend/apps/app_factory.py b/backend/apps/app_factory.py new file mode 100644 index 000000000..219da5b82 --- /dev/null +++ b/backend/apps/app_factory.py @@ -0,0 +1,108 @@ +""" +FastAPI application factory with common configurations and exception handlers. +""" +import logging + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from consts.exceptions import AppException + + +logger = logging.getLogger(__name__) + + +def create_app( + title: str = "Nexent API", + description: str = "", + version: str = "1.0.0", + root_path: str = "/api", + cors_origins: list = None, + cors_methods: list = None, + enable_monitoring: bool = True, +) -> FastAPI: + """ + Create a FastAPI application with common configurations. + + Args: + title: API title + description: API description + version: API version + root_path: Root path for the API + cors_origins: List of allowed CORS origins (default: ["*"]) + cors_methods: List of allowed CORS methods (default: ["*"]) + enable_monitoring: Whether to enable monitoring + + Returns: + Configured FastAPI application + """ + app = FastAPI( + title=title, + description=description, + version=version, + root_path=root_path + ) + + # Add CORS middleware + app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins or ["*"], + allow_credentials=True, + allow_methods=cors_methods or ["*"], + allow_headers=["*"], + ) + + # Register exception handlers + register_exception_handlers(app) + + # Initialize monitoring if enabled + if enable_monitoring: + try: + from utils.monitoring import monitoring_manager + monitoring_manager.setup_fastapi_app(app) + except ImportError: + logger.warning("Monitoring utilities not available") + + return app + + +def register_exception_handlers(app: FastAPI) -> None: + """ + Register common exception handlers for the FastAPI application. + + Args: + app: FastAPI application instance + """ + + @app.exception_handler(HTTPException) + async def http_exception_handler(request, exc): + logger.error(f"HTTPException: {exc.detail}") + return JSONResponse( + status_code=exc.status_code, + content={"message": exc.detail}, + ) + + @app.exception_handler(AppException) + async def app_exception_handler(request, exc): + logger.error(f"AppException: {exc.error_code.value} - {exc.message}") + return JSONResponse( + status_code=exc.http_status, + content={ + "code": exc.error_code.value, + "message": exc.message, + "details": exc.details if exc.details else None + }, + ) + + @app.exception_handler(Exception) + async def generic_exception_handler(request, exc): + # Don't catch AppException - it has its own handler + if isinstance(exc, AppException): + return await app_exception_handler(request, exc) + + logger.error(f"Generic Exception: {exc}") + return JSONResponse( + status_code=500, + content={"message": "Internal server error, please try again later."}, + ) diff --git a/backend/apps/config_app.py b/backend/apps/config_app.py index 8691b15e0..fb6a0a4f0 100644 --- a/backend/apps/config_app.py +++ b/backend/apps/config_app.py @@ -1,9 +1,6 @@ import logging -from fastapi import FastAPI, HTTPException -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse - +from apps.app_factory import create_app from apps.agent_app import agent_config_router as agent_router from apps.config_sync_app import router as config_sync_router from apps.datamate_app import router as datamate_router @@ -26,21 +23,11 @@ from apps.invitation_app import router as invitation_router from consts.const import IS_SPEED_MODE -# Import monitoring utilities -from utils.monitoring import monitoring_manager - # Create logger instance logger = logging.getLogger("base_app") -app = FastAPI(root_path="/api") -# Add CORS middleware -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Allows all origins - allow_credentials=True, - allow_methods=["*"], # Allows all methods - allow_headers=["*"], # Allows all headers -) +# Create FastAPI app with common configurations +app = create_app(title="Nexent Config API", description="Configuration APIs") app.include_router(model_manager_router) app.include_router(config_sync_router) @@ -69,26 +56,3 @@ app.include_router(group_router) app.include_router(user_router) app.include_router(invitation_router) - -# Initialize monitoring for the application -monitoring_manager.setup_fastapi_app(app) - - -# Global exception handler for HTTP exceptions -@app.exception_handler(HTTPException) -async def http_exception_handler(request, exc): - logger.error(f"HTTPException: {exc.detail}") - return JSONResponse( - status_code=exc.status_code, - content={"message": exc.detail}, - ) - - -# Global exception handler for all uncaught exceptions -@app.exception_handler(Exception) -async def generic_exception_handler(request, exc): - logger.error(f"Generic Exception: {exc}") - return JSONResponse( - status_code=500, - content={"message": "Internal server error, please try again later."}, - ) diff --git a/backend/apps/dify_app.py b/backend/apps/dify_app.py index c7d9321af..99ce8d86e 100644 --- a/backend/apps/dify_app.py +++ b/backend/apps/dify_app.py @@ -13,6 +13,8 @@ from fastapi import APIRouter, Header, HTTPException, Query from fastapi.responses import JSONResponse +from consts.error_code import ErrorCode +from consts.exceptions import AppException from services.dify_service import fetch_dify_datasets_impl from utils.auth_utils import get_current_user_id @@ -35,18 +37,10 @@ async def fetch_dify_datasets_api( # Normalize URL by removing trailing slash dify_api_base = dify_api_base.rstrip('/') except Exception as e: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Failed to fetch Dify datasets: {str(e)}" - ) + logger.error(f"Invalid Dify configuration: {e}") + raise AppException(ErrorCode.DIFY_CONFIG_INVALID, + f"Invalid URL format: {str(e)}") - try: - _, tenant_id = get_current_user_id(authorization) - except Exception as e: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Failed to fetch Dify datasets: {str(e)}" - ) try: result = fetch_dify_datasets_impl( @@ -57,15 +51,10 @@ async def fetch_dify_datasets_api( status_code=HTTPStatus.OK, content=result ) - except ValueError as e: - logger.warning(f"Invalid Dify configuration: {e}") - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=str(e) - ) + except AppException: + # Re-raise AppException to be handled by global middleware + raise except Exception as e: logger.error(f"Failed to fetch Dify datasets: {e}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Failed to fetch Dify datasets: {str(e)}" - ) + raise AppException(ErrorCode.DIFY_SERVICE_ERROR, + f"Failed to fetch Dify datasets: {str(e)}") diff --git a/backend/apps/northbound_base_app.py b/backend/apps/northbound_base_app.py index ff51e0e71..00e9f0462 100644 --- a/backend/apps/northbound_base_app.py +++ b/backend/apps/northbound_base_app.py @@ -1,47 +1,17 @@ -from http import HTTPStatus import logging -from fastapi import FastAPI, HTTPException -from fastapi.responses import JSONResponse -from fastapi.middleware.cors import CORSMiddleware +from apps.app_factory import create_app from .northbound_app import router as northbound_router -from consts.exceptions import LimitExceededError, UnauthorizedError, SignatureValidationError logger = logging.getLogger("northbound_base_app") - -northbound_app = FastAPI( +# Create FastAPI app with common configurations +northbound_app = create_app( title="Nexent Northbound API", description="Northbound APIs for partners", version="1.0.0", - root_path="/api" -) - -northbound_app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["GET", "POST", "PUT", "DELETE"], - allow_headers=["*"], + cors_methods=["GET", "POST", "PUT", "DELETE"], + enable_monitoring=False # Disable monitoring for northbound API if not needed ) - northbound_app.include_router(northbound_router) - - -@northbound_app.exception_handler(HTTPException) -async def northbound_http_exception_handler(request, exc): - logger.error(f"Northbound HTTPException: {exc.detail}") - return JSONResponse( - status_code=exc.status_code, - content={"message": exc.detail}, - ) - - -@northbound_app.exception_handler(Exception) -async def northbound_generic_exception_handler(request, exc): - logger.error(f"Northbound Generic Exception: {exc}") - return JSONResponse( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - content={"message": "Internal server error, please try again later."}, - ) diff --git a/backend/apps/runtime_app.py b/backend/apps/runtime_app.py index 6db480ab6..7420a14a2 100644 --- a/backend/apps/runtime_app.py +++ b/backend/apps/runtime_app.py @@ -1,58 +1,24 @@ import logging -from fastapi import FastAPI, HTTPException -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse - +from apps.app_factory import create_app from apps.agent_app import agent_runtime_router as agent_router from apps.voice_app import voice_runtime_router as voice_router from apps.conversation_management_app import router as conversation_management_router from apps.memory_config_app import router as memory_config_router from apps.file_management_app import file_management_runtime_router as file_management_router - -# Import monitoring utilities -from utils.monitoring import monitoring_manager +from middleware.exception_handler import ExceptionHandlerMiddleware # Create logger instance logger = logging.getLogger("runtime_app") -app = FastAPI(root_path="/api") -# Add CORS middleware -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) +# Create FastAPI app with common configurations +app = create_app(title="Nexent Runtime API", description="Runtime APIs") + +# Add global exception handler middleware +app.add_middleware(ExceptionHandlerMiddleware) app.include_router(agent_router) app.include_router(conversation_management_router) app.include_router(memory_config_router) app.include_router(file_management_router) app.include_router(voice_router) - -# Initialize monitoring for the application -monitoring_manager.setup_fastapi_app(app) - - -# Global exception handler for HTTP exceptions -@app.exception_handler(HTTPException) -async def http_exception_handler(request, exc): - logger.error(f"HTTPException: {exc.detail}") - return JSONResponse( - status_code=exc.status_code, - content={"message": exc.detail}, - ) - - -# Global exception handler for all uncaught exceptions -@app.exception_handler(Exception) -async def generic_exception_handler(request, exc): - logger.error(f"Generic Exception: {exc}") - return JSONResponse( - status_code=500, - content={"message": "Internal server error, please try again later."}, - ) - - diff --git a/backend/consts/const.py b/backend/consts/const.py index f226d7443..2618d4568 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -71,6 +71,7 @@ class VectorDatabaseType(str, Enum): # Permission constants used by list endpoints (e.g., /agent/list, /mcp/list). PERMISSION_READ = "READ_ONLY" PERMISSION_EDIT = "EDIT" +PERMISSION_PRIVATE = "PRIVATE" # Deployment Version Configuration diff --git a/backend/consts/error_code.py b/backend/consts/error_code.py new file mode 100644 index 000000000..73decbbf3 --- /dev/null +++ b/backend/consts/error_code.py @@ -0,0 +1,219 @@ +""" +Error code definitions for the application. + +Format: XXYYZZ (6 digits, string) +- XX: Module code (01-99, based on sidebar) + 00: Common / 公共 - cross-module common errors + 01: Chat / 开始问答 + 02: QuickConfig / 快速配置 + 03: AgentSpace / 智能体空间 + 04: AgentMarket / 智能体市场 + 05: AgentDev / 智能体开发 + 06: Knowledge / 知识库 + 07: MCPTools / MCP 工具 + 08: MonitorOps / 监控与运维 + 09: Model / 模型管理 + 10: Memory / 记忆管理 + 11: Profile / 个人信息 + 12: TenantResource / 租户资源 + 13: External / 外部服务 (DataMate, Dify) + 15: Northbound / 北向接口 + 17: DataProcess / 数据处理 + 99: System / 系统级 - system internal errors +- YY: Sub module category (01-99) +- ZZ: Sequence in category (01-99) +""" + +from enum import Enum + + +class ErrorCode(Enum): + """Business error codes (stored as strings to preserve leading zeros).""" + + # ==================== 00 Common / 公共 ==================== + # 01 - Parameter & Validation + COMMON_VALIDATION_ERROR = "000101" # Validation error + COMMON_PARAMETER_INVALID = "000102" # Invalid parameter + COMMON_MISSING_REQUIRED_FIELD = "000103" # Missing required field + + # 02 - Auth & Permission + COMMON_UNAUTHORIZED = "000201" # Not logged in / unauthenticated + COMMON_FORBIDDEN = "000202" # No permission + COMMON_TOKEN_EXPIRED = "000203" # Token expired + COMMON_TOKEN_INVALID = "000204" # Invalid token + + # 03 - External Service + COMMON_EXTERNAL_SERVICE_ERROR = "000301" # External service error + COMMON_RATE_LIMIT_EXCEEDED = "000302" # Rate limit exceeded + + # 04 - File + FILE_NOT_FOUND = "000401" # File not found + FILE_UPLOAD_FAILED = "000402" # File upload failed + FILE_TOO_LARGE = "000403" # File too large + FILE_TYPE_NOT_ALLOWED = "000404" # File type not allowed + FILE_PREPROCESS_FAILED = "000405" # File preprocess failed + + # 05 - Resource + COMMON_RESOURCE_NOT_FOUND = "000501" # Resource not found + COMMON_RESOURCE_ALREADY_EXISTS = "000502" # Resource already exists + COMMON_RESOURCE_DISABLED = "000503" # Resource disabled + + # ==================== 01 Chat / 开始问答 ==================== + # 01 - Conversation + CHAT_CONVERSATION_NOT_FOUND = "010101" # Conversation not found + CHAT_MESSAGE_NOT_FOUND = "010102" # Message not found + CHAT_CONVERSATION_SAVE_FAILED = "010103" # Failed to save conversation + CHAT_TITLE_GENERATION_FAILED = "010104" # Failed to generate title + + # ==================== 02 QuickConfig / 快速配置 ==================== + # 01 - Configuration + QUICK_CONFIG_INVALID = "020101" # Invalid configuration + QUICK_CONFIG_SYNC_FAILED = "020102" # Sync configuration failed + + # ==================== 03 AgentSpace / 智能体空间 ==================== + # 01 - Agent + AGENTSPACE_AGENT_NOT_FOUND = "030101" # Agent not found + AGENTSPACE_AGENT_DISABLED = "030102" # Agent disabled + AGENTSPACE_AGENT_RUN_FAILED = "030103" # Agent run failed + AGENTSPACE_AGENT_NAME_DUPLICATE = "030104" # Duplicate agent name + AGENTSPACE_VERSION_NOT_FOUND = "030105" # Agent version not found + + # ==================== 04 AgentMarket / 智能体市场 ==================== + # 01 - Agent + AGENTMARKET_AGENT_NOT_FOUND = "040101" # Agent not found in market + + # ==================== 05 AgentDev / 智能体开发 ==================== + # 01 - Configuration + AGENTDEV_CONFIG_INVALID = "050101" # Invalid agent configuration + AGENTDEV_PROMPT_INVALID = "050102" # Invalid prompt + + # ==================== 06 Knowledge / 知识库 ==================== + # 01 - Knowledge Base + KNOWLEDGE_NOT_FOUND = "060101" # Knowledge not found + KNOWLEDGE_UPLOAD_FAILED = "060102" # Upload failed + KNOWLEDGE_SYNC_FAILED = "060103" # Sync failed + KNOWLEDGE_INDEX_NOT_FOUND = "060104" # Index not found + KNOWLEDGE_SEARCH_FAILED = "060105" # Search failed + + # ==================== 07 MCPTools / MCP 工具 ==================== + # 01 - Tool + MCP_TOOL_NOT_FOUND = "070101" # Tool not found + MCP_TOOL_EXECUTION_FAILED = "070102" # Tool execution failed + MCP_TOOL_CONFIG_INVALID = "070103" # Invalid tool configuration + + # 02 - Connection + MCP_CONNECTION_FAILED = "070201" # MCP connection failed + MCP_CONTAINER_ERROR = "070202" # MCP container error + + # 03 - Configuration + MCP_NAME_ILLEGAL = "070301" # Illegal MCP name + + # ==================== 08 MonitorOps / 监控与运维 ==================== + # 01 - Monitoring + MONITOROPS_METRIC_QUERY_FAILED = "080101" # Metric query failed + + # 02 - Alert + MONITOROPS_ALERT_CONFIG_INVALID = "080201" # Invalid alert configuration + + # ==================== 09 Model / 模型管理 ==================== + # 01 - Model + MODEL_NOT_FOUND = "090101" # Model not found + MODEL_CONFIG_INVALID = "090102" # Invalid model configuration + MODEL_HEALTH_CHECK_FAILED = "090103" # Health check failed + MODEL_PROVIDER_ERROR = "090104" # Model provider error + + # ==================== 10 Memory / 记忆管理 ==================== + # 01 - Memory + MEMORY_NOT_FOUND = "100101" # Memory not found + MEMORY_PREPARATION_FAILED = "100102" # Memory preparation failed + MEMORY_CONFIG_INVALID = "100103" # Invalid memory configuration + + # ==================== 11 Profile / 个人信息 ==================== + # 01 - User + PROFILE_USER_NOT_FOUND = "110101" # User not found + PROFILE_UPDATE_FAILED = "110102" # Profile update failed + PROFILE_USER_ALREADY_EXISTS = "110103" # User already exists + PROFILE_INVALID_CREDENTIALS = "110104" # Invalid credentials + + # ==================== 12 TenantResource / 租户资源 ==================== + # 01 - Tenant + TENANT_NOT_FOUND = "120101" # Tenant not found + TENANT_DISABLED = "120102" # Tenant disabled + TENANT_CONFIG_ERROR = "120103" # Tenant configuration error + TENANT_RESOURCE_EXCEEDED = "120104" # Tenant resource exceeded + + # ==================== 13 External / 外部服务 ==================== + # 01 - DataMate + DATAMATE_CONNECTION_FAILED = "130101" # DataMate connection failed + + # 02 - Dify + DIFY_SERVICE_ERROR = "130201" # Dify service error + DIFY_CONFIG_INVALID = "130202" # Invalid Dify configuration + DIFY_CONNECTION_ERROR = "130203" # Dify connection error + DIFY_AUTH_ERROR = "130204" # Dify auth error + DIFY_RATE_LIMIT = "130205" # Dify rate limit + DIFY_RESPONSE_ERROR = "130206" # Dify response error + + # 03 - ME Service + ME_CONNECTION_FAILED = "130301" # ME service connection failed + + # ==================== 14 Northbound / 北向接口 ==================== + # 01 - Request + NORTHBOUND_REQUEST_FAILED = "140101" # Northbound request failed + + # 02 - Configuration + NORTHBOUND_CONFIG_INVALID = "140201" # Invalid northbound configuration + + # ==================== 15 DataProcess / 数据处理 ==================== + # 01 - Task + DATAPROCESS_TASK_FAILED = "150101" # Data process task failed + DATAPROCESS_PARSE_FAILED = "150102" # Data parse failed + + # ==================== 99 System / 系统级 ==================== + # 01 - System Errors + SYSTEM_UNKNOWN_ERROR = "990101" # Unknown error + SYSTEM_SERVICE_UNAVAILABLE = "990102" # Service unavailable + SYSTEM_DATABASE_ERROR = "990103" # Database error + SYSTEM_TIMEOUT = "990104" # Timeout + SYSTEM_INTERNAL_ERROR = "990105" # Internal error + + # 02 - Config + CONFIG_NOT_FOUND = "990201" # Configuration not found + CONFIG_UPDATE_FAILED = "990202" # Configuration update failed + + +# HTTP status code mapping +ERROR_CODE_HTTP_STATUS = { + # Common - Auth + ErrorCode.COMMON_UNAUTHORIZED: 401, + ErrorCode.COMMON_TOKEN_EXPIRED: 401, + ErrorCode.COMMON_TOKEN_INVALID: 401, + ErrorCode.COMMON_FORBIDDEN: 403, + # Common - Validation + ErrorCode.COMMON_VALIDATION_ERROR: 400, + ErrorCode.COMMON_PARAMETER_INVALID: 400, + ErrorCode.COMMON_MISSING_REQUIRED_FIELD: 400, + # Common - Rate Limit + ErrorCode.COMMON_RATE_LIMIT_EXCEEDED: 429, + # Common - Resource + ErrorCode.COMMON_RESOURCE_NOT_FOUND: 404, + ErrorCode.COMMON_RESOURCE_ALREADY_EXISTS: 409, + ErrorCode.COMMON_RESOURCE_DISABLED: 403, + # Common - File + ErrorCode.FILE_NOT_FOUND: 404, + ErrorCode.FILE_UPLOAD_FAILED: 500, + ErrorCode.FILE_TOO_LARGE: 413, + ErrorCode.FILE_TYPE_NOT_ALLOWED: 400, + ErrorCode.FILE_PREPROCESS_FAILED: 500, + # System + ErrorCode.SYSTEM_SERVICE_UNAVAILABLE: 503, + ErrorCode.SYSTEM_TIMEOUT: 504, + ErrorCode.SYSTEM_DATABASE_ERROR: 500, + ErrorCode.SYSTEM_INTERNAL_ERROR: 500, + # Dify (module 13) + ErrorCode.DIFY_CONFIG_INVALID: 400, + ErrorCode.DIFY_AUTH_ERROR: 401, + ErrorCode.DIFY_CONNECTION_ERROR: 502, + ErrorCode.DIFY_RESPONSE_ERROR: 502, + ErrorCode.DIFY_RATE_LIMIT: 429, +} diff --git a/backend/consts/error_message.py b/backend/consts/error_message.py new file mode 100644 index 000000000..aa7bf45e3 --- /dev/null +++ b/backend/consts/error_message.py @@ -0,0 +1,148 @@ +""" +Error message mappings for error codes. + +This module provides default English error messages. +Frontend should use i18n for localized messages. +""" + +from .error_code import ErrorCode + + +class ErrorMessage: + """Error code to message mapping.""" + + _MESSAGES = { + # ==================== 00 Common / 公共 ==================== + # 00 - Parameter & Validation + ErrorCode.COMMON_VALIDATION_ERROR: "Validation failed.", + ErrorCode.COMMON_PARAMETER_INVALID: "Invalid parameter.", + ErrorCode.COMMON_MISSING_REQUIRED_FIELD: "Required field is missing.", + # 01 - Auth & Permission + ErrorCode.COMMON_UNAUTHORIZED: "You are not authorized to perform this action.", + ErrorCode.COMMON_FORBIDDEN: "Access forbidden.", + ErrorCode.COMMON_TOKEN_EXPIRED: "Your session has expired. Please login again.", + ErrorCode.COMMON_TOKEN_INVALID: "Invalid token. Please login again.", + # 02 - External Service + ErrorCode.COMMON_EXTERNAL_SERVICE_ERROR: "External service error.", + ErrorCode.COMMON_RATE_LIMIT_EXCEEDED: "Too many requests. Please try again later.", + # 03 - File + ErrorCode.FILE_NOT_FOUND: "File not found.", + ErrorCode.FILE_UPLOAD_FAILED: "Failed to upload file.", + ErrorCode.FILE_TOO_LARGE: "File size exceeds limit.", + ErrorCode.FILE_TYPE_NOT_ALLOWED: "File type not allowed.", + ErrorCode.FILE_PREPROCESS_FAILED: "File preprocessing failed.", + # 04 - Resource + ErrorCode.COMMON_RESOURCE_NOT_FOUND: "Resource not found.", + ErrorCode.COMMON_RESOURCE_ALREADY_EXISTS: "Resource already exists.", + ErrorCode.COMMON_RESOURCE_DISABLED: "Resource is disabled.", + + # ==================== 01 Chat / 开始问答 ==================== + ErrorCode.CHAT_CONVERSATION_NOT_FOUND: "Conversation not found.", + ErrorCode.CHAT_MESSAGE_NOT_FOUND: "Message not found.", + ErrorCode.CHAT_CONVERSATION_SAVE_FAILED: "Failed to save conversation.", + ErrorCode.CHAT_TITLE_GENERATION_FAILED: "Failed to generate conversation title.", + + # ==================== 02 QuickConfig / 快速配置 ==================== + ErrorCode.QUICK_CONFIG_INVALID: "Invalid configuration.", + ErrorCode.QUICK_CONFIG_SYNC_FAILED: "Sync configuration failed.", + + # ==================== 03 AgentSpace / 智能体空间 ==================== + ErrorCode.AGENTSPACE_AGENT_NOT_FOUND: "Agent not found.", + ErrorCode.AGENTSPACE_AGENT_DISABLED: "Agent is disabled.", + ErrorCode.AGENTSPACE_AGENT_RUN_FAILED: "Failed to run agent. Please try again later.", + ErrorCode.AGENTSPACE_AGENT_NAME_DUPLICATE: "Agent name already exists.", + ErrorCode.AGENTSPACE_VERSION_NOT_FOUND: "Agent version not found.", + + # ==================== 04 AgentMarket / 智能体市场 ==================== + ErrorCode.AGENTMARKET_AGENT_NOT_FOUND: "Agent not found in market.", + + # ==================== 05 AgentDev / 智能体开发 ==================== + ErrorCode.AGENTDEV_CONFIG_INVALID: "Invalid agent configuration.", + ErrorCode.AGENTDEV_PROMPT_INVALID: "Invalid prompt.", + + # ==================== 06 Knowledge / 知识库 ==================== + ErrorCode.KNOWLEDGE_NOT_FOUND: "Knowledge base not found.", + ErrorCode.KNOWLEDGE_UPLOAD_FAILED: "Failed to upload knowledge.", + ErrorCode.KNOWLEDGE_SYNC_FAILED: "Failed to sync knowledge base.", + ErrorCode.KNOWLEDGE_INDEX_NOT_FOUND: "Search index not found.", + ErrorCode.KNOWLEDGE_SEARCH_FAILED: "Knowledge search failed.", + + # ==================== 07 MCPTools / MCP 工具 ==================== + ErrorCode.MCP_TOOL_NOT_FOUND: "Tool not found.", + ErrorCode.MCP_TOOL_EXECUTION_FAILED: "Tool execution failed.", + ErrorCode.MCP_TOOL_CONFIG_INVALID: "Tool configuration is invalid.", + ErrorCode.MCP_CONNECTION_FAILED: "Failed to connect to MCP service.", + ErrorCode.MCP_CONTAINER_ERROR: "MCP container operation failed.", + ErrorCode.MCP_NAME_ILLEGAL: "MCP name contains invalid characters.", + + # ==================== 08 MonitorOps / 监控与运维 ==================== + ErrorCode.MONITOROPS_METRIC_QUERY_FAILED: "Metric query failed.", + ErrorCode.MONITOROPS_ALERT_CONFIG_INVALID: "Invalid alert configuration.", + + # ==================== 09 Model / 模型管理 ==================== + ErrorCode.MODEL_NOT_FOUND: "Model not found.", + ErrorCode.MODEL_CONFIG_INVALID: "Model configuration is invalid.", + ErrorCode.MODEL_HEALTH_CHECK_FAILED: "Model health check failed.", + ErrorCode.MODEL_PROVIDER_ERROR: "Model provider error.", + + # ==================== 10 Memory / 记忆管理 ==================== + ErrorCode.MEMORY_NOT_FOUND: "Memory not found.", + ErrorCode.MEMORY_PREPARATION_FAILED: "Failed to prepare memory.", + ErrorCode.MEMORY_CONFIG_INVALID: "Memory configuration is invalid.", + + # ==================== 11 Profile / 个人信息 ==================== + ErrorCode.PROFILE_USER_NOT_FOUND: "User not found.", + ErrorCode.PROFILE_UPDATE_FAILED: "Profile update failed.", + ErrorCode.PROFILE_USER_ALREADY_EXISTS: "User already exists.", + ErrorCode.PROFILE_INVALID_CREDENTIALS: "Invalid username or password.", + + # ==================== 12 TenantResource / 租户资源 ==================== + ErrorCode.TENANT_NOT_FOUND: "Tenant not found.", + ErrorCode.TENANT_DISABLED: "Tenant is disabled.", + ErrorCode.TENANT_CONFIG_ERROR: "Tenant configuration error.", + ErrorCode.TENANT_RESOURCE_EXCEEDED: "Tenant resource exceeded.", + + # ==================== 13 External / 外部服务 ==================== + ErrorCode.DATAMATE_CONNECTION_FAILED: "Failed to connect to DataMate service.", + ErrorCode.DIFY_SERVICE_ERROR: "Dify service error.", + ErrorCode.DIFY_CONFIG_INVALID: "Dify configuration invalid. Please check URL and API key format.", + ErrorCode.DIFY_CONNECTION_ERROR: "Failed to connect to Dify. Please check network connection and URL.", + ErrorCode.DIFY_RESPONSE_ERROR: "Failed to parse Dify response. Please check API URL.", + ErrorCode.DIFY_AUTH_ERROR: "Dify authentication failed. Please check your API key.", + ErrorCode.DIFY_RATE_LIMIT: "Dify API rate limit exceeded. Please try again later.", + ErrorCode.ME_CONNECTION_FAILED: "Failed to connect to ME service.", + + # ==================== 14 Northbound / 北向接口 ==================== + ErrorCode.NORTHBOUND_REQUEST_FAILED: "Northbound request failed.", + ErrorCode.NORTHBOUND_CONFIG_INVALID: "Invalid northbound configuration.", + + # ==================== 15 DataProcess / 数据处理 ==================== + ErrorCode.DATAPROCESS_TASK_FAILED: "Data process task failed.", + ErrorCode.DATAPROCESS_PARSE_FAILED: "Data parsing failed.", + + # ==================== 99 System / 系统级 ==================== + # 01 - System Errors + ErrorCode.SYSTEM_UNKNOWN_ERROR: "An unknown error occurred. Please try again later.", + ErrorCode.SYSTEM_SERVICE_UNAVAILABLE: "Service is temporarily unavailable. Please try again later.", + ErrorCode.SYSTEM_DATABASE_ERROR: "Database operation failed. Please try again later.", + ErrorCode.SYSTEM_TIMEOUT: "Operation timed out. Please try again later.", + ErrorCode.SYSTEM_INTERNAL_ERROR: "Internal server error. Please try again later.", + # 02 - Config + ErrorCode.CONFIG_NOT_FOUND: "Configuration not found.", + ErrorCode.CONFIG_UPDATE_FAILED: "Configuration update failed.", + } + + @classmethod + def get_message(cls, error_code: ErrorCode) -> str: + """Get error message by error code.""" + return cls._MESSAGES.get(error_code, "An error occurred. Please try again later.") + + @classmethod + def get_message_with_code(cls, error_code: ErrorCode) -> tuple[int, str]: + """Get error code and message as tuple.""" + return (error_code.value, cls.get_message(error_code)) + + @classmethod + def get_all_messages(cls) -> dict: + """Get all error code to message mappings.""" + return {code.value: msg for code, msg in cls._MESSAGES.items()} diff --git a/backend/consts/exceptions.py b/backend/consts/exceptions.py index 94b1f770d..e9d270673 100644 --- a/backend/consts/exceptions.py +++ b/backend/consts/exceptions.py @@ -1,7 +1,74 @@ """ Custom exception classes for the application. + +This module provides two types of exceptions: + +1. New Framework (with ErrorCode): + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + raise AppException(ErrorCode.COMMON_VALIDATION_ERROR, "Validation failed") + raise AppException(ErrorCode.MCP_CONNECTION_FAILED, "Connection timeout", details={"host": "localhost"}) + +2. Legacy Framework (simple exceptions): + from consts.exceptions import ValidationError, NotFoundException, MCPConnectionError + + raise ValidationError("Tenant name cannot be empty") + raise NotFoundException("Tenant 123 not found") + raise MCPConnectionError("MCP connection failed") + +The exception handler automatically maps legacy exception class names to ErrorCode. """ +from .error_code import ErrorCode, ERROR_CODE_HTTP_STATUS +from .error_message import ErrorMessage + + +# ==================== New Framework: AppException with ErrorCode ==================== + +class AppException(Exception): + """ + Base application exception with ErrorCode. + + Usage: + raise AppException(ErrorCode.COMMON_VALIDATION_ERROR, "Validation failed") + raise AppException(ErrorCode.MCP_CONNECTION_FAILED, "Timeout", details={"host": "x"}) + """ + + def __init__(self, error_code: ErrorCode, message: str = None, details: dict = None): + self.error_code = error_code + self.message = message or ErrorMessage.get_message(error_code) + self.details = details or {} + super().__init__(self.message) + + def to_dict(self) -> dict: + return { + "code": int(self.error_code.value), + "message": self.message, + "details": self.details if self.details else None + } + + @property + def http_status(self) -> int: + return ERROR_CODE_HTTP_STATUS.get(self.error_code, 500) + + +def raise_error(error_code: ErrorCode, message: str = None, details: dict = None): + """Raise an AppException with the given error code.""" + raise AppException(error_code, message, details) + + +# ==================== Legacy Framework: Simple Exception Classes ==================== +# These are simple exceptions that work with the old calling pattern. +# The exception handler automatically maps class names to ErrorCode. +# +# Usage (unchanged from before): +# raise ValidationError("Invalid input") +# raise NotFoundException("Resource not found") +# raise MCPConnectionError("Connection failed") +# +# These do NOT require ErrorCode - they are simple Exception subclasses. +# Exception handler will infer ErrorCode from class name. class AgentRunException(Exception): """Exception raised when agent run fails.""" @@ -58,7 +125,6 @@ class TimeoutException(Exception): pass - class ValidationError(Exception): """Raised when validation fails.""" pass @@ -70,7 +136,7 @@ class NotFoundException(Exception): class MEConnectionException(Exception): - """Raised when not found exception occurs.""" + """Raised when ME connection fails.""" pass @@ -112,3 +178,48 @@ class DuplicateError(Exception): class DataMateConnectionError(Exception): """Raised when DataMate connection fails or URL is not configured.""" pass + + +# ==================== Legacy Aliases (same as above, for compatibility) ==================== +# These are additional aliases that map to the same simple exception classes above. +# They provide backward compatibility for code that uses these names. + +# Common aliases +ParameterInvalidError = ValidationError +ForbiddenError = Exception # Generic fallback +ServiceUnavailableError = Exception # Generic fallback +DatabaseError = Exception # Generic fallback +TimeoutError = TimeoutException +UnknownError = Exception # Generic fallback + +# Domain specific aliases +UserNotFoundError = NotFoundException +UserAlreadyExistsError = DuplicateError +InvalidCredentialsError = UnauthorizedError + +TenantNotFoundError = NotFoundException +TenantDisabledError = Exception # Generic fallback + +AgentNotFoundError = NotFoundException +AgentDisabledError = Exception # Generic fallback + +ToolNotFoundError = NotFoundException + +ConversationNotFoundError = NotFoundException + +MemoryNotFoundError = NotFoundException +KnowledgeNotFoundError = NotFoundException + +ModelNotFoundError = NotFoundException + +# File aliases +FileNotFoundError = NotFoundException +FileUploadFailedError = Exception # Generic fallback +FileTooLargeError = Exception # Generic fallback + +# External service aliases +DifyServiceException = Exception # Generic fallback +ExternalAPIError = Exception # Generic fallback + +# Signature aliases +# SignatureValidationError already defined above diff --git a/backend/consts/model.py b/backend/consts/model.py index 358e6ee3f..c9a5e3b8c 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -277,6 +277,7 @@ class AgentInfoRequest(BaseModel): enabled_tool_ids: Optional[List[int]] = None related_agent_ids: Optional[List[int]] = None group_ids: Optional[List[int]] = None + ingroup_permission: Optional[str] = None version_no: int = 0 diff --git a/backend/database/db_models.py b/backend/database/db_models.py index a3ecb7120..36f475f53 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -226,6 +226,7 @@ class AgentInfo(TableBase): group_ids = Column(String, doc="Agent group IDs list") is_new = Column(Boolean, default=False, doc="Whether this agent is marked as new for the user") current_version_no = Column(Integer, nullable=True, doc="Current published version number. NULL means no version published yet") + ingroup_permission = Column(String(30), doc="In-group permission: EDIT, READ_ONLY, PRIVATE") class ToolInstance(TableBase): @@ -236,7 +237,12 @@ class ToolInstance(TableBase): __table_args__ = {"schema": SCHEMA} tool_instance_id = Column( - Integer, primary_key=True, nullable=False, doc="ID") + Integer, + Sequence("ag_tool_instance_t_tool_instance_id_seq", schema=SCHEMA), + primary_key=True, + nullable=False, + doc="ID" + ) tool_id = Column(Integer, doc="Tenant tool ID") agent_id = Column(Integer, doc="Agent ID") params = Column(JSON, doc="Parameter configuration") @@ -351,7 +357,7 @@ class AgentRelation(TableBase): __tablename__ = "ag_agent_relation_t" __table_args__ = {"schema": SCHEMA} - relation_id = Column(Integer, primary_key=True, autoincrement=True, nullable=False, doc="Relationship ID, primary key") + relation_id = Column(Integer, Sequence("ag_agent_relation_t_relation_id_seq", schema=SCHEMA), primary_key=True, nullable=False, doc="Relationship ID, primary key") selected_agent_id = Column(Integer, primary_key=True, doc="Selected agent ID") parent_agent_id = Column(Integer, doc="Parent agent ID") tenant_id = Column(String(100), doc="Tenant ID") diff --git a/backend/middleware/__init__.py b/backend/middleware/__init__.py new file mode 100644 index 000000000..937f10972 --- /dev/null +++ b/backend/middleware/__init__.py @@ -0,0 +1 @@ +# Backend middleware package diff --git a/backend/middleware/exception_handler.py b/backend/middleware/exception_handler.py new file mode 100644 index 000000000..14d9ebb38 --- /dev/null +++ b/backend/middleware/exception_handler.py @@ -0,0 +1,176 @@ +""" +Global exception handler middleware. + +This middleware provides centralized error handling for the FastAPI application. +It catches all exceptions and returns a standardized JSON response. +""" + +import logging +import traceback +import uuid +from typing import Callable + +from fastapi import Request, Response, HTTPException +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +from consts.error_code import ErrorCode, ERROR_CODE_HTTP_STATUS +from consts.error_message import ErrorMessage + +logger = logging.getLogger(__name__) + + +def _http_status_to_error_code(status_code: int) -> ErrorCode: + """Map HTTP status codes to internal error codes for backward compatibility.""" + mapping = { + 400: ErrorCode.COMMON_VALIDATION_ERROR, + 401: ErrorCode.COMMON_UNAUTHORIZED, + 403: ErrorCode.COMMON_FORBIDDEN, + 404: ErrorCode.COMMON_RESOURCE_NOT_FOUND, + 429: ErrorCode.COMMON_RATE_LIMIT_EXCEEDED, + 500: ErrorCode.SYSTEM_INTERNAL_ERROR, + 502: ErrorCode.SYSTEM_SERVICE_UNAVAILABLE, + 503: ErrorCode.SYSTEM_SERVICE_UNAVAILABLE, + } + return mapping.get(status_code, ErrorCode.SYSTEM_UNKNOWN_ERROR) + + +class ExceptionHandlerMiddleware(BaseHTTPMiddleware): + """ + Global exception handler middleware. + + This middleware catches all exceptions and returns a standardized response: + - For AppException: returns the error code and message + - For other exceptions: logs the error and returns a generic error response + """ + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + # Generate trace ID for request tracking + trace_id = str(uuid.uuid4()) + request.state.trace_id = trace_id + + try: + response = await call_next(request) + return response + except Exception as exc: + # Check if it's an AppException by looking for the error_code attribute + # This handles both import path variations (backend.consts.exceptions vs consts.exceptions) + if hasattr(exc, 'error_code'): + # This is an AppException - get http_status from mapping + logger.error( + f"[{trace_id}] AppException: {exc.error_code.value} - {exc.message}", + extra={"trace_id": trace_id, + "error_code": exc.error_code.value} + ) + + # Use HTTP status from error code mapping, default to 500 + # Try to get http_status property first, then fall back to ERROR_CODE_HTTP_STATUS mapping + if hasattr(exc, 'http_status'): + http_status = exc.http_status + else: + http_status = ERROR_CODE_HTTP_STATUS.get( + exc.error_code, 500) + + return JSONResponse( + status_code=http_status, + content={ + "code": int(exc.error_code.value), + "message": exc.message, + "trace_id": trace_id, + "details": exc.details if exc.details else None + } + ) + elif isinstance(exc, HTTPException): + # Handle FastAPI HTTPException for backward compatibility + # Map HTTP status codes to error codes + error_code = _http_status_to_error_code(exc.status_code) + + return JSONResponse( + status_code=exc.status_code, + content={ + "code": int(error_code.value), + "message": exc.detail, + "trace_id": trace_id + } + ) + else: + # Log the full exception with traceback + logger.error( + f"[{trace_id}] Unhandled exception: {str(exc)}", + exc_info=True, + extra={"trace_id": trace_id} + ) + + # Return generic error response with proper HTTP 500 status + # Using mixed mode: HTTP status code + business error code + return JSONResponse( + status_code=500, + content={ + "code": ErrorCode.SYSTEM_INTERNAL_ERROR.value, + "message": ErrorMessage.get_message(ErrorCode.SYSTEM_INTERNAL_ERROR), + "trace_id": trace_id, + "details": None + } + ) + + +def create_error_response( + error_code: ErrorCode, + message: str = None, + trace_id: str = None, + details: dict = None, + http_status: int = None +) -> JSONResponse: + """ + Create a standardized error response with mixed mode (HTTP status + business error code). + + Args: + error_code: The error code + message: Optional custom message (defaults to standard message) + trace_id: Optional trace ID for tracking + details: Optional additional details + http_status: Optional HTTP status code (defaults to mapping from error_code) + + Returns: + JSONResponse with standardized error format + """ + # Use provided http_status or get from error code mapping + status = http_status if http_status else ERROR_CODE_HTTP_STATUS.get( + error_code, 500) + + return JSONResponse( + status_code=status, + content={ + "code": int(error_code.value), + "message": message or ErrorMessage.get_message(error_code), + "trace_id": trace_id, + "details": details + } + ) + + +def create_success_response( + data: any = None, + message: str = "OK", + trace_id: str = None +) -> JSONResponse: + """ + Create a standardized success response. + + Args: + data: The response data + message: Optional success message + trace_id: Optional trace ID for tracking + + Returns: + JSONResponse with standardized success format + """ + return JSONResponse( + status_code=200, + content={ + "code": 0, # 0 indicates success + "message": message, + "data": data, + "trace_id": trace_id + } + ) diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py index 4d15a3a9c..164b2965e 100644 --- a/backend/services/agent_service.py +++ b/backend/services/agent_service.py @@ -17,7 +17,7 @@ from agents.preprocess_manager import preprocess_manager from services.agent_version_service import publish_version_impl from consts.const import MEMORY_SEARCH_START_MSG, MEMORY_SEARCH_DONE_MSG, MEMORY_SEARCH_FAIL_MSG, TOOL_TYPE_MAPPING, \ - LANGUAGE, MESSAGE_ROLE, MODEL_CONFIG_MAPPING, CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT, PERMISSION_READ + LANGUAGE, MESSAGE_ROLE, MODEL_CONFIG_MAPPING, CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT, PERMISSION_READ, PERMISSION_PRIVATE from consts.exceptions import MemoryPreparationException from consts.model import ( AgentInfoRequest, @@ -823,7 +823,8 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str = "constraint_prompt": request.constraint_prompt, "few_shots_prompt": request.few_shots_prompt, "enabled": request.enabled if request.enabled is not None else True, - "group_ids": convert_list_to_string(request.group_ids) if request.group_ids else user_group_ids + "group_ids": convert_list_to_string(request.group_ids) if request.group_ids else user_group_ids, + "ingroup_permission": request.ingroup_permission }, tenant_id=tenant_id, user_id=user_id) agent_id = created["agent_id"] else: @@ -1139,7 +1140,8 @@ async def import_agent_impl( for sub_agent_id in managed_agents: insert_related_agent(parent_agent_id=mapping_agent_id[need_import_agent_id], child_agent_id=mapping_agent_id[sub_agent_id], - tenant_id=tenant_id) + tenant_id=tenant_id, + user_id=user_id) else: # Current agent still has sub-agents that haven't been imported agent_stack.append(need_import_agent_id) @@ -1325,7 +1327,10 @@ async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]: # Apply visibility filter for DEV/USER based on group overlap if not can_edit_all: agent_group_ids = set(convert_string_to_list(agent.get("group_ids"))) - if len(user_group_ids.intersection(agent_group_ids)) == 0 and user_id != agent.get("created_by"): + ingroup_permission = agent.get("ingroup_permission") + is_creator = str(agent.get("created_by")) == str(user_id) + # Hide agent if: no group overlap OR (ingroup_permission is PRIVATE AND user is not creator) + if len(user_group_ids.intersection(agent_group_ids)) == 0 or (ingroup_permission == PERMISSION_PRIVATE and not is_creator): continue # Use shared availability check function @@ -1358,7 +1363,14 @@ async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]: model_cache[model_id] = get_model_by_model_id(model_id, tenant_id) model_info = model_cache.get(model_id) - permission = PERMISSION_EDIT if can_edit_all or str(agent.get("created_by")) == str(user_id) else PERMISSION_READ + # Permission logic: + # - If creator or can_edit_all: PERMISSION_EDIT + # - Otherwise: use ingroup_permission, default to PERMISSION_READ if None + if can_edit_all or str(agent.get("created_by")) == str(user_id): + permission = PERMISSION_EDIT + else: + ingroup_permission = agent.get("ingroup_permission") + permission = ingroup_permission if ingroup_permission is not None else PERMISSION_READ simple_agent_list.append({ "agent_id": agent["agent_id"], diff --git a/backend/services/agent_version_service.py b/backend/services/agent_version_service.py index 062a0402d..554b3a6d1 100644 --- a/backend/services/agent_version_service.py +++ b/backend/services/agent_version_service.py @@ -187,8 +187,8 @@ def get_version_detail_impl( if key != 'current_version_no': result[key] = value - # Add tools - result['tools'] = tools_snapshot + # Add tools (only enabled tools) + result['tools'] = [t for t in tools_snapshot if t.get('enabled', True)] # Extract sub_agent_id_list from relations result['sub_agent_id_list'] = [r['selected_agent_id'] for r in relations_snapshot] @@ -578,7 +578,8 @@ def _get_version_detail_or_draft( if key != 'current_version_no': result[key] = value - result['tools'] = tools_draft + # Add tools (only enabled tools) + result['tools'] = [t for t in tools_draft if t.get('enabled', True)] result['sub_agent_id_list'] = [r['selected_agent_id'] for r in relations_draft] result['version'] = { 'version_name': 'Draft', diff --git a/backend/services/dify_service.py b/backend/services/dify_service.py index 14e3a4d6f..76c297292 100644 --- a/backend/services/dify_service.py +++ b/backend/services/dify_service.py @@ -12,6 +12,8 @@ import httpx +from consts.error_code import ErrorCode +from consts.exceptions import AppException from nexent.utils.http_client_manager import http_client_manager logger = logging.getLogger("dify_service") @@ -67,11 +69,21 @@ def fetch_dify_datasets_impl( """ # Validate inputs if not dify_api_base or not isinstance(dify_api_base, str): - raise ValueError( - "dify_api_base is required and must be a non-empty string") + raise AppException( + ErrorCode.DIFY_CONFIG_INVALID, + "Dify API URL is required and must be a non-empty string" + ) + + # Validate URL format + if not (dify_api_base.startswith("http://") or dify_api_base.startswith("https://")): + raise AppException( + ErrorCode.DIFY_CONFIG_INVALID, + "Dify API URL must start with http:// or https://" + ) if not api_key or not isinstance(api_key, str): - raise ValueError("api_key is required and must be a non-empty string") + raise AppException(ErrorCode.DIFY_CONFIG_INVALID, + "Dify API key is required and must be a non-empty string") # Normalize API base URL api_base = dify_api_base.rstrip("/") @@ -95,7 +107,7 @@ def fetch_dify_datasets_impl( # Use shared HttpClientManager for connection pooling client = http_client_manager.get_sync_client( base_url=api_base, - timeout=30.0, + timeout=10.0, verify_ssl=False ) response = client.get(url, headers=headers) @@ -157,15 +169,35 @@ def fetch_dify_datasets_impl( except httpx.RequestError as e: logger.error(f"Dify API request failed: {str(e)}") - raise Exception(f"Dify API request failed: {str(e)}") + raise AppException(ErrorCode.DIFY_CONNECTION_ERROR, + f"Dify API request failed: {str(e)}") except httpx.HTTPStatusError as e: - logger.error(f"Dify API HTTP error: {str(e)}") - raise Exception(f"Dify API HTTP error: {str(e)}") + logger.error( + f"Dify API HTTP error: {str(e)}, status_code: {e.response.status_code}") + # Map HTTP status to specific error code + if e.response.status_code == 401: + logger.error("Raising DIFY_AUTH_ERROR for 401 error") + raise AppException(ErrorCode.DIFY_AUTH_ERROR, + f"Dify authentication failed: {str(e)}") + elif e.response.status_code == 403: + logger.error("Raising DIFY_AUTH_ERROR for 403 error") + raise AppException(ErrorCode.DIFY_AUTH_ERROR, + f"Dify access forbidden: {str(e)}") + elif e.response.status_code == 429: + logger.error("Raising DIFY_RATE_LIMIT for 429 error") + raise AppException(ErrorCode.DIFY_RATE_LIMIT, + f"Dify API rate limit exceeded: {str(e)}") + else: + logger.error( + f"Raising DIFY_SERVICE_ERROR for status {e.response.status_code}") + raise AppException(ErrorCode.DIFY_SERVICE_ERROR, + f"Dify API HTTP error {e.response.status_code}: {str(e)}") except json.JSONDecodeError as e: logger.error(f"Failed to parse Dify API response: {str(e)}") - raise Exception(f"Failed to parse Dify API response: {str(e)}") + raise AppException(ErrorCode.DIFY_RESPONSE_ERROR, + f"Failed to parse Dify API response: {str(e)}") except KeyError as e: logger.error( f"Unexpected Dify API response format: missing key {str(e)}") - raise Exception( - f"Unexpected Dify API response format: missing key {str(e)}") + raise AppException(ErrorCode.DIFY_RESPONSE_ERROR, + f"Unexpected Dify API response format: missing key {str(e)}") diff --git a/docker/init.sql b/docker/init.sql index 9c3fac948..02e99632c 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -318,6 +318,7 @@ CREATE TABLE IF NOT EXISTS nexent.ag_tenant_agent_t ( provide_run_summary BOOLEAN DEFAULT FALSE, version_no INTEGER DEFAULT 0 NOT NULL, current_version_no INTEGER NULL, + ingroup_permission VARCHAR(30), create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_by VARCHAR(100), @@ -371,6 +372,7 @@ COMMENT ON COLUMN nexent.ag_tenant_agent_t.delete_flag IS 'Whether it is deleted COMMENT ON COLUMN nexent.ag_tenant_agent_t.is_new IS 'Whether this agent is marked as new for the user'; COMMENT ON COLUMN nexent.ag_tenant_agent_t.version_no IS 'Version number. 0 = draft/editing state, >=1 = published snapshot'; COMMENT ON COLUMN nexent.ag_tenant_agent_t.current_version_no IS 'Current published version number. NULL means no version published yet'; +COMMENT ON COLUMN nexent.ag_tenant_agent_t.ingroup_permission IS 'In-group permission: EDIT, READ_ONLY, PRIVATE'; -- Create index for is_new queries CREATE INDEX IF NOT EXISTS idx_ag_tenant_agent_t_is_new @@ -380,7 +382,7 @@ WHERE delete_flag = 'N'; -- Create the ag_tool_instance_t table in the nexent schema CREATE TABLE IF NOT EXISTS nexent.ag_tool_instance_t ( - tool_instance_id INTEGER NOT NULL, + tool_instance_id SERIAL NOT NULL, tool_id INTEGER, agent_id INTEGER, params JSON, @@ -564,7 +566,7 @@ COMMENT ON COLUMN nexent.user_tenant_t.delete_flag IS 'Delete flag, Y/N'; -- Create the ag_agent_relation_t table in the nexent schema CREATE TABLE IF NOT EXISTS nexent.ag_agent_relation_t ( - relation_id INTEGER NOT NULL, + relation_id SERIAL NOT NULL, selected_agent_id INTEGER, parent_agent_id INTEGER, tenant_id VARCHAR(100), diff --git a/docker/sql/v1.8.0.2_0227_add_ingroup_permission_to_ag_tenant_agent_t.sql b/docker/sql/v1.8.0.2_0227_add_ingroup_permission_to_ag_tenant_agent_t.sql new file mode 100644 index 000000000..38ae17814 --- /dev/null +++ b/docker/sql/v1.8.0.2_0227_add_ingroup_permission_to_ag_tenant_agent_t.sql @@ -0,0 +1,10 @@ +-- Migration: Add ingroup_permission column to ag_tenant_agent_t table +-- Date: 2025-03-02 +-- Description: Add ingroup_permission field to support in-group permission control for agents + +-- Add ingroup_permission column to ag_tenant_agent_t table +ALTER TABLE nexent.ag_tenant_agent_t +ADD COLUMN IF NOT EXISTS ingroup_permission VARCHAR(30) DEFAULT NULL; + +-- Add comment to the column +COMMENT ON COLUMN nexent.ag_tenant_agent_t.ingroup_permission IS 'In-group permission: EDIT, READ_ONLY, PRIVATE'; diff --git a/docker/sql/v1.8.0.2_0302_add_tool_instance_id_seq_and_agent_relation_id_seq.sql b/docker/sql/v1.8.0.2_0302_add_tool_instance_id_seq_and_agent_relation_id_seq.sql new file mode 100644 index 000000000..06fde6435 --- /dev/null +++ b/docker/sql/v1.8.0.2_0302_add_tool_instance_id_seq_and_agent_relation_id_seq.sql @@ -0,0 +1,14 @@ +-- Step 1: Create sequence for auto-increment +CREATE SEQUENCE IF NOT EXISTS "nexent"."ag_tool_instance_t_tool_instance_id_seq" +INCREMENT 1 +MINVALUE 1 +MAXVALUE 2147483647 +START 1 +CACHE 1; + +CREATE SEQUENCE IF NOT EXISTS "nexent"."ag_agent_relation_t_relation_id_seq" +INCREMENT 1 +MINVALUE 1 +MAXVALUE 2147483647 +START 1 +CACHE 1; diff --git a/frontend/app/[locale]/agents/AgentVersionCard.tsx b/frontend/app/[locale]/agents/AgentVersionCard.tsx index aedf0b8eb..caf27b5f3 100644 --- a/frontend/app/[locale]/agents/AgentVersionCard.tsx +++ b/frontend/app/[locale]/agents/AgentVersionCard.tsx @@ -28,6 +28,7 @@ import { DescriptionsProps, Modal, Dropdown, + Tooltip, theme } from "antd"; import { ExclamationCircleFilled } from '@ant-design/icons'; @@ -149,6 +150,13 @@ export function VersionCardItem({ const { tools: toolList } = useToolList(); const { agents: agentList } = useAgentList(user?.tenantId ?? null); + // Get current agent's permission from agent list + const currentAgent = useMemo(() => { + return agentList.find((a: Agent) => a.id === String(agentId)); + }, [agentList, agentId]); + + const isReadOnly = currentAgent?.permission === "READ_ONLY"; + // Modal state const [compareModalOpen, setCompareModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); @@ -366,15 +374,28 @@ export function VersionCardItem({ items: [ { key: 'edit', - label: t("common.edit"), + label: isReadOnly ? ( + + {t("common.edit")} + + ) : ( + t("common.edit") + ), icon: , + disabled: isReadOnly, onClick: () => setEditModalOpen(true) }, { key: 'rollback', - label: t("agent.version.rollback"), + label: isReadOnly ? ( + + {t("agent.version.rollback")} + + ) : ( + t("agent.version.rollback") + ), icon: , - disabled: isCurrentVersion || version.status.toLowerCase() === "disabled", + disabled: isReadOnly || isCurrentVersion || version.status.toLowerCase() === "disabled", onClick: handleRollbackClick }, { @@ -382,9 +403,15 @@ export function VersionCardItem({ }, { key: 'delete', - label: t("common.delete"), + label: isReadOnly ? ( + + {t("common.delete")} + + ) : ( + t("common.delete") + ), icon: , - disabled: isCurrentVersion, + disabled: isReadOnly || isCurrentVersion, danger: true, onClick: handleDeleteClick, }, diff --git a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx index 91ce11d33..0cd276139 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx @@ -75,7 +75,15 @@ export default function ToolManagement({ const { t } = useTranslation("common"); const queryClient = useQueryClient(); - const editable = currentAgentId || isCreatingMode; + // Get current agent permission from store + const currentAgentPermission = useAgentConfigStore( + (state) => state.currentAgentPermission + ); + + // Check if current agent is read-only (only when agent is selected and permission is READ_ONLY) + const isReadOnly = !isCreatingMode && currentAgentId !== undefined && currentAgentPermission === "READ_ONLY"; + + const editable = (currentAgentId || isCreatingMode) && !isReadOnly; // Get state from store const originalSelectedTools = useAgentConfigStore( @@ -423,8 +431,16 @@ export default function ToolManagement({ ); const isDisabledDueToVlm = isToolDisabledDueToVlm(tool.name, isVlmConfigured); const isDisabledDueToEmbedding = isToolDisabledDueToEmbedding(tool.name, isEmbeddingConfigured); - const isDisabled = isDisabledDueToVlm || isDisabledDueToEmbedding; - return ( + const isDisabled = isDisabledDueToVlm || isDisabledDueToEmbedding || isReadOnly; + // Tooltip priority: permission > VLM > Embedding + const tooltipTitle = isReadOnly + ? t("agent.noEditPermission") + : isDisabledDueToVlm + ? t("toolPool.vlmDisabledTooltip") + : isDisabledDueToEmbedding + ? t("toolPool.embeddingDisabledTooltip") + : undefined; + const toolCard = (
); + return tooltipTitle ? ( + + {toolCard} + + ) : ( + toolCard + ); })} ), @@ -513,8 +536,16 @@ export default function ToolManagement({ const isSelected = originalSelectedToolIdsSet.has(tool.id); const isDisabledDueToVlm = isToolDisabledDueToVlm(tool.name, isVlmConfigured); const isDisabledDueToEmbedding = isToolDisabledDueToEmbedding(tool.name, isEmbeddingConfigured); - const isDisabled = isDisabledDueToVlm || isDisabledDueToEmbedding; - return ( + const isDisabled = isDisabledDueToVlm || isDisabledDueToEmbedding || isReadOnly; + // Tooltip priority: permission > VLM > Embedding + const tooltipTitle = isReadOnly + ? t("agent.noEditPermission") + : isDisabledDueToVlm + ? t("toolPool.vlmDisabledTooltip") + : isDisabledDueToEmbedding + ? t("toolPool.embeddingDisabledTooltip") + : undefined; + const toolCard = (
); + return tooltipTitle ? ( + + {toolCard} + + ) : ( + toolCard + ); })} )} diff --git a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx index d3a3eab17..27129594d 100644 --- a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx +++ b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx @@ -20,16 +20,11 @@ import type { TabsProps } from "antd"; import { Zap, Maximize2 } from "lucide-react"; import log from "@/lib/logger"; -import { EditableAgent } from "@/stores/agentConfigStore"; import { AgentProfileInfo, AgentBusinessInfo } from "@/types/agentConfig"; import { configService } from "@/services/configService"; import { ConfigStore } from "@/lib/config"; +import { useAgentList } from "@/hooks/agent/useAgentList"; import { - checkAgentName, - checkAgentDisplayName, -} from "@/services/agentConfigService"; -import { - NAME_CHECK_STATUS, GENERATE_PROMPT_STREAM_TYPES, } from "@/const/agentConfig"; import { generatePromptStream } from "@/services/promptService"; @@ -76,11 +71,24 @@ export default function AgentGenerateDetail({ const { data: tenantData } = useTenantList(); const tenantId = user?.tenantId ?? tenantData?.data?.[0]?.tenant_id ?? null; const { data: groupData } = useGroupList(tenantId, 1, 100); + + // Agent list for name uniqueness validation (use local data instead of API call) + const { agents: agentList } = useAgentList(tenantId); const groups = groupData?.groups || []; // State management const [activeTab, setActiveTab] = useState("agent-info"); + // Local state to track generated content (fix for stream data not syncing with form state) + const [generatedContent, setGeneratedContent] = useState({ + dutyPrompt: "", + constraintPrompt: "", + fewShotsPrompt: "", + agentName: "", + agentDescription: "", + agentDisplayName: "", + }); + // Modal states const [expandModalOpen, setExpandModalOpen] = useState(false); const [expandModalType, setExpandModalType] = useState<'duty' | 'constraint' | 'few-shots' | null>(null); @@ -213,6 +221,7 @@ export default function AgentGenerateDetail({ mainAgentMaxStep: editedAgent.max_step || 5, agentDescription: editedAgent.description || "", group_ids: normalizeNumberArray(editedAgent.group_ids || []), + ingroup_permission: editedAgent.ingroup_permission || "READ_ONLY", dutyPrompt: editedAgent.duty_prompt || "", constraintPrompt: editedAgent.constraint_prompt || "", fewShotsPrompt: editedAgent.few_shots_prompt || "", @@ -250,7 +259,7 @@ export default function AgentGenerateDetail({ }); } - }, [currentAgentId, defaultLlmModel?.id, isCreatingMode]); + }, [currentAgentId, defaultLlmModel?.id, isCreatingMode, editedAgent.ingroup_permission]); // Default to selecting all groups when creating a new agent. // Only applies when groups are loaded and no group is selected yet. @@ -413,45 +422,38 @@ export default function AgentGenerateDetail({ } }; - // Custom validator for agent name uniqueness - const validateAgentNameUnique = async (_: any, value: string) => { + // Generic validator for agent field uniqueness - use local agent list instead of API call + const validateAgentFieldUnique = async ( + _: any, + value: string, + fieldName: "name" | "display_name", + errorKey: "nameExists" | "displayNameExists" + ) => { if (!value) return Promise.resolve(); - try { - const result = await checkAgentName(value, currentAgentId || undefined); - if (result.status === NAME_CHECK_STATUS.EXISTS_IN_TENANT) { - return Promise.reject( - new Error(t("agent.error.nameExists", { name: value })) - ); - } - return Promise.resolve(); - } catch (error) { + // Check if field value already exists in local agent list (excluding current agent) + const isDuplicated = agentList?.some( + (agent: { name?: string; display_name?: string; id?: string | number }) => + (agent as any)[fieldName] === value && + Number(agent.id) !== currentAgentId + ); + + if (isDuplicated) { return Promise.reject( - new Error(t("agent.error.displayNameExists", value)) + new Error(t(`agent.error.${errorKey}`, { [fieldName]: value })) ); } + return Promise.resolve(); + }; + + // Custom validator for agent name uniqueness + const validateAgentNameUnique = async (_: any, value: string) => { + return validateAgentFieldUnique(_, value, "name", "nameExists"); }; // Custom validator for agent display name uniqueness const validateAgentDisplayNameUnique = async (_: any, value: string) => { - if (!value) return Promise.resolve(); - - try { - const result = await checkAgentDisplayName( - value, - currentAgentId || undefined - ); - if (result.status === NAME_CHECK_STATUS.EXISTS_IN_TENANT) { - return Promise.reject( - new Error(t("agent.error.displayNameExists", { displayName: value })) - ); - } - return Promise.resolve(); - } catch (error) { - return Promise.reject( - new Error(t("agent.error.displayNameExists", value)) - ); - } + return validateAgentFieldUnique(_, value, "display_name", "displayNameExists"); }; const handleGenerateAgent = async () => { @@ -483,10 +485,10 @@ export default function AgentGenerateDetail({ sub_agent_ids: editedAgent.sub_agent_id_list, tool_ids: Array.isArray(editedAgent.tools) ? editedAgent.tools.map((tool: any) => - typeof tool === "object" && tool.id !== undefined - ? tool.id - : tool - ) + typeof tool === "object" && tool.id !== undefined + ? tool.id + : tool + ) : [], }, (data) => { @@ -495,27 +497,50 @@ export default function AgentGenerateDetail({ switch (data.type) { case GENERATE_PROMPT_STREAM_TYPES.DUTY: form.setFieldsValue({ dutyPrompt: data.content }); + setGeneratedContent((prev) => ({ + ...prev, + dutyPrompt: data.content, + })); break; case GENERATE_PROMPT_STREAM_TYPES.CONSTRAINT: form.setFieldsValue({ constraintPrompt: data.content }); + setGeneratedContent((prev) => ({ + ...prev, + constraintPrompt: data.content, + })); break; case GENERATE_PROMPT_STREAM_TYPES.FEW_SHOTS: - form.setFieldsValue({ fewShotsPrompt: data.content }); + setGeneratedContent((prev) => ({ + ...prev, + fewShotsPrompt: data.content, + })); break; case GENERATE_PROMPT_STREAM_TYPES.AGENT_VAR_NAME: if (!form.getFieldValue("agentName")?.trim()) { form.setFieldsValue({ agentName: data.content }); } + setGeneratedContent((prev) => ({ + ...prev, + agentName: data.content, + })); break; case GENERATE_PROMPT_STREAM_TYPES.AGENT_DESCRIPTION: form.setFieldsValue({ agentDescription: data.content }); + setGeneratedContent((prev) => ({ + ...prev, + agentDescription: data.content, + })); break; case GENERATE_PROMPT_STREAM_TYPES.AGENT_DISPLAY_NAME: // Only update if current agent display name is empty if (!form.getFieldValue("agentDisplayName")?.trim()) { form.setFieldsValue({ agentDisplayName: data.content }); } + setGeneratedContent((prev) => ({ + ...prev, + agentDisplayName: data.content, + })); break; } }, @@ -526,22 +551,34 @@ export default function AgentGenerateDetail({ }, () => { // After generation completes, get all form values and update parent component state + // Use generatedContent state as fallback to ensure we get the streamed data const formValues = form.getFieldsValue(); const profileUpdates: AgentProfileInfo = { - name: formValues.agentName, - display_name: formValues.agentDisplayName, + name: generatedContent.agentName || formValues.agentName, + display_name: generatedContent.agentDisplayName || formValues.agentDisplayName, author: formValues.agentAuthor, model: formValues.mainAgentModel, max_step: formValues.mainAgentMaxStep, - description: formValues.agentDescription, - duty_prompt: formValues.dutyPrompt, - constraint_prompt: formValues.constraintPrompt, - few_shots_prompt: formValues.fewShotsPrompt, + description: generatedContent.agentDescription || formValues.agentDescription, + duty_prompt: generatedContent.dutyPrompt || formValues.dutyPrompt, + constraint_prompt: generatedContent.constraintPrompt || formValues.constraintPrompt, + few_shots_prompt: generatedContent.fewShotsPrompt || formValues.fewShotsPrompt, + ingroup_permission: formValues.ingroup_permission || "READ_ONLY", }; // Update profile info in global agent config store updateProfileInfo(profileUpdates); + // Reset generated content state after updating + setGeneratedContent({ + dutyPrompt: "", + constraintPrompt: "", + fewShotsPrompt: "", + agentName: "", + agentDescription: "", + agentDisplayName: "", + }); + message.success(t("businessLogic.config.message.generateSuccess")); setIsGenerating(false); } @@ -611,6 +648,7 @@ export default function AgentGenerateDetail({ }, { validator: validateAgentNameUnique }, ]} + validateTrigger={["onBlur"]} className="mb-3" > + + +