diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 793f243a8..a3a924020 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,2 @@ # These owners will be the default owners for everything in the repo -* @Phinease - -doc/docs/zh/opensource-memorial-wall.md @WMC001 -doc/docs/en/opensource-memorial-wall.md @WMC001 \ No newline at end of file +* @Phinease @WMC001 diff --git a/.github/workflows/sdk_publish.yml b/.github/workflows/sdk_publish.yml new file mode 100644 index 000000000..1e5759277 --- /dev/null +++ b/.github/workflows/sdk_publish.yml @@ -0,0 +1,44 @@ +name: Publish SDK to PyPI + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to publish (leave empty to use pyproject.toml version)' + required: false + type: string + +jobs: + build-and-publish: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build + + - name: Update version if specified + if: ${{ inputs.version != '' }} + working-directory: sdk + run: | + sed -i "s/^version = .*/version = \"${{ inputs.version }}\"/" pyproject.toml + + - name: Build package + working-directory: sdk + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: sdk/dist/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 906d04740..1809918b2 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -61,7 +61,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -[chenshuangrui@huawei.com]. +[wanmingchen1@huawei.com]. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 79ece2a89..2f0ebc0b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -162,7 +162,7 @@ To contribute: Stuck or have questions? We're here to help! Reach out to us via: - **GitHub Issues**: Open an issue for discussion. - **Discord**: Join our [Nexent Community](https://discord.gg/YXH5C8SQ) for real-time chat. -- **Email**: Drop us a line at [chenshuangrui@huawei.com](mailto:chenshuangrui@huawei.com). +- **Email**: Drop us a line at [wanmingchen1@huawei.com](mailto:wanmingchen1@huawei.com). ## 🎉 Celebrate Your Contribution! diff --git a/LICENSE b/LICENSE index 3f8f27668..29da544d2 100644 --- a/LICENSE +++ b/LICENSE @@ -7,7 +7,7 @@ Nexent is permitted to be used commercially, including as a backend service for a. Multi-tenant SaaS service: Unless explicitly authorized by Nexent in writing, you may not use the Nexent source code to operate a multi-tenant SaaS service. b. LOGO and copyright information: In the process of using Nexent's frontend, you may not remove or modify the LOGO or copyright information in the Nexent console or applications. This restriction is inapplicable to uses of Nexent that do not involve its frontend. -Please contact chenshuangrui@huawei.com by email to inquire about licensing matters. +Please contact zhenggaoqi@huawei.com by email to inquire about licensing matters. As a contributor, you should agree that: diff --git a/backend/apps/config_app.py b/backend/apps/config_app.py index eb2c824c1..88be72d55 100644 --- a/backend/apps/config_app.py +++ b/backend/apps/config_app.py @@ -10,7 +10,6 @@ from apps.file_management_app import file_management_config_router as file_manager_router from apps.image_app import router as proxy_router from apps.knowledge_summary_app import router as summary_router -from apps.me_model_managment_app import router as me_model_manager_router from apps.mock_user_management_app import router as mock_user_management_router from apps.model_managment_app import router as model_manager_router from apps.prompt_app import router as prompt_router @@ -37,7 +36,6 @@ allow_headers=["*"], # Allows all headers ) -app.include_router(me_model_manager_router) app.include_router(model_manager_router) app.include_router(config_sync_router) app.include_router(agent_router) diff --git a/backend/apps/me_model_managment_app.py b/backend/apps/me_model_managment_app.py deleted file mode 100644 index d7055474f..000000000 --- a/backend/apps/me_model_managment_app.py +++ /dev/null @@ -1,47 +0,0 @@ -import logging -from http import HTTPStatus - -from fastapi import APIRouter, Query, HTTPException -from fastapi.responses import JSONResponse - -from consts.exceptions import MEConnectionException, TimeoutException -from services.me_model_management_service import check_me_variable_set, check_me_connectivity - -router = APIRouter(prefix="/me") - - -@router.get("/healthcheck") -async def check_me_health(timeout: int = Query(default=30, description="Timeout in seconds")): - """ - Health check for ModelEngine platform by actually calling the API. - Returns connectivity status based on actual API response. - """ - try: - # First check if environment variables are configured - if not await check_me_variable_set(): - return JSONResponse( - status_code=HTTPStatus.OK, - content={ - "connectivity": False, - "message": "ModelEngine platform environment variables not configured. Healthcheck skipped.", - } - ) - - # Then check actual connectivity - await check_me_connectivity(timeout) - return JSONResponse( - status_code=HTTPStatus.OK, - content={ - "connectivity": True, - "message": "ModelEngine platform connected successfully.", - } - ) - except MEConnectionException as e: - logging.error(f"ModelEngine healthcheck failed: {str(e)}") - raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=f"ModelEngine connection failed: {str(e)}") - except TimeoutException as e: - logging.error(f"ModelEngine healthcheck timeout: {str(e)}") - raise HTTPException(status_code=HTTPStatus.REQUEST_TIMEOUT, detail="ModelEngine connection timeout.") - except Exception as e: - logging.error(f"ModelEngine healthcheck failed with unknown error: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"ModelEngine healthcheck failed: {str(e)}") diff --git a/backend/apps/remote_mcp_app.py b/backend/apps/remote_mcp_app.py index 65aa92b01..a6b582889 100644 --- a/backend/apps/remote_mcp_app.py +++ b/backend/apps/remote_mcp_app.py @@ -1,11 +1,11 @@ import logging from typing import Optional -from fastapi import APIRouter, Header, HTTPException +from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form from fastapi.responses import JSONResponse from http import HTTPStatus -from consts.const import MCP_DOCKER_IMAGE +from consts.const import NEXENT_MCP_DOCKER_IMAGE, ENABLE_UPLOAD_IMAGE from consts.exceptions import MCPConnectionError, MCPNameIllegal, MCPContainerError from consts.model import MCPConfigRequest from services.remote_mcp_service import ( @@ -14,7 +14,9 @@ get_remote_mcp_server_list, check_mcp_health_and_update_db, delete_mcp_by_container_id, + upload_and_start_mcp_image, ) +from database.remote_mcp_db import check_mcp_name_exists from services.tool_configuration_service import get_tool_from_remote_mcp_server from services.mcp_container_service import MCPContainerManager from utils.auth_utils import get_current_user_id @@ -116,6 +118,7 @@ async def get_remote_proxies( return JSONResponse( status_code=HTTPStatus.OK, content={"remote_mcp_server_list": remote_mcp_server_list, + "enable_upload_image": ENABLE_UPLOAD_IMAGE, "status": "success"} ) except Exception as e: @@ -153,7 +156,7 @@ async def add_mcp_from_config( """ Add MCP server by starting a container with command+args config. Similar to Cursor's MCP server configuration format. - + Example request: { "mcpServers": { @@ -167,7 +170,7 @@ async def add_mcp_from_config( """ try: user_id, tenant_id = get_current_user_id(authorization) - + # Initialize container manager try: container_manager = MCPContainerManager() @@ -177,25 +180,30 @@ async def add_mcp_from_config( status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="Docker service unavailable. Please ensure Docker socket is mounted." ) - + results = [] errors = [] - + for service_name, config in mcp_config.mcpServers.items(): try: command = config.command args = config.args or [] env_vars = config.env or {} port = config.port - + if not command: errors.append(f"{service_name}: command is required") continue - + if port is None: errors.append(f"{service_name}: port is required") continue + # Check if MCP service name already exists before starting container + if check_mcp_name_exists(mcp_name=service_name, tenant_id=tenant_id): + errors.append(f"{service_name}: MCP name already exists") + continue + # Build full command to run inside nexent/nexent-mcp image full_command = [ "python", @@ -219,28 +227,19 @@ async def add_mcp_from_config( user_id=user_id, env_vars=env_vars, host_port=port, - image=config.image or MCP_DOCKER_IMAGE, + image=config.image or NEXENT_MCP_DOCKER_IMAGE, full_command=full_command, ) - + # Register to remote MCP server list - try: - await add_remote_mcp_server_list( - tenant_id=tenant_id, - user_id=user_id, - remote_mcp_server=container_info["mcp_url"], - remote_mcp_server_name=service_name, - container_id=container_info["container_id"], - ) - except MCPNameIllegal: - # If name already exists, try to stop the container we just created - try: - await container_manager.stop_mcp_container(container_info["container_id"]) - except Exception: - pass - errors.append(f"{service_name}: MCP name already exists") - continue - + await add_remote_mcp_server_list( + tenant_id=tenant_id, + user_id=user_id, + remote_mcp_server=container_info["mcp_url"], + remote_mcp_server_name=service_name, + container_id=container_info["container_id"], + ) + results.append({ "service_name": service_name, "status": "success", @@ -249,20 +248,22 @@ async def add_mcp_from_config( "container_name": container_info.get("container_name"), "host_port": container_info.get("host_port") }) - + except MCPContainerError as e: - logger.error(f"Failed to start MCP container {service_name}: {e}") + logger.error( + f"Failed to start MCP container {service_name}: {e}") errors.append(f"{service_name}: {str(e)}") except Exception as e: - logger.error(f"Unexpected error adding MCP {service_name}: {e}") + logger.error( + f"Unexpected error adding MCP {service_name}: {e}") errors.append(f"{service_name}: {str(e)}") - + if errors and not results: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"All MCP servers failed: {errors}" ) - + return JSONResponse( status_code=HTTPStatus.OK, content={ @@ -272,7 +273,7 @@ async def add_mcp_from_config( "status": "success" } ) - + except HTTPException: raise except Exception as e: @@ -291,7 +292,7 @@ async def stop_mcp_container( """ Stop and remove MCP container """ try: user_id, tenant_id = get_current_user_id(authorization) - + try: container_manager = MCPContainerManager() except MCPContainerError as e: @@ -339,7 +340,7 @@ async def list_mcp_containers( """ List all MCP containers for the current tenant """ try: user_id, tenant_id = get_current_user_id(authorization) - + try: container_manager = MCPContainerManager() except MCPContainerError as e: @@ -348,9 +349,9 @@ async def list_mcp_containers( status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="Docker service unavailable" ) - + containers = container_manager.list_mcp_containers(tenant_id=tenant_id) - + return JSONResponse( status_code=HTTPStatus.OK, content={ @@ -377,7 +378,7 @@ async def get_container_logs( """ Get logs from MCP container """ try: user_id, tenant_id = get_current_user_id(authorization) - + try: container_manager = MCPContainerManager() except MCPContainerError as e: @@ -386,9 +387,9 @@ async def get_container_logs( status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="Docker service unavailable" ) - + logs = container_manager.get_container_logs(container_id, tail=tail) - + return JSONResponse( status_code=HTTPStatus.OK, content={ @@ -404,3 +405,62 @@ async def get_container_logs( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Failed to get container logs: {str(e)}" ) + + +# Conditionally add upload-image route based on ENABLE_UPLOAD_IMAGE setting +if ENABLE_UPLOAD_IMAGE: + @router.post("/upload-image") + async def upload_mcp_image( + file: UploadFile = File(..., description="Docker image tar file"), + port: int = Form(..., ge=1, le=65535, + description="Host port to expose the MCP server on (1-65535)"), + service_name: Optional[str] = Form( + None, description="Name for the MCP service (auto-generated if not provided)"), + env_vars: Optional[str] = Form( + None, description="Environment variables as JSON string"), + authorization: Optional[str] = Header(None) + ): + """ + Upload Docker image tar file and start MCP container. + + Container naming: {filename-without-extension}-{tenant-id[:8]}-{user-id[:8]} + """ + try: + user_id, tenant_id = get_current_user_id(authorization) + + # Read file content + content = await file.read() + + # Call service layer to handle the business logic + result = await upload_and_start_mcp_image( + tenant_id=tenant_id, + user_id=user_id, + file_content=content, + filename=file.filename, + port=port, + service_name=service_name, + env_vars=env_vars, + ) + + return JSONResponse(status_code=HTTPStatus.OK, content=result) + + except ValueError as e: + logger.error(f"Validation error: {e}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except MCPNameIllegal as e: + logger.error(f"MCP name conflict: {e}") + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e)) + except MCPContainerError as e: + logger.error(f"Container error: {e}") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) + except Exception as e: + logger.error(f"Failed to upload and start MCP container: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Failed to upload and start MCP container: {str(e)}" + ) +else: + logger.info( + "MCP image upload feature is disabled (ENABLE_UPLOAD_IMAGE=false)") diff --git a/backend/consts/const.py b/backend/consts/const.py index 003e01057..7fd0e0098 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -129,8 +129,8 @@ class VectorDatabaseType(str, Enum): DISABLE_CELERY_FLOWER = os.getenv( "DISABLE_CELERY_FLOWER", "false").lower() == "true" DOCKER_ENVIRONMENT = os.getenv("DOCKER_ENVIRONMENT", "false").lower() == "true" -DOCKER_HOST = os.getenv("DOCKER_HOST") -MCP_DOCKER_IMAGE = os.getenv("MCP_DOCKER_IMAGE", "nexent/nexent-mcp:latest") +NEXENT_MCP_DOCKER_IMAGE = os.getenv("NEXENT_MCP_DOCKER_IMAGE", "nexent/nexent-mcp:latest") +ENABLE_UPLOAD_IMAGE = os.getenv("ENABLE_UPLOAD_IMAGE", "false").lower() == "true" # Celery Configuration @@ -287,4 +287,4 @@ class VectorDatabaseType(str, Enum): # APP Version -APP_VERSION = "v1.7.9" +APP_VERSION = "v1.7.9.1" diff --git a/backend/consts/model.py b/backend/consts/model.py index 1e0f5b5e0..b7a377077 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -66,6 +66,7 @@ class ProviderModelRequest(BaseModel): provider: str model_type: str api_key: Optional[str] = '' + base_url: Optional[str] = '' class BatchCreateModelsRequest(BaseModel): @@ -231,8 +232,10 @@ class GeneratePromptRequest(BaseModel): task_description: str agent_id: int model_id: int - tool_ids: Optional[List[int]] = None # Optional: tool IDs from frontend (takes precedence over database query) - sub_agent_ids: Optional[List[int]] = None # Optional: sub-agent IDs from frontend (takes precedence over database query) + tool_ids: Optional[List[int]] = Field( + None, description="Optional: tool IDs from frontend (takes precedence over database query)") + sub_agent_ids: Optional[List[int]] = Field( + None, description="Optional: sub-agent IDs from frontend (takes precedence over database query)") class GenerateTitleRequest(BaseModel): @@ -398,19 +401,22 @@ def default(cls) -> "MemoryAgentShareMode": # --------------------------------------------------------------------------- class VoiceConnectivityRequest(BaseModel): """Request model for voice service connectivity check""" - model_type: str = Field(..., description="Type of model to check ('stt' or 'tts')") + model_type: str = Field(..., + description="Type of model to check ('stt' or 'tts')") class VoiceConnectivityResponse(BaseModel): """Response model for voice service connectivity check""" - connected: bool = Field(..., description="Whether the service is connected") + connected: bool = Field(..., + description="Whether the service is connected") model_type: str = Field(..., description="Type of model checked") message: str = Field(..., description="Status message") class TTSRequest(BaseModel): """Request model for TTS text-to-speech conversion""" - text: str = Field(..., min_length=1, description="Text to convert to speech") + text: str = Field(..., min_length=1, + description="Text to convert to speech") stream: bool = Field(True, description="Whether to stream the audio") @@ -434,7 +440,8 @@ class ToolValidateRequest(BaseModel): class MCPServerConfig(BaseModel): """Configuration for a single MCP server""" command: str = Field(..., description="Command to run (e.g., 'npx')") - args: List[str] = Field(default_factory=list, description="Command arguments") + args: List[str] = Field(default_factory=list, + description="Command arguments") env: Optional[Dict[str, str]] = Field( None, description="Environment variables for the MCP server") port: Optional[int] = Field( @@ -448,4 +455,4 @@ class MCPServerConfig(BaseModel): class MCPConfigRequest(BaseModel): """Request model for adding MCP servers from configuration""" mcpServers: Dict[str, MCPServerConfig] = Field( - ..., description="Dictionary of MCP server configurations") \ No newline at end of file + ..., description="Dictionary of MCP server configurations") diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py index aafa38ba6..591da0064 100644 --- a/backend/services/agent_service.py +++ b/backend/services/agent_service.py @@ -1433,7 +1433,7 @@ async def prepare_agent_run( """ memory_context = build_memory_context( - user_id, tenant_id, agent_request.agent_id) + user_id, tenant_id, agent_request.agent_id, skip_query=not allow_memory_search) agent_run_info = await create_agent_run_info( agent_id=agent_request.agent_id, minio_files=agent_request.minio_files, @@ -1682,12 +1682,12 @@ async def run_agent_stream( }) monitoring_manager.set_span_attributes(user_message_saved=False) - # Step 3: Build memory context + # Step 3: Build memory context (skip for debug mode) memory_start_time = time.time() monitoring_manager.add_span_event("memory_context_build.started") memory_ctx_preview = build_memory_context( - resolved_user_id, resolved_tenant_id, agent_request.agent_id + resolved_user_id, resolved_tenant_id, agent_request.agent_id, skip_query=agent_request.is_debug ) memory_duration = time.time() - memory_start_time @@ -1695,7 +1695,8 @@ async def run_agent_stream( monitoring_manager.add_span_event("memory_context_build.completed", { "duration": memory_duration, "memory_enabled": memory_enabled, - "agent_share_option": getattr(memory_ctx_preview.user_config, "agent_share_option", "unknown") + "agent_share_option": getattr(memory_ctx_preview.user_config, "agent_share_option", "unknown"), + "debug_mode": agent_request.is_debug }) monitoring_manager.set_span_attributes( memory_enabled=memory_enabled, diff --git a/backend/services/mcp_container_service.py b/backend/services/mcp_container_service.py index b30b87094..733429acc 100644 --- a/backend/services/mcp_container_service.py +++ b/backend/services/mcp_container_service.py @@ -8,7 +8,6 @@ import logging from typing import Dict, List, Optional -from consts.const import DOCKER_HOST from consts.exceptions import MCPConnectionError, MCPContainerError from nexent.container import ( DockerContainerConfig, @@ -28,26 +27,59 @@ class MCPContainerManager: while delegating to the SDK's standardized container management module. """ - def __init__(self, docker_socket_path: str = "/var/run/docker.sock"): + def __init__(self, docker_socket_path: Optional[str] = None): """ Initialize container manager using SDK Args: - docker_socket_path: Path to Docker socket + docker_socket_path: Path to Docker socket. If None, uses platform default. For container access, mount docker socket: -v /var/run/docker.sock:/var/run/docker.sock """ try: # Create Docker configuration config = DockerContainerConfig( - docker_socket_path=docker_socket_path, docker_host=DOCKER_HOST + docker_socket_path=docker_socket_path ) # Create container client from config self.client = create_container_client_from_config(config) - logger.info("MCPContainerManager initialized using SDK container module") + logger.info( + "MCPContainerManager initialized using SDK container module") except ContainerError as e: logger.error(f"Failed to initialize container manager: {e}") raise MCPContainerError(f"Cannot connect to Docker: {e}") + async def load_image_from_tar_file(self, tar_file_path: str) -> str: + """ + Load Docker image from tar file + + Args: + tar_file_path: Path to the tar file containing the Docker image + + Returns: + Image name/tag that was loaded + + Raises: + MCPContainerError: If image loading fails + """ + try: + # Load image from tar file + with open(tar_file_path, 'rb') as tar_file: + images = self.client.client.images.load(tar_file.read()) + + if not images: + raise MCPContainerError("No images found in tar file") + + # Get the first loaded image + loaded_image = images[0] + image_name = loaded_image.tags[0] if loaded_image.tags else str( + loaded_image.id) + + except Exception as e: + logger.error(f"Failed to load image from tar file: {e}") + raise MCPContainerError(f"Failed to load image from tar file: {e}") + logger.info(f"Successfully loaded image: {image_name}") + return image_name + async def start_mcp_container( self, service_name: str, @@ -74,8 +106,6 @@ async def start_mcp_container( MCPContainerError: If container startup fails """ try: - if not full_command: - raise MCPContainerError("full_command is required to start MCP container") result = await self.client.start_container( service_name=service_name, tenant_id=tenant_id, @@ -88,7 +118,8 @@ async def start_mcp_container( # Map SDK response to existing interface (mcp_url instead of service_url) return { "container_id": result["container_id"], - "mcp_url": result["service_url"], # Map service_url to mcp_url for compatibility + # Map service_url to mcp_url for compatibility + "mcp_url": result["service_url"], "host_port": result["host_port"], "status": result["status"], "container_name": result.get("container_name"), @@ -100,6 +131,54 @@ async def start_mcp_container( logger.error(f"MCP connection error: {e}") raise MCPConnectionError(f"MCP connection failed: {e}") + async def start_mcp_container_from_tar( + self, + tar_file_path: str, + service_name: str, + tenant_id: str, + user_id: str, + env_vars: Optional[Dict[str, str]] = None, + host_port: Optional[int] = None, + full_command: Optional[List[str]] = None, + ) -> Dict[str, str]: + """ + Load image from tar file and start MCP container + + Args: + tar_file_path: Path to the tar file containing the Docker image + service_name: Name of the MCP service + tenant_id: Tenant ID for isolation + user_id: User ID for isolation + env_vars: Optional environment variables + host_port: Optional host port to bind + full_command: Optional command to run in container + + Returns: + Dictionary with container_id, mcp_url, host_port, and status + + Raises: + MCPContainerError: If container startup fails + """ + try: + # Load image from tar file + image_name = await self.load_image_from_tar_file(tar_file_path) + + # Start container with the loaded image + return await self.start_mcp_container( + service_name=service_name, + tenant_id=tenant_id, + user_id=user_id, + env_vars=env_vars, + host_port=host_port, + image=image_name, + full_command=full_command, + ) + + except Exception as e: + logger.error(f"Failed to start MCP container from tar file: {e}") + raise MCPContainerError( + f"Failed to start container from tar file: {e}") + async def stop_mcp_container(self, container_id: str) -> bool: """ Stop and remove MCP container @@ -118,7 +197,7 @@ async def stop_mcp_container(self, container_id: str) -> bool: stop_result = await self.client.stop_container(container_id) if not stop_result: return False - + # Then remove the container remove_result = await self.client.remove_container(container_id) return remove_result diff --git a/backend/services/me_model_management_service.py b/backend/services/me_model_management_service.py deleted file mode 100644 index 9860ffe5b..000000000 --- a/backend/services/me_model_management_service.py +++ /dev/null @@ -1,55 +0,0 @@ -import aiohttp -import asyncio - -from consts.const import MODEL_ENGINE_APIKEY, MODEL_ENGINE_HOST -from consts.exceptions import MEConnectionException, TimeoutException - - -async def check_me_variable_set() -> bool: - """ - Check if the ME environment variables are correctly set. - Returns: - bool: True if both MODEL_ENGINE_APIKEY and MODEL_ENGINE_HOST are set and non-empty, False otherwise. - """ - return bool(MODEL_ENGINE_APIKEY and MODEL_ENGINE_HOST) - - -async def check_me_connectivity(timeout: int = 30) -> bool: - """ - Check ModelEngine connectivity by actually calling the API. - - Args: - timeout: Request timeout in seconds - - Returns: - bool: True if connection successful, False otherwise - - Raises: - MEConnectionException: If connection failed with specific error - TimeoutException: If request timed out - """ - if not await check_me_variable_set(): - return False - - try: - headers = {"Authorization": f"Bearer {MODEL_ENGINE_APIKEY}"} - - async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=timeout), - connector=aiohttp.TCPConnector(ssl=False) - ) as session: - async with session.get( - f"{MODEL_ENGINE_HOST}/open/router/v1/models", - headers=headers - ) as response: - if response.status == 200: - return True - else: - raise MEConnectionException( - f"Connection failed, error code: {response.status}") - except asyncio.TimeoutError: - raise TimeoutException("Connection timed out") - except MEConnectionException: - raise - except Exception as e: - raise Exception(f"Unknown error occurred: {str(e)}") diff --git a/backend/services/memory_config_service.py b/backend/services/memory_config_service.py index dbb9c323e..9841caf8d 100644 --- a/backend/services/memory_config_service.py +++ b/backend/services/memory_config_service.py @@ -191,7 +191,23 @@ def remove_disabled_useragent_id(user_id: str, ua_id: str) -> bool: return _remove_multi_value(user_id, DISABLE_USERAGENT_ID_KEY, ua_id) -def build_memory_context(user_id: str, tenant_id: str, agent_id: str | int) -> MemoryContext: +def build_memory_context(user_id: str, tenant_id: str, agent_id: str | int, skip_query: bool = False) -> MemoryContext: + if skip_query: + # When memory is forcibly disabled (e.g., debug mode), return minimum context without database queries + memory_user_config = MemoryUserConfig( + memory_switch=False, + agent_share_option="never", + disable_agent_ids=[], + disable_user_agent_ids=[], + ) + return MemoryContext( + user_config=memory_user_config, + memory_config=dict(), + tenant_id=tenant_id, + user_id=user_id, + agent_id=str(agent_id), + ) + memory_user_config = MemoryUserConfig( memory_switch=get_memory_switch(user_id), agent_share_option=get_agent_share(user_id).value, diff --git a/backend/services/model_health_service.py b/backend/services/model_health_service.py index 30d1a925d..78f6413ee 100644 --- a/backend/services/model_health_service.py +++ b/backend/services/model_health_service.py @@ -195,14 +195,17 @@ async def verify_model_config_connectivity(model_config: dict): connectivity = await _perform_connectivity_check( model_name, model_type, model_base_url, model_api_key, ssl_verify ) - + if not connectivity and ssl_verify: + connectivity = await _perform_connectivity_check( + model_name, model_type, model_base_url, model_api_key, False + ) if not connectivity: return { "connectivity": False, "model_name": model_name, "error": f"Failed to connect to model '{model_name}' at {model_base_url}. Please verify the URL, API key, and network connection." } - + return { "connectivity": True, "model_name": model_name, diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index 7e7c59a5a..dc38026d8 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -45,7 +45,9 @@ async def create_model_for_tenant(user_id: str, tenant_id: str, model_data: Dict model_base_url.replace(LOCALHOST_NAME, DOCKER_INTERNAL_HOST) .replace(LOCALHOST_IP, DOCKER_INTERNAL_HOST) ) - + model_data['ssl_verify'] = True + if "open/router" in model_base_url: + model_data['ssl_verify'] = False # Split model_name into repo and name model_repo, model_name = split_repo_name( model_data["model_name"]) if model_data.get("model_name") else ("", "") @@ -286,7 +288,7 @@ async def delete_model_for_tenant(user_id: str, tenant_id: str, display_name: st raise LookupError(f"Model not found: {display_name}") deleted_types: List[str] = [] - + # Check if any of the models is multi_embedding (which means we have both types) has_multi_embedding = any( m.get("model_type") == "multi_embedding" for m in models @@ -343,12 +345,12 @@ async def list_models_for_tenant(tenant_id: str): try: records = get_model_records(None, tenant_id) result: List[Dict[str, Any]] = [] - + # Type mapping for backwards compatibility (chat -> llm for frontend) type_map = { "chat": "llm", } - + for record in records: record["model_name"] = add_repo_to_name( model_repo=record["model_repo"], @@ -356,11 +358,11 @@ async def list_models_for_tenant(tenant_id: str): ) record["connect_status"] = ModelConnectStatusEnum.get_value( record.get("connect_status")) - + # Map model_type if necessary (for ModelEngine compatibility) if record.get("model_type") in type_map: record["model_type"] = type_map[record["model_type"]] - + result.append(record) logging.debug("Successfully retrieved model list") diff --git a/backend/services/model_provider_service.py b/backend/services/model_provider_service.py index e154aba72..24fb5bc16 100644 --- a/backend/services/model_provider_service.py +++ b/backend/services/model_provider_service.py @@ -21,6 +21,7 @@ logger = logging.getLogger("model_provider_service") +MODEL_ENGINE_NORTH_PREFIX = "open/router/v1" class AbstractModelProvider(ABC): """Common interface that all model provider integrations must implement.""" @@ -85,19 +86,22 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: List of models with canonical fields """ try: - if not MODEL_ENGINE_HOST or not MODEL_ENGINE_APIKEY: - logger.warning("ModelEngine environment variables not configured") + model_type: str = provider_config.get("model_type", "") + host = provider_config.get("base_url") + api_key = provider_config.get("api_key") + model_engine_url = get_model_engine_raw_url(host) + if not host or not api_key: + logger.warning("ModelEngine host or api key not configured") return [] - model_type: str = provider_config.get("model_type", "") - headers = {"Authorization": f"Bearer {MODEL_ENGINE_APIKEY}"} + headers = {"Authorization": f"Bearer {api_key}"} async with aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=30), connector=aiohttp.TCPConnector(ssl=False) ) as session: async with session.get( - f"{MODEL_ENGINE_HOST}/open/router/v1/models", + f"{model_engine_url.rstrip('/')}/{MODEL_ENGINE_NORTH_PREFIX}/models", headers=headers ) as response: response.raise_for_status() @@ -130,9 +134,8 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: "model_type": internal_type, "model_tag": me_type, "max_tokens": DEFAULT_LLM_MAX_TOKENS if internal_type in ("llm", "vlm") else 0, - # ModelEngine models will get base_url and api_key from environment - "base_url": MODEL_ENGINE_HOST, - "api_key": MODEL_ENGINE_APIKEY, + "base_url": host, + "api_key": api_key, }) return filtered_models @@ -178,12 +181,7 @@ async def prepare_model_dict(provider: str, model: dict, model_url: str, model_a if provider == ProviderEnum.MODELENGINE.value: # Get the raw host URL from model (e.g., "https://120.253.225.102:50001") raw_model_url = model.get("base_url", "") - # Strip any existing path to get just the host - if raw_model_url: - # Remove any trailing /open/router/v1 or similar paths to get base host - raw_model_url = raw_model_url.split("/open/")[0] if "/open/" in raw_model_url else raw_model_url - model_url = raw_model_url - model_api_key = model.get("api_key", model_api_key) + model_url = get_model_engine_raw_url(raw_model_url) # Build the canonical representation using the existing Pydantic schema for # consistency of validation and default handling. @@ -216,14 +214,14 @@ async def prepare_model_dict(provider: str, model: dict, model_url: str, model_a model_dict["base_url"] = f"{model_url}embeddings" else: # For ModelEngine embedding models, append the embeddings path - model_dict["base_url"] = f"{model_url.rstrip('/')}/open/router/v1/embeddings" + model_dict["base_url"] = f"{model_url.rstrip('/')}/{MODEL_ENGINE_NORTH_PREFIX}/embeddings" # The embedding dimension might differ from the provided max_tokens. model_dict["max_tokens"] = await embedding_dimension_check(model_dict) else: # For non-embedding models if provider == ProviderEnum.MODELENGINE.value: # Ensure ModelEngine models have the full API path - model_dict["base_url"] = f"{model_url.rstrip('/')}/open/router/v1" + model_dict["base_url"] = f"{model_url.rstrip('/')}/{MODEL_ENGINE_NORTH_PREFIX}" else: model_dict["base_url"] = model_url @@ -295,3 +293,11 @@ async def get_provider_models(model_data: dict) -> List[dict]: model_list = await provider.get_models(model_data) return model_list + +def get_model_engine_raw_url(model_engine_url: str) -> str: + # Strip any existing path to get just the host + model_engine_raw_url = model_engine_url + if model_engine_url: + # Remove any trailing /open/router/v1 or similar paths to get base host + model_engine_raw_url = model_engine_url.split("/open/")[0] if "/open/" in model_engine_url else model_engine_url + return model_engine_raw_url diff --git a/backend/services/remote_mcp_service.py b/backend/services/remote_mcp_service.py index b8c2c3998..e5e6b032a 100644 --- a/backend/services/remote_mcp_service.py +++ b/backend/services/remote_mcp_service.py @@ -1,4 +1,6 @@ import logging +import os +import tempfile from fastmcp import Client @@ -11,6 +13,7 @@ check_mcp_name_exists, update_mcp_status_by_name_and_url, ) +from services.mcp_container_service import MCPContainerManager logger = logging.getLogger("remote_mcp_service") @@ -22,7 +25,8 @@ async def mcp_server_health(remote_mcp_server: str) -> bool: connected = client.is_connected() return connected except BaseException as e: - logger.error(f"Remote MCP server health check failed: {e}", exc_info=True) + logger.error( + f"Remote MCP server health check failed: {e}", exc_info=True) # Prevent library-level exits (e.g., SystemExit) from crashing the service raise MCPConnectionError("MCP connection failed") @@ -52,7 +56,8 @@ async def add_remote_mcp_server_list( "status": True, "container_id": container_id, } - create_mcp_record(mcp_data=insert_mcp_data, tenant_id=tenant_id, user_id=user_id) + create_mcp_record(mcp_data=insert_mcp_data, + tenant_id=tenant_id, user_id=user_id) async def delete_remote_mcp_server_list(tenant_id: str, @@ -108,3 +113,111 @@ async def delete_mcp_by_container_id(tenant_id: str, user_id: str, container_id: tenant_id=tenant_id, user_id=user_id, ) + + +async def upload_and_start_mcp_image( + tenant_id: str, + user_id: str, + file_content: bytes, + filename: str, + port: int, + service_name: str | None = None, + env_vars: str | None = None, +): + """ + Upload MCP Docker image and start container. + + Args: + tenant_id: Tenant ID for isolation + user_id: User ID for isolation + file_content: Raw file content bytes + filename: Original filename + port: Host port to expose the MCP server on + service_name: Optional name for the MCP service (auto-generated if not provided) + env_vars: Optional environment variables as JSON string + + Returns: + Dictionary with service details including mcp_url, container_id, etc. + + Raises: + MCPContainerError: If container operations fail + MCPNameIllegal: If service name already exists + ValueError: If file validation fails + """ + # Validate file type + if not filename.lower().endswith('.tar'): + raise ValueError("Only .tar files are allowed") + + # Validate file size (limit to 1GB) + file_size = len(file_content) + if file_size > 1024 * 1024 * 1024: # 1GB limit + raise ValueError("File size exceeds 1GB limit") + + # Parse environment variables + parsed_env_vars = None + if env_vars: + try: + import json + parsed_env_vars = json.loads(env_vars) + if not isinstance(parsed_env_vars, dict): + raise ValueError("Environment variables must be a JSON object") + except (json.JSONDecodeError, ValueError) as e: + raise ValueError(f"Invalid environment variables format: {str(e)}") + + # Generate service name if not provided + final_service_name = service_name + if not final_service_name: + # Remove .tar extension from filename + final_service_name = os.path.splitext(filename)[0] + + # Check if MCP service name already exists + if check_mcp_name_exists(mcp_name=final_service_name, tenant_id=tenant_id): + raise MCPNameIllegal("MCP service name already exists") + + # Save file to temporary location (delete=False, manual cleanup) + with tempfile.NamedTemporaryFile(delete=False, suffix='.tar') as temp_file: + temp_file.write(file_content) + temp_file_path = temp_file.name + + try: + # Initialize container manager + container_manager = MCPContainerManager() + + # Start container from uploaded image + # Note: uploaded image should be a complete MCP server implementation + # that can be started directly without additional commands (uses image's CMD/ENTRYPOINT) + container_info = await container_manager.start_mcp_container_from_tar( + tar_file_path=temp_file_path, + service_name=final_service_name, + tenant_id=tenant_id, + user_id=user_id, + env_vars=parsed_env_vars, + host_port=port, + full_command=None, # Uploaded image should contain the MCP server + ) + finally: + # Manual cleanup of temporary file + try: + os.unlink(temp_file_path) + except Exception as e: + logger.warning( + f"Failed to clean up temporary file {temp_file_path}: {e}") + + # Register to remote MCP server list + await add_remote_mcp_server_list( + tenant_id=tenant_id, + user_id=user_id, + remote_mcp_server=container_info["mcp_url"], + remote_mcp_server_name=final_service_name, + container_id=container_info["container_id"], + ) + + return { + "message": "MCP container started successfully from uploaded image", + "status": "success", + "service_name": final_service_name, + "mcp_url": container_info["mcp_url"], + "container_id": container_info["container_id"], + "container_name": container_info.get("container_name"), + "host_port": container_info.get("host_port") + } diff --git a/backend/utils/config_utils.py b/backend/utils/config_utils.py index 67b78283c..a9bd1566f 100644 --- a/backend/utils/config_utils.py +++ b/backend/utils/config_utils.py @@ -51,17 +51,7 @@ def get_model_name_from_config(model_config: Dict[str, Any]) -> str: class TenantConfigManager: - """Tenant configuration manager for dynamic loading and caching configurations from database""" - - def __init__(self): - self.config_cache = {} - self.cache_expiry = {} # Store expiration timestamps for each cache entry - self.CACHE_DURATION = 86400 # 1 day in seconds - self.last_modified_times = {} # Store last modified times for each tenant - - def _get_cache_key(self, tenant_id: str, key: str) -> str: - """Generate a unique cache key combining tenant_id and key""" - return f"{tenant_id}:{key}" + """Tenant configuration manager that reads configurations from the database on demand.""" def load_config(self, tenant_id: str, force_reload: bool = False): """Load configuration from database and update cache @@ -78,61 +68,22 @@ def load_config(self, tenant_id: str, force_reload: bool = False): logger.warning("Invalid tenant ID provided") return {} - complete_cache_key = self._get_cache_key(tenant_id, "*") - current_time = time.time() - - # Check if we have a valid cache entry - if not force_reload and complete_cache_key in self.config_cache: - # Check if cache is still valid - if complete_cache_key in self.cache_expiry and current_time < self.cache_expiry[complete_cache_key]: - return self.config_cache[complete_cache_key] - - # Cache miss or forced reload - Get configurations from database + # Always load latest configurations directly from DB (no in-process cache). configs = get_all_configs_by_tenant_id(tenant_id) if not configs: logger.info(f"No configurations found for tenant {tenant_id}") return {} - # Update cache with new configurations - cache_updates = 0 tenant_configs = {} - for config in configs: - cache_key = self._get_cache_key(tenant_id, config["config_key"]) - self.config_cache[cache_key] = config["config_value"] tenant_configs[config["config_key"]] = config["config_value"] - self.cache_expiry[cache_key] = current_time + self.CACHE_DURATION - cache_updates += 1 - - # Store the complete tenant config - self.config_cache[complete_cache_key] = tenant_configs - self.cache_expiry[complete_cache_key] = current_time + \ - self.CACHE_DURATION - - # Store the last modified time from database - self.last_modified_times[tenant_id] = self._get_tenant_config_modified_time( - tenant_id) logger.info( - f"Configuration reloaded for tenant {tenant_id} at: {time.strftime('%Y-%m-%d %H:%M:%S')}") + f"Configuration loaded for tenant {tenant_id} at: {time.strftime('%Y-%m-%d %H:%M:%S')}") return tenant_configs - def _get_tenant_config_modified_time(self, tenant_id: str) -> float: - """Get the last modification time of tenant configurations - - Args: - tenant_id (str): The tenant ID to check - - Returns: - float: The last modification timestamp - """ - # This is a placeholder - implement actual database query - # to get the last modification time of tenant configurations - # Example: return db.query("SELECT MAX(modified_at) FROM tenant_configs WHERE tenant_id = %s", tenant_id) - return time.time() # Temporary implementation - def get_model_config(self, key: str, default=None, tenant_id: str | None = None): if default is None: default = {} @@ -186,8 +137,6 @@ def set_single_config(self, user_id: str | None = None, tenant_id: str | None = } insert_config(insert_data) - # Clear cache for this tenant after setting new config - self.clear_cache(tenant_id) def delete_single_config(self, tenant_id: str | None = None, key: str | None = None, ): """Delete configuration value in database""" @@ -200,8 +149,6 @@ def delete_single_config(self, tenant_id: str | None = None, key: str | None = N if existing_config: delete_config_by_tenant_config_id( existing_config["tenant_config_id"]) - # Clear cache for this tenant after deleting config - self.clear_cache(tenant_id) return def update_single_config(self, tenant_id: str | None = None, key: str | None = None): @@ -219,26 +166,7 @@ def update_single_config(self, tenant_id: str | None = None, key: str | None = N } update_config_by_tenant_config_id_and_data( existing_config["tenant_config_id"], update_data) - - # Clear cache for this tenant after updating config so that - # subsequent reads immediately see the latest configuration - self.clear_cache(tenant_id) return - def clear_cache(self, tenant_id: str | None = None): - """Clear the cache for a specific tenant or all tenants""" - if tenant_id: - # Clear cache for specific tenant - keys_to_remove = [ - k for k in self.config_cache.keys() if k.startswith(f"{tenant_id}:")] - for key in keys_to_remove: - del self.config_cache[key] - if key in self.cache_expiry: - del self.cache_expiry[key] - else: - # Clear all cache - self.config_cache.clear() - self.cache_expiry.clear() - tenant_config_manager = TenantConfigManager() diff --git a/doc/docs/en/code-of-conduct.md b/doc/docs/en/code-of-conduct.md index 906d04740..1809918b2 100644 --- a/doc/docs/en/code-of-conduct.md +++ b/doc/docs/en/code-of-conduct.md @@ -61,7 +61,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -[chenshuangrui@huawei.com]. +[wanmingchen1@huawei.com]. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/doc/docs/en/contributing.md b/doc/docs/en/contributing.md index c5c313c05..fca722076 100644 --- a/doc/docs/en/contributing.md +++ b/doc/docs/en/contributing.md @@ -219,7 +219,7 @@ To contribute: Stuck or have questions? We're here to help! Reach out to us via: - **GitHub Issues**: Open an issue for discussion. - **Discord**: Join our [Nexent Community](https://discord.gg/YXH5C8SQ) for real-time chat. -- **Email**: Drop us a line at [chenshuangrui@huawei.com](mailto:chenshuangrui@huawei.com). +- **Email**: Drop us a line at [wanmingchen1@huawei.com](mailto:wanmingchen1@huawei.com). ## 🎉 Celebrate Your Contribution! diff --git a/doc/docs/en/license.md b/doc/docs/en/license.md index e50e3c4d4..b54842687 100644 --- a/doc/docs/en/license.md +++ b/doc/docs/en/license.md @@ -16,7 +16,7 @@ Nexent is permitted to be used commercially, including as a backend service for a. Multi-tenant SaaS service: Unless explicitly authorized by Nexent in writing, you may not use the Nexent source code to operate a multi-tenant SaaS service. b. LOGO and copyright information: In the process of using Nexent's frontend, you may not remove or modify the LOGO or copyright information in the Nexent console or applications. This restriction is inapplicable to uses of Nexent that do not involve its frontend. -Please contact chenshuangrui@huawei.com by email to inquire about licensing matters. +Please contact zhenggaoqi@huawei.com by email to inquire about licensing matters. As a contributor, you should agree that: @@ -32,7 +32,7 @@ Copyright © 2025 Huawei Technologies Co., Ltd. ## Contact Information For licensing inquiries: -- **Email**: chenshuangrui@huawei.com +- **Email**: zhenggaoqi@huawei.com - **MIT License Details**: https://opensource.org/licenses/MIT Please ensure you understand and comply with all license terms before using Nexent in your projects. \ No newline at end of file diff --git a/doc/docs/zh/code-of-conduct.md b/doc/docs/zh/code-of-conduct.md index 8c97723b1..f8361a923 100644 --- a/doc/docs/zh/code-of-conduct.md +++ b/doc/docs/zh/code-of-conduct.md @@ -36,7 +36,7 @@ ## 执行 -可以向负责执行的社区领导者举报滥用、骚扰或其他不可接受的行为实例,联系邮箱:[chenshuangrui@huawei.com]。 +可以向负责执行的社区领导者举报滥用、骚扰或其他不可接受的行为实例,联系邮箱:[wanmingchen1@huawei.com]。 所有投诉都将得到及时和公平的审查和调查。 所有社区领导者都有义务尊重任何事件举报者的隐私和安全。 diff --git a/doc/docs/zh/contributing.md b/doc/docs/zh/contributing.md index 43b9259af..e11a5d37a 100644 --- a/doc/docs/zh/contributing.md +++ b/doc/docs/zh/contributing.md @@ -217,7 +217,7 @@ git merge upstream/main 遇到困难或有疑问?我们随时为您提供帮助!通过以下方式联系我们: - **GitHub Issues**:新建一个 Issue 进行讨论。 - **Discord**:加入我们的 [Nexent 社区](https://discord.gg/YXH5C8SQ) 进行实时聊天。 -- **电子邮件**:给我们发邮件至 [chenshuangrui@huawei.com](mailto:chenshuangrui@huawei.com)。 +- **电子邮件**:给我们发邮件至 [wanmingchen1@huawei.com](mailto:wanmingchen1@huawei.com)。 ## 🎉 庆祝您的贡献! diff --git a/doc/docs/zh/license.md b/doc/docs/zh/license.md index af455ff43..87eb83ed1 100644 --- a/doc/docs/zh/license.md +++ b/doc/docs/zh/license.md @@ -16,7 +16,7 @@ Nexent is permitted to be used commercially, including as a backend service for a. Multi-tenant SaaS service: Unless explicitly authorized by Nexent in writing, you may not use the Nexent source code to operate a multi-tenant SaaS service. b. LOGO and copyright information: In the process of using Nexent's frontend, you may not remove or modify the LOGO or copyright information in the Nexent console or applications. This restriction is inapplicable to uses of Nexent that do not involve its frontend. -Please contact chenshuangrui@huawei.com by email to inquire about licensing matters. +Please contact zhenggaoqi@huawei.com by email to inquire about licensing matters. As a contributor, you should agree that: @@ -32,7 +32,7 @@ Copyright © 2025 Huawei Technologies Co., Ltd. ## 联系信息 许可证咨询: -- **邮箱**: chenshuangrui@huawei.com +- **邮箱**: zhenggaoqi@huawei.com - **MIT 许可证详情**: https://opensource.org/licenses/MIT 请确保在项目中使用 Nexent 之前理解并遵守所有许可证条款。 \ No newline at end of file diff --git a/docker/.env.example b/docker/.env.example index 97c230a11..04a9cfa5a 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -70,8 +70,9 @@ REDIS_URL=redis://redis:6379/0 REDIS_BACKEND_URL=redis://redis:6379/1 # Model Engine Config -MODEL_ENGINE_HOST=https://localhost:30555 -MODEL_ENGINE_APIKEY= +MODEL_ENGINE_ENABLED=false +MODEL_ENGINE_HOST="" +MODEL_ENGINE_API_KEY="" # Supabase Config DASHBOARD_USERNAME=supabase @@ -131,8 +132,7 @@ RAY_LOG_LEVEL=INFO DISABLE_RAY_DASHBOARD=true DISABLE_CELERY_FLOWER=true DOCKER_ENVIRONMENT=false -DOCKER_HOST=unix:///var/run/docker.sock -MCP_DOCKER_IMAGE=nexent/nexent-mcp:latest +ENABLE_UPLOAD_IMAGE=true # Celery Configuration CELERY_WORKER_PREFETCH_MULTIPLIER=1 diff --git a/docker/.env.general b/docker/.env.general index 1f98b1356..e2ac200be 100644 --- a/docker/.env.general +++ b/docker/.env.general @@ -1,6 +1,7 @@ NEXENT_IMAGE=nexent/nexent:${APP_VERSION} NEXENT_WEB_IMAGE=nexent/nexent-web:${APP_VERSION} NEXENT_DATA_PROCESS_IMAGE=nexent/nexent-data-process:${APP_VERSION} +NEXENT_MCP_DOCKER_IMAGE=nexent/nexent-mcp:${APP_VERSION} ELASTICSEARCH_IMAGE=docker.elastic.co/elasticsearch/elasticsearch:8.17.4 POSTGRESQL_IMAGE=postgres:15-alpine @@ -10,4 +11,4 @@ OPENSSH_SERVER_IMAGE=nexent/nexent-ubuntu-terminal:${APP_VERSION} SUPABASE_KONG=kong:2.8.1 SUPABASE_GOTRUE=supabase/gotrue:v2.170.0 -SUPABASE_DB=supabase/postgres:15.8.1.060 \ No newline at end of file +SUPABASE_DB=supabase/postgres:15.8.1.060 diff --git a/docker/.env.mainland b/docker/.env.mainland index 162788527..fd628ba46 100644 --- a/docker/.env.mainland +++ b/docker/.env.mainland @@ -1,6 +1,7 @@ NEXENT_IMAGE=ccr.ccs.tencentyun.com/nexent-hub/nexent:${APP_VERSION} NEXENT_WEB_IMAGE=ccr.ccs.tencentyun.com/nexent-hub/nexent-web:${APP_VERSION} NEXENT_DATA_PROCESS_IMAGE=ccr.ccs.tencentyun.com/nexent-hub/nexent-data-process:${APP_VERSION} +NEXENT_MCP_DOCKER_IMAGE=ccr.ccs.tencentyun.com/nexent-hub/nexent-mcp:${APP_VERSION} ELASTICSEARCH_IMAGE=elastic.m.daocloud.io/elasticsearch/elasticsearch:8.17.4 POSTGRESQL_IMAGE=docker.m.daocloud.io/postgres:15-alpine diff --git a/docker/deploy.sh b/docker/deploy.sh index 015aad63f..2545bf2dc 100755 --- a/docker/deploy.sh +++ b/docker/deploy.sh @@ -430,6 +430,33 @@ disable_dashboard() { update_env_var "DISABLE_CELERY_FLOWER" "true" } +pull_mcp_image() { + echo "🔄 Checking MCP Docker image..." + + # Get MCP image name from environment or use default + MCP_IMAGE_NAME=${NEXENT_MCP_DOCKER_IMAGE:-nexent/nexent-mcp:latest} + echo " 📦 Image: ${MCP_IMAGE_NAME}" + + # Check if image already exists locally + if docker image inspect "${MCP_IMAGE_NAME}" >/dev/null 2>&1; then + echo " ✅ MCP image already exists locally" + echo " 💡 Skipping pull, using existing image" + else + echo " 📥 MCP image not found locally, pulling..." + if docker pull "${MCP_IMAGE_NAME}"; then + echo " ✅ MCP image pulled successfully" + echo " 💡 The image will be available when you need to start MCP services" + else + echo " ⚠️ Failed to pull MCP image, but deployment continues" + echo " 💡 You can manually pull the image later: docker pull ${MCP_IMAGE_NAME}" + fi + fi + + echo "" + echo "--------------------------------" + echo "" +} + select_deployment_mode() { echo "🎛️ Please select deployment mode:" echo " 1) 🛠️ Development mode - Expose all service ports for debugging" @@ -905,6 +932,14 @@ main_deploy() { select_terminal_tool || { echo "❌ Terminal tool container configuration failed"; exit 1; } choose_image_env || { echo "❌ Image environment setup failed"; exit 1; } + # Set NEXENT_MCP_DOCKER_IMAGE in .env file + if [ -n "${NEXENT_MCP_DOCKER_IMAGE:-}" ]; then + update_env_var "NEXENT_MCP_DOCKER_IMAGE" "${NEXENT_MCP_DOCKER_IMAGE}" + echo "🔧 NEXENT_MCP_DOCKER_IMAGE set to: ${NEXENT_MCP_DOCKER_IMAGE}" + else + echo "⚠️ NEXENT_MCP_DOCKER_IMAGE not found in environment, will use default from code" + fi + # Add permission prepare_directory_and_data || { echo "❌ Permission setup failed"; exit 1; } generate_minio_ak_sk || { echo "❌ MinIO key generation failed"; exit 1; } @@ -930,6 +965,10 @@ main_deploy() { echo " You can now start the core services manually using dev containers" echo " Environment file available at: $(cd .. && pwd)/.env" echo "💡 Use 'source .env' to load environment variables in your development shell" + + # Pull MCP image for later use + pull_mcp_image + persist_deploy_options return 0 fi @@ -948,6 +987,10 @@ main_deploy() { fi persist_deploy_options + + # Pull MCP image for later use + pull_mcp_image + echo "🎉 Deployment completed successfully!" echo "🌐 You can now access the application at http://localhost:3000" } diff --git a/docker/generate_env.sh b/docker/generate_env.sh index 8d3f13341..b98e72737 100755 --- a/docker/generate_env.sh +++ b/docker/generate_env.sh @@ -172,19 +172,6 @@ update_env_file() { echo "REDIS_BACKEND_URL=redis://localhost:6379/1" >> ../.env fi - # DOCKER_HOST (set default per platform) - local docker_host_value="unix:///var/run/docker.sock" - case "$(uname -s)" in - MINGW*|CYGWIN*|MSYS*|Windows_NT) - docker_host_value="npipe:////./pipe/docker_engine" - ;; - esac - if grep -q "^DOCKER_HOST=" ../.env; then - sed -i.bak "s~^DOCKER_HOST=.*~DOCKER_HOST=$docker_host_value~" ../.env - else - echo "DOCKER_HOST=$docker_host_value" >> ../.env - fi - # POSTGRES_HOST if grep -q "^POSTGRES_HOST=" ../.env; then sed -i.bak "s~^POSTGRES_HOST=.*~POSTGRES_HOST=localhost~" ../.env @@ -201,7 +188,7 @@ update_env_file() { # Supabase Configuration (Only for full version) if [ "$DEPLOYMENT_VERSION" = "full" ]; then - if [ -n "$SUPABASE_KEY" ]; then + if [ -n "$SUPABASE_KEY" ]; then if grep -q "^SUPABASE_KEY=" ../.env; then sed -i.bak "s~^SUPABASE_KEY=.*~SUPABASE_KEY=$SUPABASE_KEY~" ../.env else @@ -218,20 +205,20 @@ update_env_file() { echo "SERVICE_ROLE_KEY=$SERVICE_ROLE_KEY" >> ../.env fi fi - + # Additional Supabase configuration if grep -q "^SUPABASE_URL=" ../.env; then sed -i.bak "s~^SUPABASE_URL=.*~SUPABASE_URL=http://localhost:8000~" ../.env else echo "SUPABASE_URL=http://localhost:8000" >> ../.env fi - + if grep -q "^API_EXTERNAL_URL=" ../.env; then sed -i.bak "s~^API_EXTERNAL_URL=.*~API_EXTERNAL_URL=http://localhost:8000~" ../.env else echo "API_EXTERNAL_URL=http://localhost:8000" >> ../.env fi - + if grep -q "^SITE_URL=" ../.env; then sed -i.bak "s~^SITE_URL=.*~SITE_URL=http://localhost:3011~" ../.env else @@ -295,4 +282,4 @@ main() { } # Run main function -main "$@" \ No newline at end of file +main "$@" diff --git a/docker/init.sql b/docker/init.sql index 5ba10457a..527857e0e 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -465,6 +465,7 @@ CREATE TABLE IF NOT EXISTS nexent.mcp_record_t ( mcp_name VARCHAR(100), mcp_server VARCHAR(500), status BOOLEAN DEFAULT NULL, + container_id VARCHAR(200) DEFAULT NULL, create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_by VARCHAR(100), @@ -482,6 +483,7 @@ COMMENT ON COLUMN nexent.mcp_record_t.user_id IS 'User ID'; COMMENT ON COLUMN nexent.mcp_record_t.mcp_name IS 'MCP name'; COMMENT ON COLUMN nexent.mcp_record_t.mcp_server IS 'MCP server address'; COMMENT ON COLUMN nexent.mcp_record_t.status IS 'MCP server connection status, true=connected, false=disconnected, null=unknown'; +COMMENT ON COLUMN nexent.mcp_record_t.container_id IS 'Docker container ID for MCP service, NULL for non-containerized MCP'; COMMENT ON COLUMN nexent.mcp_record_t.create_time IS 'Creation time, audit field'; COMMENT ON COLUMN nexent.mcp_record_t.update_time IS 'Update time, audit field'; COMMENT ON COLUMN nexent.mcp_record_t.created_by IS 'Creator ID, audit field'; diff --git a/frontend/app/[locale]/agents/AgentConfiguration.tsx b/frontend/app/[locale]/agents/AgentConfiguration.tsx deleted file mode 100644 index b23f2ccae..000000000 --- a/frontend/app/[locale]/agents/AgentConfiguration.tsx +++ /dev/null @@ -1,653 +0,0 @@ -"use client"; - -import { - useState, - useEffect, - useRef, - forwardRef, - useImperativeHandle, - useCallback, -} from "react"; -import { useTranslation } from "react-i18next"; -import { Drawer, App } from "antd"; -import { - AGENT_SETUP_LAYOUT_DEFAULT, - GENERATE_PROMPT_STREAM_TYPES, -} from "@/const/agentConfig"; -import { SETUP_PAGE_CONTAINER, STANDARD_CARD } from "@/const/layoutConstants"; -import { ModelOption } from "@/types/modelConfig"; -import { - LayoutConfig, - AgentConfigDataResponse, - AgentConfigCustomEvent, - AgentRefreshEvent, -} from "@/types/agentConfig"; -import { - fetchTools, - fetchAgentList, - exportAgent, - deleteAgent, -} from "@/services/agentConfigService"; -import { generatePromptStream } from "@/services/promptService"; -import { updateToolList } from "@/services/mcpService"; -import log from "@/lib/logger"; -import { configStore } from "@/lib/config"; - -import AgentSetupOrchestrator from "./components/AgentSetupOrchestrator"; -import DebugConfig from "./components/DebugConfig"; - -import "../i18n"; - -// Layout Height Constant Configuration -const LAYOUT_CONFIG: LayoutConfig = AGENT_SETUP_LAYOUT_DEFAULT; - -/** - * Agent configuration main component - * Provides a full-width interface for agent business logic configuration - * Follows SETUP_PAGE_CONTAINER layout standards for consistent height and spacing - */ -export type AgentConfigHandle = { - hasUnsavedChanges: () => boolean; - saveAllChanges: () => Promise; - reloadCurrentAgentData: () => Promise; -}; - -interface AgentConfigProps { - canAccessProtectedData: boolean; -} - -export default forwardRef(function AgentConfig( - { canAccessProtectedData }, - ref -) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const [businessLogic, setBusinessLogic] = useState(""); - const [selectedTools, setSelectedTools] = useState([]); - const [isDebugDrawerOpen, setIsDebugDrawerOpen] = useState(false); - const [isCreatingNewAgent, setIsCreatingNewAgent] = useState(false); - const [mainAgentModel, setMainAgentModel] = useState(null); - const [mainAgentModelId, setMainAgentModelId] = useState(null); - const [mainAgentMaxStep, setMainAgentMaxStep] = useState(5); - const [businessLogicModel, setBusinessLogicModel] = useState( - null - ); - const [businessLogicModelId, setBusinessLogicModelId] = useState< - number | null - >(null); - const [tools, setTools] = useState([]); - const [mainAgentId, setMainAgentId] = useState(null); - const [subAgentList, setSubAgentList] = useState([]); - const [loadingAgents, setLoadingAgents] = useState(false); - - const [enabledAgentIds, setEnabledAgentIds] = useState([]); - - const [isEditingAgent, setIsEditingAgent] = useState(false); - const [editingAgent, setEditingAgent] = useState(null); - - // Add state for three segmented content sections - const [dutyContent, setDutyContent] = useState(""); - const [constraintContent, setConstraintContent] = useState(""); - const [fewShotsContent, setFewShotsContent] = useState(""); - - // Add state for agent name and description - const [agentName, setAgentName] = useState(""); - const [agentDescription, setAgentDescription] = useState(""); - const [agentDisplayName, setAgentDisplayName] = useState(""); - const [agentAuthor, setAgentAuthor] = useState(""); - - // Add state for business logic and action buttons - const [isGeneratingAgent, setIsGeneratingAgent] = useState(false); - const [isEmbeddingConfigured, setIsEmbeddingConfigured] = useState(false); - - // Error state for business logic input - const [businessLogicError, setBusinessLogicError] = useState(false); - - // Only auto scan once flag - const hasAutoScanned = useRef(false); - const unsavedRef = useRef(false); - const saveHandlerRef = useRef Promise)>(null); - const reloadHandlerRef = useRef Promise)>(null); - - useImperativeHandle(ref, () => ({ - hasUnsavedChanges: () => unsavedRef.current, - saveAllChanges: async () => { - if (saveHandlerRef.current) { - await saveHandlerRef.current(); - } - }, - reloadCurrentAgentData: async () => { - if (reloadHandlerRef.current) { - await reloadHandlerRef.current(); - } - }, - })); - - // Handle generate agent - const handleGenerateAgent = async (selectedModel?: ModelOption) => { - if (!businessLogic || businessLogic.trim() === "") { - setBusinessLogicError(true); - message.warning( - t("businessLogic.config.error.businessDescriptionRequired") - ); - // Scroll to business logic input after a short delay to ensure it's visible - setTimeout(() => { - const businessLogicInput = document.querySelector( - "[data-business-logic-input]" - ); - if (businessLogicInput) { - businessLogicInput.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - } - }, 100); - return; - } - // Clear error when validation passes - setBusinessLogicError(false); - - // In create mode, agent_id should be 0 (backend will handle this case) - // In edit mode, use the current agent id - const currentAgentId = getCurrentAgentId(); - const agentIdToUse = isCreatingNewAgent ? 0 : currentAgentId || 0; - - if (!isCreatingNewAgent && !currentAgentId) { - message.error(t("businessLogic.config.error.noAgentId")); - return; - } - - setIsGeneratingAgent(true); - try { - const currentAgentName = agentName; - const currentAgentDisplayName = agentDisplayName; - - // Extract tool IDs from selected tools (convert string IDs to numbers) - // Always pass tool_ids array (empty array means no tools selected, undefined means use database) - // In edit mode, we want to use current selection, so pass the array even if empty - const toolIds = selectedTools.map((tool) => Number(tool.id)); - - // Get sub-agent IDs from enabledAgentIds - // Always pass sub_agent_ids array (empty array means no sub-agents selected, undefined means use database) - // In edit mode, we want to use current selection, so pass the array even if empty - const subAgentIds = [...enabledAgentIds]; - - // Call backend API to generate agent prompt - // Pass tool_ids and sub_agent_ids to use frontend selection instead of database query - await generatePromptStream( - { - agent_id: agentIdToUse, - task_description: businessLogic, - model_id: selectedModel?.id?.toString() || "", - tool_ids: toolIds, - sub_agent_ids: subAgentIds, - }, - (data) => { - // Process streaming response data - switch (data.type) { - case GENERATE_PROMPT_STREAM_TYPES.DUTY: - setDutyContent(data.content); - break; - case GENERATE_PROMPT_STREAM_TYPES.CONSTRAINT: - setConstraintContent(data.content); - break; - case GENERATE_PROMPT_STREAM_TYPES.FEW_SHOTS: - setFewShotsContent(data.content); - break; - case GENERATE_PROMPT_STREAM_TYPES.AGENT_VAR_NAME: - // Only update if current agent name is empty - if (!currentAgentName || currentAgentName.trim() === "") { - setAgentName(data.content); - } - break; - case GENERATE_PROMPT_STREAM_TYPES.AGENT_DESCRIPTION: - setAgentDescription(data.content); - break; - case GENERATE_PROMPT_STREAM_TYPES.AGENT_DISPLAY_NAME: - // Only update if current agent display name is empty - if ( - !currentAgentDisplayName || - currentAgentDisplayName.trim() === "" - ) { - setAgentDisplayName(data.content); - } - break; - } - }, - (error) => { - log.error("Generate prompt stream error:", error); - message.error(t("businessLogic.config.message.generateError")); - }, - () => { - message.success(t("businessLogic.config.message.generateSuccess")); - } - ); - } catch (error) { - log.error("Generate agent error:", error); - message.error(t("businessLogic.config.message.generateError")); - } finally { - setIsGeneratingAgent(false); - } - }; - - // Handle export agent - const handleExportAgent = async () => { - if (!editingAgent) { - message.warning(t("agent.error.noAgentSelected")); - return; - } - - try { - const result = await exportAgent(Number(editingAgent.id)); - if (result.success) { - // Handle backend returned string or object - let exportData = result.data; - if (typeof exportData === "string") { - try { - exportData = JSON.parse(exportData); - } catch (e) { - // If parsing fails, it means it's already a string, export directly - } - } - const blob = new Blob([JSON.stringify(exportData, null, 2)], { - type: "application/json", - }); - - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `${editingAgent.name}_config.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - message.success(t("businessLogic.config.message.agentExportSuccess")); - } else { - message.error( - result.message || t("businessLogic.config.error.agentExportFailed") - ); - } - } catch (error) { - log.error(t("agentConfig.agents.exportFailed"), error); - message.error(t("businessLogic.config.error.agentExportFailed")); - } - }; - - // Handle delete agent - const handleDeleteAgent = async () => { - if (!editingAgent) { - message.warning(t("agent.error.noAgentSelected")); - return; - } - - try { - const result = await deleteAgent(Number(editingAgent.id)); - if (result.success) { - message.success( - t("businessLogic.config.message.agentDeleteSuccess", { - name: editingAgent.name, - }) - ); - // Reset editing state - setIsEditingAgent(false); - setEditingAgent(null); - setBusinessLogic(""); - setDutyContent(""); - setConstraintContent(""); - setFewShotsContent(""); - setAgentName(""); - setAgentDescription(""); - // Notify AgentManagementConfig to refresh agent list - window.dispatchEvent( - new CustomEvent("refreshAgentList") as AgentRefreshEvent - ); - } else { - message.error( - result.message || t("businessLogic.config.message.agentDeleteFailed") - ); - } - } catch (error) { - log.error(t("agentConfig.agents.deleteFailed"), error); - message.error(t("businessLogic.config.message.agentDeleteFailed")); - } - }; - - // Load tools when page is loaded - useEffect(() => { - if (!canAccessProtectedData) { - return; - } - // Check embedding configuration once when entering the page - try { - const modelConfig = configStore.getModelConfig(); - setIsEmbeddingConfigured(!!modelConfig?.embedding?.modelName); - } catch (e) { - setIsEmbeddingConfigured(false); - } - - const loadTools = async () => { - try { - const result = await fetchTools(); - if (result.success) { - setTools(result.data); - // If the tool list is empty and auto scan hasn't been triggered, trigger scan once - if (result.data.length === 0 && !hasAutoScanned.current) { - hasAutoScanned.current = true; - // Mark as auto scanned - const scanResult = await updateToolList(); - if (!scanResult.success) { - message.error(t("toolManagement.message.refreshFailed")); - return; - } - message.success(t("toolManagement.message.refreshSuccess")); - // After scan, fetch the tool list again - const reFetch = await fetchTools(); - if (reFetch.success) { - setTools(reFetch.data); - } - } - } else { - message.error(result.message); - } - } catch (error) { - log.error(t("agent.error.loadTools"), error); - message.error(t("agent.error.loadToolsRetry")); - } - }; - - loadTools(); - }, [canAccessProtectedData, t]); - - // Get agent list - const fetchAgents = async () => { - setLoadingAgents(true); - try { - const result = await fetchAgentList(); - if (result.success) { - // fetchAgentList now returns AgentBasicInfo[], so we just set the subAgentList - setSubAgentList(result.data); - // Clear other states since we don't have detailed info yet - setMainAgentId(null); - // No longer manually clear enabledAgentIds, completely rely on backend returned sub_agent_id_list - setMainAgentModel(null); - setMainAgentMaxStep(5); - setBusinessLogic(""); - setDutyContent(""); - setConstraintContent(""); - setFewShotsContent(""); - setBusinessLogicError(false); - // Clear agent name and description only when not in editing mode - if (!isEditingAgent) { - setAgentName(""); - setAgentDescription(""); - setAgentDisplayName(""); - } - } else { - message.error(result.message || t("agent.error.fetchAgentList")); - } - } catch (error) { - log.error(t("agent.error.fetchAgentList"), error); - message.error(t("agent.error.fetchAgentListRetry")); - } finally { - setLoadingAgents(false); - } - }; - - // Get agent list when component is loaded - useEffect(() => { - if (!canAccessProtectedData) { - return; - } - fetchAgents(); - }, [canAccessProtectedData]); - - // Use refs to store latest values to avoid recreating event listener - const businessLogicRef = useRef(businessLogic); - const dutyContentRef = useRef(dutyContent); - const constraintContentRef = useRef(constraintContent); - const fewShotsContentRef = useRef(fewShotsContent); - - // Update refs when values change - useEffect(() => { - businessLogicRef.current = businessLogic; - }, [businessLogic]); - useEffect(() => { - dutyContentRef.current = dutyContent; - }, [dutyContent]); - useEffect(() => { - constraintContentRef.current = constraintContent; - }, [constraintContent]); - useEffect(() => { - fewShotsContentRef.current = fewShotsContent; - }, [fewShotsContent]); - - // Memoize event handler to avoid recreating listener - const handleGetAgentConfigData = useCallback(() => { - // Check if there is system prompt content - let hasSystemPrompt = false; - - // If any of the segmented prompts has content, consider it as having system prompt - if (dutyContentRef.current && dutyContentRef.current.trim() !== "") { - hasSystemPrompt = true; - } else if ( - constraintContentRef.current && - constraintContentRef.current.trim() !== "" - ) { - hasSystemPrompt = true; - } else if ( - fewShotsContentRef.current && - fewShotsContentRef.current.trim() !== "" - ) { - hasSystemPrompt = true; - } - - // Send the current configuration data to the main page - const eventData: AgentConfigDataResponse = { - businessLogic: businessLogicRef.current, - systemPrompt: hasSystemPrompt ? "has_content" : "", - }; - - window.dispatchEvent( - new CustomEvent("agentConfigDataResponse", { - detail: eventData, - }) as AgentConfigCustomEvent - ); - }, []); // Empty deps - handler uses refs for latest values - - // Add event listener to respond to the data request from the main page - useEffect(() => { - window.addEventListener("getAgentConfigData", handleGetAgentConfigData); - - return () => { - window.removeEventListener( - "getAgentConfigData", - handleGetAgentConfigData - ); - }; - }, [handleGetAgentConfigData]); - - const handleEditingStateChange = (isEditing: boolean, agent: any) => { - setIsEditingAgent(isEditing); - setEditingAgent(agent); - - // When starting to edit agent, set agent name and description to the right-side name description box - if (isEditing && agent) { - setAgentName(agent.name || ""); - setAgentDescription(agent.description || ""); - setAgentAuthor(agent.author || ""); - setBusinessLogicError(false); - } else if (!isEditing) { - // When stopping editing, clear name description box - setAgentName(""); - setAgentDescription(""); - setAgentDisplayName(""); - setAgentAuthor(""); - setBusinessLogicError(false); - } - }; - - const getCurrentAgentId = () => { - // In edit mode, always use the currently editing agent's id - if (isEditingAgent && editingAgent) { - return parseInt(editingAgent.id); - } - // In create mode, the agent has not been persisted yet, so there should be no agent_id - if (isCreatingNewAgent) { - return undefined; - } - // Fallback to mainAgentId when not creating and not explicitly editing - return mainAgentId ? parseInt(mainAgentId) : undefined; - }; - - // Handle exit creation mode - should clear cache - const handleExitCreation = () => { - setIsCreatingNewAgent(false); - setBusinessLogic(""); - setDutyContent(""); - setConstraintContent(""); - setFewShotsContent(""); - setAgentName(""); - setAgentDescription(""); - setAgentAuthor(""); - setBusinessLogicError(false); - }; - - // Refresh tool list - const handleToolsRefresh = async (showSuccessMessage = true) => { - try { - const result = await fetchTools(); - if (result.success) { - setTools(result.data); - // Only show success message if explicitly requested (e.g., manual refresh) - // Don't show message when auto-refreshing after MCP tool add/delete - if (showSuccessMessage) { - message.success(t("agentConfig.tools.refreshSuccess")); - } - return result.data; // Return the updated tools list - } else { - message.error(t("agentConfig.tools.refreshFailed")); - return null; - } - } catch (error) { - log.error(t("agentConfig.tools.refreshFailedDebug"), error); - message.error(t("agentConfig.tools.refreshFailed")); - return null; - } - }; - - return ( - -
-
-
- { - setBusinessLogic(value); - // Clear error when user starts typing - if (businessLogicError && value.trim() !== "") { - setBusinessLogicError(false); - } - }} - businessLogicError={businessLogicError} - selectedTools={selectedTools} - setSelectedTools={setSelectedTools} - isCreatingNewAgent={isCreatingNewAgent} - setIsCreatingNewAgent={setIsCreatingNewAgent} - mainAgentModel={mainAgentModel} - setMainAgentModel={setMainAgentModel} - mainAgentModelId={mainAgentModelId} - setMainAgentModelId={setMainAgentModelId} - mainAgentMaxStep={mainAgentMaxStep} - setMainAgentMaxStep={setMainAgentMaxStep} - businessLogicModel={businessLogicModel} - setBusinessLogicModel={setBusinessLogicModel} - businessLogicModelId={businessLogicModelId} - setBusinessLogicModelId={setBusinessLogicModelId} - tools={tools} - subAgentList={subAgentList} - loadingAgents={loadingAgents} - mainAgentId={mainAgentId} - setMainAgentId={setMainAgentId} - setSubAgentList={setSubAgentList} - enabledAgentIds={enabledAgentIds} - setEnabledAgentIds={setEnabledAgentIds} - onEditingStateChange={handleEditingStateChange} - onToolsRefresh={handleToolsRefresh} - dutyContent={dutyContent} - setDutyContent={setDutyContent} - constraintContent={constraintContent} - setConstraintContent={setConstraintContent} - fewShotsContent={fewShotsContent} - setFewShotsContent={setFewShotsContent} - agentName={agentName} - setAgentName={setAgentName} - agentDescription={agentDescription} - setAgentDescription={setAgentDescription} - agentDisplayName={agentDisplayName} - setAgentDisplayName={setAgentDisplayName} - agentAuthor={agentAuthor} - setAgentAuthor={setAgentAuthor} - isGeneratingAgent={isGeneratingAgent} - // SystemPromptDisplay related props - onDebug={() => { - setIsDebugDrawerOpen(true); - }} - getCurrentAgentId={getCurrentAgentId} - onGenerateAgent={handleGenerateAgent} - onExportAgent={handleExportAgent} - onDeleteAgent={handleDeleteAgent} - editingAgent={editingAgent} - onExitCreation={handleExitCreation} - isEmbeddingConfigured={isEmbeddingConfigured} - onUnsavedChange={(dirty) => { - unsavedRef.current = dirty; - }} - registerSaveHandler={(handler) => { - saveHandlerRef.current = handler; - }} - registerReloadHandler={(handler) => { - reloadHandlerRef.current = handler; - }} - /> -
-
-
- - {/* Debug drawer */} - setIsDebugDrawerOpen(false)} - open={isDebugDrawerOpen} - width={LAYOUT_CONFIG.DRAWER_WIDTH} - destroyOnClose={true} - styles={{ - body: { - padding: 0, - height: "100%", - overflow: "hidden", - }, - }} - > -
- -
-
-
- ); -}); diff --git a/frontend/app/[locale]/agents/AgentSetupOrchestrator.tsx b/frontend/app/[locale]/agents/AgentSetupOrchestrator.tsx new file mode 100644 index 000000000..99f3c7d3b --- /dev/null +++ b/frontend/app/[locale]/agents/AgentSetupOrchestrator.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Card, Row, Col } from "antd"; + +import AgentManageComp from "./components/AgentManageComp"; +import AgentConfigComp from "./components/AgentConfigComp"; +import AgentInfoComp from "./components/AgentInfoComp"; + +interface AgentSetupOrchestratorProps { + onImportAgent?: () => void; +} + +export default function AgentSetupOrchestrator({ + onImportAgent, +}: AgentSetupOrchestratorProps) { + return ( + + + {/* Three-column layout using Ant Design Grid */} + + {/* Left column: Agent Management */} + + + + + {/* Middle column: Agent Config */} + + + + + {/* Right column: Agent Info */} + + + + + + ); +} diff --git a/frontend/app/[locale]/agents/AgentsContent.tsx b/frontend/app/[locale]/agents/AgentsContent.tsx deleted file mode 100644 index 84f29ac28..000000000 --- a/frontend/app/[locale]/agents/AgentsContent.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; - -import React, {useState, useEffect, useRef, forwardRef, useImperativeHandle} from "react"; -import {motion} from "framer-motion"; - -import {useSetupFlow} from "@/hooks/useSetupFlow"; -import { - ConnectionStatus, -} from "@/const/modelConfig"; - -import AgentConfig, {AgentConfigHandle} from "./AgentConfiguration"; - -interface AgentsContentProps { - /** Whether currently saving */ - isSaving?: boolean; - /** Connection status */ - connectionStatus?: ConnectionStatus; - /** Is checking connection */ - isCheckingConnection?: boolean; - /** Check connection callback */ - onCheckConnection?: () => void; - /** Callback to expose connection status */ - onConnectionStatusChange?: (status: ConnectionStatus) => void; - /** Callback to expose saving state */ - onSavingStateChange?: (isSaving: boolean) => void; -} - -/** - * AgentsContent - Main component for agent configuration - * Can be used in setup flow or as standalone page - */ -export default forwardRef(function AgentsContent({ - isSaving: externalIsSaving, - connectionStatus: externalConnectionStatus, - isCheckingConnection: externalIsCheckingConnection, - onCheckConnection: externalOnCheckConnection, - onConnectionStatusChange, - onSavingStateChange, -}: AgentsContentProps, ref) { - const agentConfigRef = useRef(null); - - // Use custom hook for common setup flow logic - const { - canAccessProtectedData, - pageVariants, - pageTransition, - } = useSetupFlow({ - requireAdmin: true, - externalConnectionStatus, - externalIsCheckingConnection, - onCheckConnection: externalOnCheckConnection, - onConnectionStatusChange, - nonAdminRedirect: "/setup/knowledges", - }); - - const [internalIsSaving, setInternalIsSaving] = useState(false); - const isSaving = externalIsSaving ?? internalIsSaving; - - // Expose AgentConfigHandle methods to parent - useImperativeHandle(ref, () => ({ - hasUnsavedChanges: () => agentConfigRef.current?.hasUnsavedChanges?.() ?? false, - saveAllChanges: async () => { - if (agentConfigRef.current?.saveAllChanges) { - await agentConfigRef.current.saveAllChanges(); - } - }, - reloadCurrentAgentData: async () => { - if (agentConfigRef.current?.reloadCurrentAgentData) { - await agentConfigRef.current.reloadCurrentAgentData(); - } - }, - }), []); - - // Update external saving state - useEffect(() => { - onSavingStateChange?.(isSaving); - }, [isSaving, onSavingStateChange]); - - return ( - <> - -
- {canAccessProtectedData ? ( - - ) : null} -
-
- - ); -}); - diff --git a/frontend/app/[locale]/agents/components/AgentConfigComp.tsx b/frontend/app/[locale]/agents/components/AgentConfigComp.tsx new file mode 100644 index 000000000..8f8ce4bc3 --- /dev/null +++ b/frontend/app/[locale]/agents/components/AgentConfigComp.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { App, Button, Row, Col, Flex, Tooltip, Badge, Divider } from "antd"; +import CollaborativeAgent from "./agentConfig/CollaborativeAgent"; +import ToolManagement from "./agentConfig/ToolManagement"; + +import { updateToolList } from "@/services/mcpService"; +import { useAgentConfigStore } from "@/stores/agentConfigStore"; +import { useToolList } from "@/hooks/agent/useToolList"; +import McpConfigModal from "./agentConfig/McpConfigModal"; + +import { RefreshCw, Lightbulb, Plug } from "lucide-react"; + +interface AgentConfigCompProps {} + +export default function AgentConfigComp({}: AgentConfigCompProps) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + + // Get state from store + const currentAgentId = useAgentConfigStore((state) => state.currentAgentId); + + const isCreatingMode = useAgentConfigStore((state) => state.isCreatingMode); + + const editable = !!(currentAgentId || isCreatingMode); + + const [isMcpModalOpen, setIsMcpModalOpen] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + + // Use tool list hook for data management + const { groupedTools, invalidate } = useToolList(); + + const handleRefreshTools = useCallback(async () => { + setIsRefreshing(true); + try { + // Step 1: Update backend tool status, rescan MCP and local tools + const updateResult = await updateToolList(); + if (!updateResult.success) { + message.warning(t("toolManagement.message.updateStatusFailed")); + } + + // Step 2: Invalidate and refresh tool list cache + invalidate(); + message.success(t("toolManagement.message.refreshSuccess")); + } catch (error) { + message.error(t("toolManagement.message.refreshFailedRetry")); + } finally { + setIsRefreshing(false); + } + }, [invalidate]); + + + return ( + <> + {/* Import handled by Ant Design Upload (no hidden input required) */} + + + + + +

+ {t("businessLogic.config.title")} +

+
+ +
+ + + + + + + + + + +

+ {t("toolPool.title")} +

+ + {t("toolPool.tooltip.functionGuide")} + + } + color="#ffffff" + styles={{ + root: { + backgroundColor: "#ffffff", + color: "#374151", + border: "1px solid #e5e7eb", + borderRadius: "6px", + boxShadow: + "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", + padding: "12px", + maxWidth: "800px", + minWidth: "700px", + width: "fit-content", + }, + }} + > + + +
+ + + + + + + +
+ + + + + + + + +
+ + setIsMcpModalOpen(false)} + /> + + ); +} diff --git a/frontend/app/[locale]/agents/components/AgentInfoComp.tsx b/frontend/app/[locale]/agents/components/AgentInfoComp.tsx new file mode 100644 index 000000000..d4832b6f3 --- /dev/null +++ b/frontend/app/[locale]/agents/components/AgentInfoComp.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Row, Col, Flex, Badge, Divider, Button, Drawer, App } from "antd"; +import { Bug, Save, Info } from "lucide-react"; + +import { AGENT_SETUP_LAYOUT_DEFAULT } from "@/const/agentConfig"; +import { useAgentConfigStore } from "@/stores/agentConfigStore"; +import { useSaveGuard } from "@/hooks/agent/useSaveGuard"; +import { AgentBusinessInfo, AgentProfileInfo } from "@/types/agentConfig"; + +import AgentGenerateDetail from "./agentInfo/AgentGenerateDetail"; +import DebugConfig from "./agentInfo/DebugConfig"; + +export interface AgentInfoCompProps {} + +export default function AgentInfoComp({}: AgentInfoCompProps) { + const { t } = useTranslation("common"); + + // Get data from store + const { editedAgent, updateBusinessInfo, updateProfileInfo, isCreatingMode } = + useAgentConfigStore(); + + // Get state from store + const currentAgentId = useAgentConfigStore((state) => state.currentAgentId); + + const editable = !!(currentAgentId || isCreatingMode); + + // Save guard hook + const saveGuard = useSaveGuard(); + + // Debug drawer state + const [isDebugDrawerOpen, setIsDebugDrawerOpen] = useState(false); + + // Handle business info updates + const handleUpdateBusinessInfo = (updates: AgentBusinessInfo) => { + updateBusinessInfo(updates); + }; + + // Handle profile info updates + const handleUpdateProfile = (updates: AgentProfileInfo) => { + updateProfileInfo(updates); + }; + + return ( + <> + { + + + + + +

+ {t("guide.steps.describeBusinessLogic.title")} +

+
+ +
+ + + + + + + + + + + + + + + + + + +
+ } + + {!editable && ( + +
+
+
+ +

+ {t("systemPrompt.nonEditing.title")} +

+
+

+ {t("systemPrompt.nonEditing.subtitle")} +

+
+
+
+ )} + + {/* Debug drawer */} + setIsDebugDrawerOpen(false)} + open={isDebugDrawerOpen} + styles={{ + wrapper: { + width: AGENT_SETUP_LAYOUT_DEFAULT.DRAWER_WIDTH, + }, + body: { + padding: 0, + height: "100%", + overflow: "hidden", + }, + }} + > +
+ +
+
+ + ); +} diff --git a/frontend/app/[locale]/agents/components/AgentManageComp.tsx b/frontend/app/[locale]/agents/components/AgentManageComp.tsx new file mode 100644 index 000000000..7e5afe3c9 --- /dev/null +++ b/frontend/app/[locale]/agents/components/AgentManageComp.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { + App, + Row, + Col, + Flex, + Tooltip, + Badge, + Divider, + Upload, + theme, +} from "antd"; +import { FileInput, Plus, X } from "lucide-react"; + +import { Agent } from "@/types/agentConfig"; +import AgentList from "./agentManage/AgentList"; +import { useSaveGuard } from "@/hooks/agent/useSaveGuard"; +import { useCallback } from "react"; +import { useAgentConfigStore } from "@/stores/agentConfigStore"; +import { importAgent } from "@/services/agentConfigService"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAgentList } from "@/hooks/agent/useAgentList"; +import { useAgentInfo } from "@/hooks/agent/useAgentInfo"; +import log from "@/lib/logger"; +import { useState, useEffect } from "react"; + +interface AgentManageCompProps { + onImportAgent?: () => void; +} + +export default function AgentManageComp({ + onImportAgent, +}: AgentManageCompProps) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + + // Get state from store + const currentAgentId = useAgentConfigStore((state) => state.currentAgentId); + const hasUnsavedChanges = useAgentConfigStore( + (state) => state.hasUnsavedChanges + ); + const isCreatingMode = useAgentConfigStore((state) => state.isCreatingMode); + const setCurrentAgent = useAgentConfigStore((state) => state.setCurrentAgent); + const enterCreateMode = useAgentConfigStore((state) => state.enterCreateMode); + const reset = useAgentConfigStore((state) => state.reset); + + // Unsaved changes guard + const checkUnsavedChanges = useSaveGuard(); + + // Handle unsaved changes check and agent switching + const handleAgentSwitch = useCallback( + async (agentDetail: any) => { + const canSwitch = await checkUnsavedChanges.saveWithModal(); + if (canSwitch) { + setCurrentAgent(agentDetail); + } + }, + [checkUnsavedChanges] + ); + + const editable = currentAgentId || isCreatingMode; + + // Shared agent list via React Query + const { agents: agentList, isLoading: loading, refetch } = useAgentList(); + const queryClient = useQueryClient(); + + // State for selected agent info loading + const [selectedAgentId, setSelectedAgentId] = useState(null); + + const { + data: agentDetail, + isLoading: agentInfoLoading, + error: agentInfoError, + } = useAgentInfo(selectedAgentId); + + const importAgentMutation = useMutation({ + mutationFn: (agentData: any) => importAgent(agentData), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agents"] }), + }); + + // Handle agent detail loading completion + useEffect(() => { + if ( + selectedAgentId && + agentDetail && + !agentInfoLoading && + !agentInfoError + ) { + // Handle agent switch with unsaved changes check + handleAgentSwitch(agentDetail); + setSelectedAgentId(null); + } else if (selectedAgentId && agentInfoError && !agentInfoLoading) { + // Handle error + log.error("Failed to load agent detail:", agentInfoError); + message.error(t("agentConfig.agents.detailsLoadFailed")); + setSelectedAgentId(null); + } + }, [ + selectedAgentId, + agentDetail, + agentInfoLoading, + agentInfoError, + handleAgentSwitch, + message, + t, + ]); + + // Handle select agent + const handleSelectAgent = async (agent: Agent) => { + // If already selected, deselect it + if ( + currentAgentId !== null && + String(currentAgentId) === String(agent.id) + ) { + const canDeselect = await checkUnsavedChanges.saveWithModal(); + if (canDeselect) { + setCurrentAgent(null); + } + return; + } + + // Set selected agent id to trigger the hook + setSelectedAgentId(Number(agent.id)); + }; + + return ( + <> + {/* Import handled by Ant Design Upload (no hidden input required) */} + + + + + +

+ {t("subAgentPool.management")} +

+
+ +
+ + + + + + {isCreatingMode ? ( + +
+ + + + +
+ {t("subAgentPool.button.exitCreate")} +
+
+ {t("subAgentPool.description.exitCreate")} +
+
+
+
+
+ ) : ( + +
+ + + + +
+ {t("subAgentPool.button.create")} +
+
+ {t("subAgentPool.description.createAgent")} +
+
+
+
+
+ )} + + + + +
+ + + + +
+ {t("subAgentPool.button.import")} +
+
+ {t("subAgentPool.description.importAgent")} +
+
+
+
+
+ +
+ +
+ { + if (currentAgentId === agentId) { + setCurrentAgent(null); + } + }} + /> +
+
+ + ); +} diff --git a/frontend/app/[locale]/agents/components/AgentSetupOrchestrator.tsx b/frontend/app/[locale]/agents/components/AgentSetupOrchestrator.tsx deleted file mode 100644 index e5718c98b..000000000 --- a/frontend/app/[locale]/agents/components/AgentSetupOrchestrator.tsx +++ /dev/null @@ -1,2446 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { TFunction } from "i18next"; - -import { App, Modal, Button, Tooltip, Row, Col } from "antd"; -import { WarningFilled } from "@ant-design/icons"; - -import { TooltipProvider } from "@/components/ui/tooltip"; -import { - fetchAgentList, - updateAgent, - deleteAgent, - exportAgent, - searchAgentInfo, - searchToolConfig, - updateToolConfig, -} from "@/services/agentConfigService"; -import { useAgentImport, ImportAgentData } from "@/hooks/useAgentImport"; -import { - Agent, - AgentSetupOrchestratorProps, - Tool, - ToolParam, -} from "@/types/agentConfig"; -import AgentImportWizard from "@/components/agent/AgentImportWizard"; -import log from "@/lib/logger"; -import { useConfirmModal } from "@/hooks/useConfirmModal"; -import { useAuth } from "@/hooks/useAuth"; - -import SubAgentPool from "./agent/SubAgentPool"; -import CollaborativeAgentDisplay from "./agent/CollaborativeAgentDisplay"; -import { MemoizedToolPool } from "./tool/ToolPool"; -import PromptManager from "./PromptManager"; -import AgentCallRelationshipModal from "./agent/AgentCallRelationshipModal"; -import SaveConfirmModal from "./SaveConfirmModal"; - -type PendingAction = () => void | Promise; - -/** - * Agent Setup Orchestrator - Main coordination component for agent setup workflow - */ -export default function AgentSetupOrchestrator({ - businessLogic, - setBusinessLogic, - businessLogicError = false, - selectedTools, - setSelectedTools, - isCreatingNewAgent, - setIsCreatingNewAgent, - mainAgentModel, - setMainAgentModel, - mainAgentModelId, - setMainAgentModelId, - mainAgentMaxStep, - setMainAgentMaxStep, - businessLogicModel, - setBusinessLogicModel, - businessLogicModelId, - setBusinessLogicModelId, - tools, - subAgentList = [], - loadingAgents = false, - mainAgentId, - setMainAgentId, - setSubAgentList, - enabledAgentIds, - setEnabledAgentIds, - onEditingStateChange, - onToolsRefresh, - dutyContent, - setDutyContent, - constraintContent, - setConstraintContent, - fewShotsContent, - setFewShotsContent, - agentName, - setAgentName, - agentDescription, - setAgentDescription, - agentDisplayName, - setAgentDisplayName, - agentAuthor, - setAgentAuthor, - isGeneratingAgent = false, - // SystemPromptDisplay related props - onDebug, - getCurrentAgentId, - onGenerateAgent, - onExportAgent, - onDeleteAgent, - editingAgent: editingAgentFromParent, - onExitCreation, - isEmbeddingConfigured, - onUnsavedChange, - registerSaveHandler, - registerReloadHandler, -}: AgentSetupOrchestratorProps) { - const { user, isSpeedMode } = useAuth(); - const [enabledToolIds, setEnabledToolIds] = useState([]); - const [isLoadingTools, setIsLoadingTools] = useState(false); - const [isImporting, setIsImporting] = useState(false); - const [toolConfigDrafts, setToolConfigDrafts] = useState< - Record - >({}); - const [pendingImportData, setPendingImportData] = useState<{ - agentInfo: any; - } | null>(null); - const [importingAction, setImportingAction] = useState< - "force" | "regenerate" | null - >(null); - - // Agent import wizard states - const [importWizardVisible, setImportWizardVisible] = useState(false); - const [importWizardData, setImportWizardData] = useState(null); - // Use generation state passed from parent component, not local state - - - - - const lastProcessedAgentIdForEmbedding = useRef(null); - - // Flag to track if we need to refresh enabledToolIds after tools update - const shouldRefreshEnabledToolIds = useRef(false); - // Track previous tools prop to detect when it's updated - const previousToolsRef = useRef(undefined); - - // Call relationship modal state - const [callRelationshipModalVisible, setCallRelationshipModalVisible] = - useState(false); - - // Edit agent related status - const [isEditingAgent, setIsEditingAgent] = useState(false); - const [editingAgent, setEditingAgent] = useState(null); - const activeEditingAgent = editingAgentFromParent || editingAgent; - const isAgentUnavailable = activeEditingAgent?.is_available === false; - const agentUnavailableReasons = - isAgentUnavailable && Array.isArray(activeEditingAgent?.unavailable_reasons) - ? (activeEditingAgent?.unavailable_reasons as string[]) - : []; - const mergeAgentAvailabilityMetadata = useCallback( - (detail: Agent, fallback?: Agent | null): Agent => { - const detailReasons = Array.isArray(detail?.unavailable_reasons) - ? detail.unavailable_reasons - : []; - const fallbackReasons = Array.isArray(fallback?.unavailable_reasons) - ? fallback!.unavailable_reasons! - : []; - const normalizedReasons = - detailReasons.length > 0 ? detailReasons : fallbackReasons; - - const normalizedAvailability = - normalizedReasons.length > 0 - ? false - : typeof detail?.is_available === "boolean" - ? detail.is_available - : typeof fallback?.is_available === "boolean" - ? fallback.is_available - : detail?.is_available; - - return { - ...detail, - unavailable_reasons: normalizedReasons, - is_available: normalizedAvailability, - }; - }, - [] - ); - - const numericMainAgentId = - mainAgentId !== null && - mainAgentId !== undefined && - String(mainAgentId).trim() !== "" - ? Number(mainAgentId) - : null; - const hasPersistedMainAgentId = - typeof numericMainAgentId === "number" && - !Number.isNaN(numericMainAgentId) && - numericMainAgentId > 0; - const isDraftCreationSession = - isCreatingNewAgent && !hasPersistedMainAgentId && !isEditingAgent; - - // Add a flag to track if it has been initialized to avoid duplicate calls - const hasInitialized = useRef(false); - // Baseline snapshot for change detection - const baselineRef = useRef(null); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [pendingAction, setPendingAction] = useState( - null - ); - const [isSaveConfirmOpen, setIsSaveConfirmOpen] = useState(false); - // When true, bypass unsaved-check to avoid reopening the confirm modal during a confirmed switch - const skipUnsavedCheckRef = useRef(false); - // Context for confirmation modal behavior - const [confirmContext, setConfirmContext] = useState<"switch" | "exitCreate">( - "switch" - ); - - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const { confirm } = useConfirmModal(); - - // Common refresh agent list function, moved to the front to avoid hoisting issues - const refreshAgentList = async (t: TFunction, clearTools: boolean = true) => { - if (clearTools) { - setIsLoadingTools(true); - // Clear the tool selection status when loading starts - setSelectedTools([]); - setEnabledToolIds([]); - } - - try { - const result = await fetchAgentList(); - if (result.success) { - // Update agent list with basic info only - setSubAgentList(result.data); - // Removed success message to avoid duplicate notifications - } else { - message.error( - result.message || t("businessLogic.config.error.agentListFailed") - ); - } - } catch (error) { - log.error(t("agentConfig.agents.listFetchFailedDebug"), error); - message.error(t("businessLogic.config.error.agentListFailed")); - } finally { - if (clearTools) { - setIsLoadingTools(false); - } - } - }; - // Build current snapshot for dirty detection - const currentSnapshot = useMemo( - () => ({ - agentId: - (isEditingAgent && editingAgent ? editingAgent.id : mainAgentId) ?? - null, - agentName: agentName || "", - agentDescription: agentDescription || "", - agentDisplayName: agentDisplayName || "", - businessLogic: businessLogic || "", - dutyContent: dutyContent || "", - constraintContent: constraintContent || "", - fewShotsContent: fewShotsContent || "", - mainAgentModelId: mainAgentModelId ?? null, - businessLogicModelId: businessLogicModelId ?? null, - mainAgentMaxStep: Number(mainAgentMaxStep ?? 5), - enabledAgentIds: Array.from( - new Set( - (enabledAgentIds || []).map((n) => Number(n)).filter((n) => !isNaN(n)) - ) - ).sort((a, b) => a - b), - enabledToolIds: Array.from( - new Set( - (enabledToolIds || []).map((n) => Number(n)).filter((n) => !isNaN(n)) - ) - ).sort((a, b) => a - b), - selectedToolIds: Array.from( - new Set( - (selectedTools || []) - .map((t: any) => Number(t.id)) - .filter((id: number) => !isNaN(id)) - ) - ).sort((a, b) => a - b), - }), - [ - isEditingAgent, - editingAgent, - mainAgentId, - agentName, - agentDescription, - agentDisplayName, - businessLogic, - dutyContent, - constraintContent, - fewShotsContent, - mainAgentModelId, - businessLogicModelId, - mainAgentMaxStep, - enabledAgentIds, - enabledToolIds, - selectedTools, - ] - ); - - // Initialize baseline when entering edit mode or loading agent details - useEffect(() => { - if (isEditingAgent && editingAgent) { - baselineRef.current = { ...currentSnapshot }; - setHasUnsavedChanges(false); - } - }, [isEditingAgent, editingAgent]); - - useEffect(() => { - if (!isDraftCreationSession) { - setToolConfigDrafts({}); - } - }, [isDraftCreationSession]); - - // Initialize baseline when entering create mode so draft changes don't attach to previous agent - useEffect(() => { - if (isCreatingNewAgent && !isEditingAgent) { - // Ensure state clears have applied, then capture a clean baseline - setTimeout(() => { - baselineRef.current = { ...currentSnapshot }; - setHasUnsavedChanges(false); - onUnsavedChange?.(false); - }, 0); - } - }, [isCreatingNewAgent, isEditingAgent, currentSnapshot, onUnsavedChange]); - - // Track changes to mark dirty - useEffect(() => { - if (!baselineRef.current) return; - const b = baselineRef.current; - const c = currentSnapshot; - const shallowEqual = - String(b.agentId ?? "") === String(c.agentId ?? "") && - b.agentName === c.agentName && - b.agentDescription === c.agentDescription && - b.agentDisplayName === c.agentDisplayName && - b.businessLogic === c.businessLogic && - b.dutyContent === c.dutyContent && - b.constraintContent === c.constraintContent && - b.fewShotsContent === c.fewShotsContent && - String(b.mainAgentModelId ?? "") === String(c.mainAgentModelId ?? "") && - String(b.businessLogicModelId ?? "") === - String(c.businessLogicModelId ?? "") && - Number(b.mainAgentMaxStep ?? 5) === Number(c.mainAgentMaxStep ?? 5) && - JSON.stringify(b.enabledAgentIds || []) === - JSON.stringify(c.enabledAgentIds || []) && - JSON.stringify(b.enabledToolIds || []) === - JSON.stringify(c.enabledToolIds || []) && - JSON.stringify(b.selectedToolIds || []) === - JSON.stringify(c.selectedToolIds || []); - setHasUnsavedChanges(!shallowEqual); - onUnsavedChange?.(!shallowEqual); - }, [currentSnapshot]); - - // Reload current agent's complete data from backend - const reloadCurrentAgentData = useCallback(async () => { - const currentAgentId = - (isEditingAgent && editingAgent ? editingAgent.id : mainAgentId) ?? null; - - if (!currentAgentId) { - // If no agent ID, just reset unsaved state - setHasUnsavedChanges(false); - onUnsavedChange?.(false); - return; - } - - try { - // Call query interface to get complete Agent information - const result = await searchAgentInfo(Number(currentAgentId)); - - if (!result.success || !result.data) { - message.error( - result.message || t("businessLogic.config.error.agentDetailFailed") - ); - return; - } - - const agentDetail = mergeAgentAvailabilityMetadata( - result.data as Agent, - editingAgent - ); - setEditingAgent(agentDetail); - - // Reload all agent data to match backend state - setAgentName?.(agentDetail.name || ""); - setAgentDescription?.(agentDetail.description || ""); - setAgentDisplayName?.(agentDetail.display_name || ""); - setAgentAuthor?.(agentDetail.author || ""); - - // Load Agent data to interface - setMainAgentModel(agentDetail.model); - setMainAgentModelId(agentDetail.model_id ?? null); - setMainAgentMaxStep(agentDetail.max_step); - setBusinessLogic(agentDetail.business_description || ""); - setBusinessLogicModel(agentDetail.business_logic_model_name || null); - setBusinessLogicModelId(agentDetail.business_logic_model_id || null); - - // Use backend returned sub_agent_id_list to set enabled agent list - if ( - agentDetail.sub_agent_id_list && - agentDetail.sub_agent_id_list.length > 0 - ) { - setEnabledAgentIds( - agentDetail.sub_agent_id_list.map((id: any) => Number(id)) - ); - } else { - setEnabledAgentIds([]); - } - - // Load the segmented prompt content - setDutyContent?.(agentDetail.duty_prompt || ""); - setConstraintContent?.(agentDetail.constraint_prompt || ""); - setFewShotsContent?.(agentDetail.few_shots_prompt || ""); - - // Load Agent tools - // Only set enabledToolIds, let useEffect sync selectedTools from tools array - // This ensures tool objects in selectedTools match the structure in tools array - if (agentDetail.tools && agentDetail.tools.length > 0) { - // Set enabled tool IDs, ensure deduplication - const toolIds = Array.from( - new Set( - agentDetail.tools - .map((tool: any) => Number(tool.id)) - .filter((id: number) => !isNaN(id)) - ) - ).sort((a, b) => a - b); - setEnabledToolIds(toolIds); - // Don't set selectedTools directly - let useEffect handle it based on enabledToolIds - // This ensures tool objects match the structure from tools array - } else { - setEnabledToolIds([]); - // Don't set selectedTools directly - let useEffect handle it - } - - // Refresh agent list to ensure consistency, but don't clear tools to avoid flash - await refreshAgentList(t, false); - - // Update baseline and reset unsaved state after reload - // Use setTimeout to ensure state updates are processed before updating baseline - setTimeout(() => { - // Rebuild snapshot after state updates to get accurate baseline - const updatedSnapshot = { - agentId: Number(currentAgentId), - agentName: agentDetail.name || "", - agentDescription: agentDetail.description || "", - agentDisplayName: agentDetail.display_name || "", - businessLogic: agentDetail.business_description || "", - dutyContent: agentDetail.duty_prompt || "", - constraintContent: agentDetail.constraint_prompt || "", - fewShotsContent: agentDetail.few_shots_prompt || "", - mainAgentModelId: agentDetail.model_id ?? null, - businessLogicModelId: agentDetail.business_logic_model_id ?? null, - mainAgentMaxStep: Number(agentDetail.max_step ?? 5), - enabledAgentIds: (agentDetail.sub_agent_id_list || []) - .map((id: any) => Number(id)) - .sort(), - enabledToolIds: Array.from( - new Set( - (agentDetail.tools || []) - .map((tool: any) => Number(tool.id)) - .filter((id: number) => !isNaN(id)) - ) - ).sort((a, b) => a - b), - selectedToolIds: Array.from( - new Set( - (agentDetail.tools || []) - .map((tool: any) => Number(tool.id)) - .filter((id: number) => !isNaN(id)) - ) - ).sort((a, b) => a - b), - }; - baselineRef.current = updatedSnapshot; - setHasUnsavedChanges(false); - onUnsavedChange?.(false); - }, 200); - } catch (error) { - log.error(t("agentConfig.agents.detailsLoadFailed"), error); - message.error(t("businessLogic.config.error.agentDetailFailed")); - // Even on error, reset unsaved state - setHasUnsavedChanges(false); - onUnsavedChange?.(false); - } - }, [ - isEditingAgent, - editingAgent, - mainAgentId, - currentSnapshot, - t, - refreshAgentList, - onUnsavedChange, - ]); - - // Expose a save function to be reused by confirm modal flows - const saveAllChanges = useCallback(async () => { - await handleSaveNewAgent( - agentName || "", - agentDescription || "", - mainAgentModel, - mainAgentMaxStep, - businessLogic - ); - // Reload data from backend after save to ensure consistency - await reloadCurrentAgentData(); - }, [ - agentName, - agentDescription, - mainAgentModel, - mainAgentMaxStep, - businessLogic, - reloadCurrentAgentData, - ]); - - useEffect(() => { - if (registerSaveHandler) { - registerSaveHandler(saveAllChanges); - } - }, [registerSaveHandler, saveAllChanges]); - - useEffect(() => { - if (registerReloadHandler) { - registerReloadHandler(reloadCurrentAgentData); - } - }, [registerReloadHandler, reloadCurrentAgentData]); - - const confirmOrRun = useCallback( - (action: PendingAction) => { - // In creation mode, always show save confirmation dialog when clicking debug - // Also show when there are unsaved changes - if ((isCreatingNewAgent && !isEditingAgent) || hasUnsavedChanges) { - setPendingAction(() => action); - setConfirmContext("switch"); - setIsSaveConfirmOpen(true); - } else { - void Promise.resolve(action()); - } - }, - [hasUnsavedChanges, isCreatingNewAgent, isEditingAgent] - ); - - const handleToolConfigDraftSave = useCallback( - (updatedTool: Tool) => { - if (!isDraftCreationSession) { - return; - } - setToolConfigDrafts((prev) => ({ - ...prev, - [updatedTool.id]: - updatedTool.initParams?.map((param) => ({ ...param })) || [], - })); - setSelectedTools((prev: Tool[]) => { - if (!prev || prev.length === 0) { - return prev; - } - const index = prev.findIndex((tool) => tool.id === updatedTool.id); - if (index === -1) { - return prev; - } - const next = [...prev]; - next[index] = { - ...updatedTool, - initParams: - updatedTool.initParams?.map((param) => ({ ...param })) || [], - }; - return next; - }); - }, - [isDraftCreationSession, setSelectedTools] - ); - - // Function to directly update enabledAgentIds - const handleUpdateEnabledAgentIds = (newEnabledAgentIds: number[]) => { - setEnabledAgentIds(newEnabledAgentIds); - }; - - // Removed creation-mode sub-agent fetch; creation is deferred until saving - - // Listen for changes in the creation of a new Agent - useEffect(() => { - if (isCreatingNewAgent) { - if (!isEditingAgent) { - // Clear configuration in creating mode - setBusinessLogic(""); - } else { - // In edit mode, data is loaded in handleEditAgent, here validate the form - } - } else { - // When exiting the creation of a new Agent, reset the main Agent configuration - // Only refresh list when exiting creation mode in non-editing mode to avoid flicker when exiting editing mode - if (!isEditingAgent && hasInitialized.current) { - setBusinessLogic(""); - setMainAgentModel(null); - setMainAgentModelId(null); - setMainAgentMaxStep(5); - // Delay refreshing agent list to avoid jumping - setTimeout(() => { - refreshAgentList(t); - }, 200); - } - // Sign that has been initialized - hasInitialized.current = true; - } - }, [isCreatingNewAgent, isEditingAgent, mainAgentId]); - - const applyDraftParamsToTool = useCallback( - (tool: Tool): Tool => { - if (!isDraftCreationSession) { - return tool; - } - const draft = toolConfigDrafts[tool.id]; - if (!draft || draft.length === 0) { - return tool; - } - return { - ...tool, - initParams: draft.map((param) => ({ ...param })), - }; - }, - [isDraftCreationSession, toolConfigDrafts] - ); - - // Listen for changes in the tool status, update the selected tool - useEffect(() => { - if (!tools || isLoadingTools) return; - // Allow empty enabledToolIds array (it's valid when no tools are selected) - if (enabledToolIds === undefined || enabledToolIds === null) return; - - // Filter out unavailable tools (is_available === false) to prevent deleted MCP tools from showing - const enabledTools = tools - .filter( - (tool) => - enabledToolIds.includes(Number(tool.id)) && - tool.is_available !== false - ) - .map((tool) => applyDraftParamsToTool(tool)); - - setSelectedTools(enabledTools); - }, [ - tools, - enabledToolIds, - isLoadingTools, - applyDraftParamsToTool, - setSelectedTools, - ]); - - // Auto-unselect knowledge_base_search if embedding is not configured - useEffect(() => { - if (isEmbeddingConfigured) return; - if (!tools || tools.length === 0) return; - - const kbTool = tools.find((tool) => tool.name === "knowledge_base_search"); - if (!kbTool) return; - - const currentAgentId = ( - isEditingAgent && editingAgent - ? Number(editingAgent.id) - : mainAgentId - ? Number(mainAgentId) - : undefined - ) as number | undefined; - - if (!currentAgentId) return; - if (lastProcessedAgentIdForEmbedding.current === currentAgentId) return; - - const kbToolId = Number(kbTool.id); - if (!enabledToolIds || !enabledToolIds.includes(kbToolId)) { - lastProcessedAgentIdForEmbedding.current = currentAgentId; - return; - } - - const run = async () => { - try { - // Fetch existing params to avoid losing saved configuration - const search = await searchToolConfig(kbToolId, currentAgentId); - const params = - search.success && search.data?.params ? search.data.params : {}; - // Disable the tool - await updateToolConfig(kbToolId, currentAgentId, params, false); - // Update local state - setEnabledToolIds((prev) => prev.filter((id) => id !== kbToolId)); - const nextSelected = selectedTools.filter( - (tool) => tool.id !== kbTool.id - ); - setSelectedTools(nextSelected); - } catch (error) { - // Even if API fails, still inform user and prevent usage in UI - } finally { - confirm({ - title: t("embedding.agentToolAutoDeselectModal.title"), - content: t("embedding.agentToolAutoDeselectModal.content"), - okText: t("common.confirm"), - onOk: () => {}, - }); - lastProcessedAgentIdForEmbedding.current = currentAgentId; - } - }; - - run(); - }, [ - isEmbeddingConfigured, - tools, - enabledToolIds, - isEditingAgent, - editingAgent, - mainAgentId, - ]); - - // Listen for refresh agent list events from parent component - useEffect(() => { - const handleRefreshAgentList = () => { - refreshAgentList(t); - }; - - window.addEventListener("refreshAgentList", handleRefreshAgentList); - - return () => { - window.removeEventListener("refreshAgentList", handleRefreshAgentList); - }; - }, [t]); - - // Listen for tools updated events and refresh enabledToolIds if agent is selected - useEffect(() => { - const handleToolsUpdated = async () => { - // If there's a selected agent (mainAgentId or editingAgent), refresh enabledToolIds - const currentAgentId = (isEditingAgent && editingAgent - ? Number(editingAgent.id) - : mainAgentId - ? Number(mainAgentId) - : undefined) as number | undefined; - - if (currentAgentId) { - try { - // First, refresh the tools list to ensure it's up to date - // Pass false to prevent showing success message (MCP modal will show its own message) - if (onToolsRefresh) { - // First, synchronize the selected tools once using search_info. - await refreshAgentToolSelectionsFromServer(currentAgentId); - // Then refresh the tool list - await onToolsRefresh(false); - // Wait for React state to update and tools prop to be updated - // Use setTimeout to ensure tools prop is updated before refreshing enabledToolIds - await new Promise((resolve) => setTimeout(resolve, 300)); - // Set flag to refresh enabledToolIds after tools prop updates - shouldRefreshEnabledToolIds.current = true; - } - } catch (error) { - log.error("Failed to refresh tools after tools update:", error); - } - } - }; - - window.addEventListener("toolsUpdated", handleToolsUpdated); - - return () => { - window.removeEventListener("toolsUpdated", handleToolsUpdated); - }; - }, [mainAgentId, isEditingAgent, editingAgent, onToolsRefresh, t]); - - const refreshAgentToolSelectionsFromServer = useCallback( - async (agentId: number) => { - try { - const agentInfoResult = await searchAgentInfo(agentId); - if (agentInfoResult.success && agentInfoResult.data) { - const remoteTools = Array.isArray(agentInfoResult.data.tools) - ? agentInfoResult.data.tools - : []; - const enabledIdsFromServer = remoteTools - .filter( - (remoteTool: any) => - remoteTool && remoteTool.is_available !== false - ) - .map((remoteTool: any) => Number(remoteTool.id)) - .filter((id) => !Number.isNaN(id)); - - const filteredIds = enabledIdsFromServer.filter((toolId) => { - const toolMeta = tools?.find( - (tool) => Number(tool.id) === Number(toolId) - ); - return toolMeta && toolMeta.is_available !== false; - }); - - const dedupedIds = Array.from(new Set(filteredIds)); - setEnabledToolIds(dedupedIds); - log.info("Refreshed agent tool selection from search_info", { - agentId, - toolIds: dedupedIds, - }); - } else { - log.error( - "Failed to refresh agent tool selection via search_info", - agentInfoResult.message - ); - } - } catch (error) { - log.error( - "Failed to refresh agent tool selection via search_info:", - error - ); - } - }, - [tools, setEnabledToolIds, setSelectedTools] - ); - - // Refresh enabledToolIds when tools prop updates after toolsUpdated event - useEffect(() => { - const prevTools = previousToolsRef.current; - const haveTools = tools && tools.length > 0; - const prevLen = prevTools?.length ?? 0; - const currLen = tools?.length ?? 0; - const idsChanged = - prevTools === undefined || - JSON.stringify(prevTools?.map((t) => t.id).sort()) !== - JSON.stringify((tools || []).map((t) => t.id).sort()); - const grew = currLen > prevLen; - - // Always update the previous ref for future comparisons - previousToolsRef.current = tools; - - // If there are no tools, nothing to do - if (!haveTools) { - return; - } - - const currentAgentId = (isEditingAgent && editingAgent - ? Number(editingAgent.id) - : mainAgentId - ? Number(mainAgentId) - : undefined) as number | undefined; - - if (!currentAgentId) { - shouldRefreshEnabledToolIds.current = false; - return; - } - - const refreshEnabledToolIds = async () => { - try { - // Small delay to allow tools prop to stabilize after updates - await new Promise((resolve) => setTimeout(resolve, 50)); - await refreshAgentToolSelectionsFromServer(currentAgentId); - } catch (error) { - log.error( - "Failed to refresh enabled tool IDs after tools update:", - error - ); - } - shouldRefreshEnabledToolIds.current = false; - }; - - // Trigger when: - // 1) We explicitly flagged a refresh after a toolsUpdated event, OR - // 2) The tool list grew (e.g., an MCP tool was added) or IDs changed, - // which indicates the available tool set has changed and we should re-sync - if (shouldRefreshEnabledToolIds.current || grew || idsChanged) { - // Optimistically update selected tools to reduce perceived delay/flicker - if (haveTools && Array.isArray(enabledToolIds) && enabledToolIds.length > 0) { - try { - const optimisticSelected = (tools || []).filter((tool) => - enabledToolIds.includes(Number(tool.id)) - ); - setSelectedTools(optimisticSelected); - } catch (e) { - log.warn("Optimistic selection update failed; will rely on refresh", e); - } - } - refreshEnabledToolIds(); - } - }, [ - tools, - mainAgentId, - isEditingAgent, - editingAgent, - enabledToolIds, - refreshAgentToolSelectionsFromServer, - ]); - - // Immediately reflect UI selection from enabledToolIds and latest tools (no server wait) - useEffect(() => { - const haveTools = Array.isArray(tools) && tools.length > 0; - if (!haveTools) { - setSelectedTools([]); - return; - } - if (!Array.isArray(enabledToolIds) || enabledToolIds.length === 0) { - setSelectedTools([]); - return; - } - try { - const nextSelected = (tools || []).filter((tool) => - enabledToolIds.includes(Number(tool.id)) - ); - setSelectedTools(nextSelected); - } catch (e) { - log.warn("Failed to sync selectedTools from enabledToolIds", e); - } - }, [enabledToolIds, tools, setSelectedTools]); - - // When tools change, sanitize enabledToolIds against availability to prevent transient flicker - useEffect(() => { - if (!Array.isArray(tools) || tools.length === 0) { - return; - } - if (!Array.isArray(enabledToolIds)) { - return; - } - const availableIdSet = new Set( - (tools || []) - .filter((t) => t && t.is_available !== false) - .map((t) => Number(t.id)) - .filter((id) => !Number.isNaN(id)) - ); - const sanitized = enabledToolIds.filter((id) => availableIdSet.has(Number(id))); - if ( - sanitized.length !== enabledToolIds.length || - sanitized.some((id, idx) => Number(id) !== Number(enabledToolIds[idx])) - ) { - setEnabledToolIds(sanitized); - } - }, [tools, enabledToolIds, setEnabledToolIds]); - - // Handle the creation of a new Agent - const handleCreateNewAgent = async () => { - // Set to create mode - setIsEditingAgent(false); - setEditingAgent(null); - setIsCreatingNewAgent(true); - - // Clear all content when creating new agent to avoid showing cached data - setBusinessLogic(""); - setDutyContent?.(""); - setConstraintContent?.(""); - setFewShotsContent?.(""); - setAgentName?.(""); - setAgentDescription?.(""); - setAgentDisplayName?.(""); - setAgentAuthor?.(""); - setAgentAuthor?.(""); - - // Clear tool and agent selections - setSelectedTools([]); - setEnabledToolIds([]); - setEnabledAgentIds([]); - setToolConfigDrafts({}); - setMainAgentId?.(null); - - // Clear business logic model to allow default from global settings - // The useEffect in PromptManager will set it to the default from localStorage - setBusinessLogicModel(null); - setBusinessLogicModelId(null); - - // Clear main agent model selection to trigger default model selection - // The useEffect in AgentConfigModal will set it to the default from localStorage - setMainAgentModel(null); - setMainAgentModelId(null); - - try { - await onToolsRefresh?.(false); - } catch (error) { - log.error("Failed to refresh tools in creation mode:", error); - } - - onEditingStateChange?.(false, null); - }; - - // Reset the status when the user cancels the creation of an Agent - const handleCancelCreating = async () => { - // First notify external editing state change to avoid UI jumping - onEditingStateChange?.(false, null); - - // Delay resetting state to let UI complete state switching first - setTimeout(() => { - // Use the parent's exit creation handler to properly clear cache - if (onExitCreation) { - onExitCreation(); - } else { - setIsCreatingNewAgent(false); - } - setIsEditingAgent(false); - setEditingAgent(null); - - // Clear the mainAgentId - setMainAgentId(null); - - // Note: Content clearing is handled by onExitCreation above - // Delay clearing tool and collaborative agent selection to avoid jumping - setTimeout(() => { - setSelectedTools([]); - setEnabledToolIds([]); - setEnabledAgentIds([]); - }, 200); - // Reset unsaved state and baseline - baselineRef.current = null; - setHasUnsavedChanges(false); - onUnsavedChange?.(false); - }, 100); - }; - - // Handle exit edit mode - const handleExitEditMode = async () => { - if (isCreatingNewAgent) { - // If in creation mode, check unsaved changes first - if (hasUnsavedChanges) { - setConfirmContext("exitCreate"); - setPendingAction(null); - setIsSaveConfirmOpen(true); - return; - } - await handleCancelCreating(); - } else if (isEditingAgent) { - // If in editing mode, clear related states first, then update editing state to avoid flickering - // First clear tool and agent selection states - setSelectedTools([]); - setEnabledToolIds([]); - setEnabledAgentIds([]); - - // Clear right-side name description box - setAgentName?.(""); - setAgentDescription?.(""); - - // Clear business logic - setBusinessLogic(""); - - // Clear segmented prompt content - setDutyContent?.(""); - setConstraintContent?.(""); - setFewShotsContent?.(""); - - // Notify external editing state change - onEditingStateChange?.(false, null); - - // Finally update editing state to avoid triggering refresh logic in useEffect - setIsEditingAgent(false); - setEditingAgent(null); - setMainAgentId(null); - - // Ensure tool pool won't show loading state - setIsLoadingTools(false); - - // Reset unsaved state and baseline when explicitly exiting edit mode - baselineRef.current = null; - setHasUnsavedChanges(false); - onUnsavedChange?.(false); - } - }; - - // Handle the creation of a new Agent - const persistDraftToolConfigs = useCallback( - async (agentId: number, toolIdsToEnable: number[]) => { - if (!toolIdsToEnable || toolIdsToEnable.length === 0) { - return; - } - - const payloads = toolIdsToEnable - .map((toolId) => { - const toolIdStr = String(toolId); - const draftParams = toolConfigDrafts[toolIdStr]; - const baseTool = - selectedTools.find((tool) => Number(tool.id) === toolId) || - tools.find((tool) => Number(tool.id) === toolId); - const paramsSource = - (draftParams && draftParams.length > 0 - ? draftParams - : baseTool?.initParams) || []; - if (!paramsSource || paramsSource.length === 0) { - return null; - } - const params = paramsSource.reduce((acc, param) => { - acc[param.name] = param.value; - return acc; - }, {} as Record); - return { - toolId, - params, - }; - }) - .filter(Boolean) as Array<{ - toolId: number; - params: Record; - }>; - - if (payloads.length === 0) { - return; - } - - let persistError = false; - for (const payload of payloads) { - try { - await updateToolConfig(payload.toolId, agentId, payload.params, true); - } catch (error) { - persistError = true; - log.error("Failed to persist tool configuration for new agent:", error); - } - } - - if (persistError) { - message.error(t("toolConfig.message.saveError")); - } - }, - [toolConfigDrafts, selectedTools, tools, message, t] - ); - - const handleSaveNewAgent = async ( - name: string, - description: string, - model: string | null, - max_step: number, - business_description: string - ) => { - if (name.trim()) { - try { - let result; - - // Generate deduplicated enabledToolIds from selectedTools to ensure consistency - const deduplicatedToolIds = Array.from( - new Set( - (selectedTools || []) - .map((tool) => Number(tool.id)) - .filter((id) => !isNaN(id)) - ) - ).sort((a, b) => a - b); - - // Generate deduplicated enabledAgentIds to ensure consistency - const deduplicatedAgentIds = Array.from( - new Set( - (enabledAgentIds || []) - .map((id) => Number(id)) - .filter((id) => !isNaN(id)) - ) - ).sort((a, b) => a - b); - - // Determine author value: use provided author, or default to user email in Full mode - const finalAuthor = agentAuthor || (!isSpeedMode && user?.email ? user.email : undefined); - - if (isEditingAgent && editingAgent) { - // Editing existing agent - result = await updateAgent( - Number(editingAgent.id), - name, - description, - model === null ? undefined : model, - max_step, - false, - true, - business_description, - dutyContent, - constraintContent, - fewShotsContent, - agentDisplayName, - mainAgentModelId ?? undefined, - businessLogicModel ?? undefined, - businessLogicModelId ?? undefined, - deduplicatedToolIds, - deduplicatedAgentIds, - finalAuthor - ); - } else { - // Creating new agent on save - result = await updateAgent( - undefined, - name, - description, - model === null ? undefined : model, - max_step, - false, - true, - business_description, - dutyContent, - constraintContent, - fewShotsContent, - agentDisplayName, - mainAgentModelId ?? undefined, - businessLogicModel ?? undefined, - businessLogicModelId ?? undefined, - deduplicatedToolIds, - deduplicatedAgentIds, - finalAuthor - ); - } - - if (result.success) { - if (!isEditingAgent && result.data?.agent_id) { - await persistDraftToolConfigs( - Number(result.data.agent_id), - deduplicatedToolIds - ); - setToolConfigDrafts({}); - } - // If created, set new mainAgentId for subsequent operations - if (!isEditingAgent && result.data?.agent_id) { - setMainAgentId(String(result.data.agent_id)); - } - message.success(t("businessLogic.config.message.agentSaveSuccess")); - - // Reset unsaved changes state to remove blue indicator - setHasUnsavedChanges(false); - onUnsavedChange?.(false); - - // If editing existing agent, reload data and maintain edit state - if (isEditingAgent && editingAgent) { - // Reload agent data to sync with backend - await reloadCurrentAgentData(); - } else if (result.data?.agent_id) { - // On create success: auto-select and enter edit mode for the new agent - const newId = Number(result.data.agent_id); - try { - const detail = await searchAgentInfo(newId); - if (detail.success && detail.data) { - const agentDetail = mergeAgentAvailabilityMetadata( - detail.data as Agent - ); - setIsEditingAgent(true); - setEditingAgent(agentDetail); - setMainAgentId(agentDetail.id); - setIsCreatingNewAgent(false); - // Populate UI fields - setAgentName?.(agentDetail.name || ""); - setAgentDescription?.(agentDetail.description || ""); - setAgentDisplayName?.(agentDetail.display_name || ""); - setAgentAuthor?.(agentDetail.author || ""); - onEditingStateChange?.(true, agentDetail); - setMainAgentModel(agentDetail.model); - setMainAgentModelId(agentDetail.model_id ?? null); - setMainAgentMaxStep(agentDetail.max_step); - setBusinessLogic(agentDetail.business_description || ""); - setBusinessLogicModel( - agentDetail.business_logic_model_name || null - ); - setBusinessLogicModelId( - agentDetail.business_logic_model_id || null - ); - if ( - agentDetail.sub_agent_id_list && - agentDetail.sub_agent_id_list.length > 0 - ) { - setEnabledAgentIds( - agentDetail.sub_agent_id_list.map((id: any) => Number(id)) - ); - } else { - setEnabledAgentIds([]); - } - setDutyContent?.(agentDetail.duty_prompt || ""); - setConstraintContent?.(agentDetail.constraint_prompt || ""); - setFewShotsContent?.(agentDetail.few_shots_prompt || ""); - if (agentDetail.tools && agentDetail.tools.length > 0) { - setSelectedTools(agentDetail.tools); - setEnabledToolIds( - agentDetail.tools - .map((tool: any) => Number(tool.id)) - .filter((id: number) => !isNaN(id)) as number[] - ); - } else { - setSelectedTools([]); - setEnabledToolIds([]); - } - // Establish clean baseline for the freshly created agent to avoid modal on switch - setTimeout(() => { - baselineRef.current = { - agentId: agentDetail.id, - agentName: agentDetail.name || "", - agentDescription: agentDetail.description || "", - agentDisplayName: agentDetail.display_name || "", - businessLogic: agentDetail.business_description || "", - dutyContent: agentDetail.duty_prompt || "", - constraintContent: agentDetail.constraint_prompt || "", - fewShotsContent: agentDetail.few_shots_prompt || "", - mainAgentModelId: agentDetail.model_id ?? null, - businessLogicModelId: - agentDetail.business_logic_model_id ?? null, - mainAgentMaxStep: Number(agentDetail.max_step ?? 5), - enabledAgentIds: Array.from( - new Set( - (agentDetail.sub_agent_id_list || []) - .map((n: any) => Number(n)) - .filter((n: number) => !isNaN(n)) - ) - ) as number[], - enabledToolIds: Array.from( - new Set( - (agentDetail.tools || []) - .map((t: any) => Number(t.id)) - .filter((id: number) => !isNaN(id)) - ) - ) as number[], - selectedToolIds: Array.from( - new Set( - (agentDetail.tools || []) - .map((t: any) => Number(t.id)) - .filter((id: number) => !isNaN(id)) - ) - ) as number[], - } as any; - setHasUnsavedChanges(false); - onUnsavedChange?.(false); - }, 0); - } else { - // Fallback: set minimal selection - setIsEditingAgent(true); - setEditingAgent({ id: newId } as any); - setMainAgentId(String(newId)); - setIsCreatingNewAgent(false); - setHasUnsavedChanges(false); - onUnsavedChange?.(false); - } - } catch { - setIsEditingAgent(true); - setEditingAgent({ id: newId } as any); - setMainAgentId(String(newId)); - setIsCreatingNewAgent(false); - setHasUnsavedChanges(false); - onUnsavedChange?.(false); - } - } - - // Refresh agent list and keep tools intact to avoid flashing - refreshAgentList(t, false); - } else { - message.error( - result.message || t("businessLogic.config.error.saveFailed") - ); - } - } catch (error) { - log.error("Error saving agent:", error); - message.error(t("businessLogic.config.error.saveRetry")); - } - } else { - if (!name.trim()) { - message.error(t("businessLogic.config.error.nameEmpty")); - } - if (!mainAgentId) { - message.error(t("businessLogic.config.error.noAgentId")); - } - } - }; - - const handleSaveAgent = () => { - // The save button's disabled state is controlled by canSaveAgent, which already validates the required fields. - // We can still add checks here for better user feedback in case the function is triggered unexpectedly. - if (!agentName || agentName.trim() === "") { - message.warning(t("businessLogic.config.message.completeAgentInfo")); - return; - } - - if (!mainAgentModel) { - message.warning(t("businessLogic.config.message.selectModelRequired")); - return; - } - - const hasPromptContent = - dutyContent?.trim() || - constraintContent?.trim() || - fewShotsContent?.trim(); - if (!hasPromptContent) { - message.warning(t("businessLogic.config.message.generatePromptFirst")); - return; - } - - // Always use agentName and agentDescription as they are bound to the inputs in both create and edit modes. - handleSaveNewAgent( - agentName, - agentDescription || "", - mainAgentModel, - mainAgentMaxStep, - businessLogic - ); - }; - - const handleEditAgent = async (agent: Agent, t: TFunction) => { - // Check for unsaved changes before switching agents (unless bypass flag set) - if (hasUnsavedChanges && !skipUnsavedCheckRef.current) { - setPendingAction(() => () => handleEditAgent(agent, t)); - setIsSaveConfirmOpen(true); - return; - } - - try { - // Call query interface to get complete Agent information - const result = await searchAgentInfo(Number(agent.id)); - - if (!result.success || !result.data) { - message.error( - result.message || t("businessLogic.config.error.agentDetailFailed") - ); - return; - } - - const agentDetail = mergeAgentAvailabilityMetadata( - result.data as Agent, - agent - ); - - // Set editing state and highlight after successfully getting information - setIsEditingAgent(true); - setEditingAgent(agentDetail); - // Set mainAgentId to current editing Agent ID - setMainAgentId(agentDetail.id); - // When editing existing agent, ensure exit creation mode AFTER setting all data - // Use setTimeout to ensure all data is set before triggering useEffect - setTimeout(() => { - setIsCreatingNewAgent(false); - }, 100); // Increase delay to ensure state updates are processed - - // First set right-side name description box data to ensure immediate display - - setAgentName?.(agentDetail.name || ""); - setAgentDescription?.(agentDetail.description || ""); - setAgentDisplayName?.(agentDetail.display_name || ""); - setAgentAuthor?.(agentDetail.author || ""); - - // Notify external editing state change (use complete data) - onEditingStateChange?.(true, agentDetail); - - // Load Agent data to interface - setMainAgentModel(agentDetail.model); - setMainAgentModelId(agentDetail.model_id ?? null); - setMainAgentMaxStep(agentDetail.max_step); - setBusinessLogic(agentDetail.business_description || ""); - setBusinessLogicModel(agentDetail.business_logic_model_name || null); - setBusinessLogicModelId(agentDetail.business_logic_model_id || null); - - // Use backend returned sub_agent_id_list to set enabled agent list - if ( - agentDetail.sub_agent_id_list && - agentDetail.sub_agent_id_list.length > 0 - ) { - setEnabledAgentIds( - agentDetail.sub_agent_id_list.map((id: any) => Number(id)) - ); - } else { - setEnabledAgentIds([]); - } - - // Load the segmented prompt content - setDutyContent?.(agentDetail.duty_prompt || ""); - setConstraintContent?.(agentDetail.constraint_prompt || ""); - setFewShotsContent?.(agentDetail.few_shots_prompt || ""); - - // Load Agent tools - // Filter out unavailable tools (is_available === false) to prevent deleted MCP tools from showing - if (agentDetail.tools && agentDetail.tools.length > 0) { - const availableTools = agentDetail.tools.filter( - (tool: any) => tool.is_available !== false - ); - const toolIds = Array.from( - new Set( - availableTools - .map((tool: any) => Number(tool.id)) - .filter((id: number) => !isNaN(id)) - ) - ).sort((a, b) => a - b); - setSelectedTools(availableTools); - setEnabledToolIds(toolIds); - } else { - setSelectedTools([]); - setEnabledToolIds([]); - } - } catch (error) { - log.error(t("agentConfig.agents.detailsLoadFailed"), error); - message.error(t("businessLogic.config.error.agentDetailFailed")); - // If error occurs, reset editing state - setIsEditingAgent(false); - setEditingAgent(null); - // Note: Don't reset isCreatingNewAgent, keep agent pool display - onEditingStateChange?.(false, null); - } - }; - - // Handle the update of the model - // Handle Business Logic Model change - const handleBusinessLogicModelChange = (value: string, modelId?: number) => { - setBusinessLogicModel(value); - if (modelId !== undefined) { - setBusinessLogicModelId(modelId); - } - }; - - const handleModelChange = async (value: string, modelId?: number) => { - const targetAgentId = - isEditingAgent && editingAgent ? editingAgent.id : mainAgentId; - - // Update local state first - setMainAgentModel(value); - if (modelId !== undefined) { - setMainAgentModelId(modelId); - } - - // If no agent ID yet (e.g., during initial creation setup), just update local state - // The model will be saved when the agent is fully created - // Also skip update API call if in create mode (agent not saved yet) - if (!targetAgentId || isCreatingNewAgent) { - return; - } - - // Call updateAgent API to save the model change - try { - const result = await updateAgent( - Number(targetAgentId), - undefined, // name - undefined, // description - value, // modelName - undefined, // maxSteps - undefined, // provideRunSummary - undefined, // enabled - undefined, // businessDescription - undefined, // dutyPrompt - undefined, // constraintPrompt - undefined, // fewShotsPrompt - undefined, // displayName - modelId, // modelId - undefined, // businessLogicModelName - undefined, // businessLogicModelId - undefined // enabledToolIds - ); - - if (!result.success) { - message.error( - result.message || t("businessLogic.config.error.modelUpdateFailed") - ); - // Revert local state on failure - setMainAgentModel(mainAgentModel); - setMainAgentModelId(mainAgentModelId); - } - } catch (error) { - log.error("Error updating agent model:", error); - message.error(t("businessLogic.config.error.modelUpdateFailed")); - // Revert local state on failure - setMainAgentModel(mainAgentModel); - setMainAgentModelId(mainAgentModelId); - } - }; - - // Handle the update of the maximum number of steps - const handleMaxStepChange = async (value: number | null) => { - const targetAgentId = - isEditingAgent && editingAgent ? editingAgent.id : mainAgentId; - - const newValue = value ?? 5; - - // Update local state first - setMainAgentMaxStep(newValue); - - // If no agent ID yet (e.g., during initial creation setup), just update local state - // The max steps will be saved when the agent is fully created - // Also skip update API call if in create mode (agent not saved yet) - if (!targetAgentId || isCreatingNewAgent) { - return; - } - - // Call updateAgent API to save the max steps change - try { - const result = await updateAgent( - Number(targetAgentId), - undefined, // name - undefined, // description - undefined, // modelName - newValue, // maxSteps - undefined, // provideRunSummary - undefined, // enabled - undefined, // businessDescription - undefined, // dutyPrompt - undefined, // constraintPrompt - undefined, // fewShotsPrompt - undefined, // displayName - undefined, // modelId - undefined, // businessLogicModelName - undefined, // businessLogicModelId - undefined // enabledToolIds - ); - - if (!result.success) { - message.error( - result.message || t("businessLogic.config.error.maxStepsUpdateFailed") - ); - // Revert local state on failure - setMainAgentMaxStep(mainAgentMaxStep); - } - } catch (error) { - log.error("Error updating agent max steps:", error); - message.error(t("businessLogic.config.error.maxStepsUpdateFailed")); - // Revert local state on failure - setMainAgentMaxStep(mainAgentMaxStep); - } - }; - - // Use unified import hooks - one for normal import, one for force import - const { importFromData: runNormalImport } = useAgentImport({ - onSuccess: () => { - message.success(t("businessLogic.config.error.agentImportSuccess")); - refreshAgentList(t, false); - }, - onError: (error) => { - log.error(t("agentConfig.agents.importFailed"), error); - message.error(t("businessLogic.config.error.agentImportFailed")); - }, - forceImport: false, - }); - - const { importFromData: runForceImport } = useAgentImport({ - onSuccess: () => { - message.success(t("businessLogic.config.error.agentImportSuccess")); - refreshAgentList(t, false); - }, - onError: (error) => { - log.error(t("agentConfig.agents.importFailed"), error); - message.error(t("businessLogic.config.error.agentImportFailed")); - }, - forceImport: true, - }); - - const runAgentImport = useCallback( - async ( - agentPayload: any, - options?: { forceImport?: boolean } - ) => { - setIsImporting(true); - try { - if (options?.forceImport) { - await runForceImport(agentPayload); - } else { - await runNormalImport(agentPayload); - } - return true; - } catch (error) { - return false; - } finally { - setIsImporting(false); - } - }, - [runNormalImport, runForceImport] - ); - - // Handle importing agent - use AgentImportWizard for ExportAndImportDataFormat - const handleImportAgent = (t: TFunction) => { - // Create a hidden file input element - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = ".json"; - fileInput.onchange = async (event) => { - setPendingImportData(null); - const file = (event.target as HTMLInputElement).files?.[0]; - if (!file) return; - - // Check file type - if (!file.name.endsWith(".json")) { - message.error(t("businessLogic.config.error.invalidFileType")); - return; - } - - try { - // Read file content - const fileContent = await file.text(); - let agentInfo; - - try { - agentInfo = JSON.parse(fileContent); - } catch (parseError) { - message.error(t("businessLogic.config.error.invalidFileType")); - return; - } - - // Check if it's ExportAndImportDataFormat (has agent_id and agent_info) - if (agentInfo.agent_id && agentInfo.agent_info && typeof agentInfo.agent_info === "object") { - // Use AgentImportWizard for full agent import with configuration - const importData: ImportAgentData = { - agent_id: agentInfo.agent_id, - agent_info: agentInfo.agent_info, - mcp_info: agentInfo.mcp_info || [], - }; - setImportWizardData(importData); - setImportWizardVisible(true); - return; - } - - // Fallback to legacy import logic for other formats - const normalizeValue = (value?: string | null) => - typeof value === "string" ? value.trim() : ""; - - const extractImportedAgents = (data: any): any[] => { - if (!data) { - return []; - } - - if (Array.isArray(data)) { - return data; - } - - if (data.agent_info && typeof data.agent_info === "object") { - return Object.values(data.agent_info).filter( - (item) => item && typeof item === "object" - ); - } - - if (data.agentInfo && typeof data.agentInfo === "object") { - return Object.values(data.agentInfo).filter( - (item) => item && typeof item === "object" - ); - } - - return [data]; - }; - - const importedAgents = extractImportedAgents(agentInfo); - const agentList = Array.isArray(subAgentList) ? subAgentList : []; - - const existingNames = new Set( - agentList - .map((agent) => normalizeValue(agent?.name)) - .filter((name) => !!name) - ); - const existingDisplayNames = new Set( - agentList - .map((agent) => normalizeValue(agent?.display_name)) - .filter((name) => !!name) - ); - - const duplicateNames = Array.from( - new Set( - importedAgents - .map((agent) => normalizeValue(agent?.name)) - .filter( - (name) => name && existingNames.has(name) - ) as string[] - ) - ); - const duplicateDisplayNames = Array.from( - new Set( - importedAgents - .map((agent) => - normalizeValue(agent?.display_name ?? agent?.displayName) - ) - .filter( - (displayName) => - displayName && existingDisplayNames.has(displayName) - ) as string[] - ) - ); - - const hasNameConflict = duplicateNames.length > 0; - const hasDisplayNameConflict = duplicateDisplayNames.length > 0; - - if (hasNameConflict || hasDisplayNameConflict) { - setPendingImportData({ - agentInfo, - }); - } else { - await runAgentImport(agentInfo); - } - } catch (error) { - log.error(t("agentConfig.agents.importFailed"), error); - message.error(t("businessLogic.config.error.agentImportFailed")); - } - }; - - fileInput.click(); - }; - - // Handle import completion from wizard - const handleImportComplete = () => { - refreshAgentList(t, false); - setImportWizardVisible(false); - setImportWizardData(null); - }; - - const handleConfirmedDuplicateImport = useCallback(async () => { - if (!pendingImportData) { - return; - } - setImportingAction("regenerate"); - const success = await runAgentImport(pendingImportData.agentInfo); - if (success) { - setPendingImportData(null); - } - setImportingAction(null); - }, [pendingImportData, runAgentImport, t]); - - const handleForceDuplicateImport = useCallback(async () => { - if (!pendingImportData) { - return; - } - setImportingAction("force"); - const success = await runAgentImport(pendingImportData.agentInfo, { - forceImport: true, - }); - if (success) { - setPendingImportData(null); - } - setImportingAction(null); - }, [pendingImportData, runAgentImport, t]); - - // Handle confirmed deletion - const handleConfirmDelete = async (agent: Agent) => { - try { - const result = await deleteAgent(Number(agent.id)); - if (result.success) { - message.success( - t("businessLogic.config.error.agentDeleteSuccess", { - name: agent.name, - }) - ); - // If currently editing the deleted agent, reset to initial clean state and avoid confirm modal on next switch - const deletedId = Number(agent.id); - const currentEditingId = - (isEditingAgent && editingAgent ? Number(editingAgent.id) : null) ?? - null; - if (currentEditingId === deletedId) { - // Clear editing/creation states - setIsEditingAgent(false); - setEditingAgent(null); - setIsCreatingNewAgent(false); - setMainAgentId(null); - // Clear form/content states - setBusinessLogic(""); - setDutyContent?.(""); - setConstraintContent?.(""); - setFewShotsContent?.(""); - setAgentName?.(""); - setAgentDescription?.(""); - setAgentDisplayName?.(""); - setSelectedTools([]); - setEnabledToolIds([]); - setEnabledAgentIds([]); - // Reset baseline/dirty and bypass next unsaved check - baselineRef.current = null; - setHasUnsavedChanges(false); - onUnsavedChange?.(false); - skipUnsavedCheckRef.current = true; - setTimeout(() => { - skipUnsavedCheckRef.current = false; - }, 0); - onEditingStateChange?.(false, null); - } else { - // If deleting another agent that is in enabledAgentIds, remove it and update baseline - // to avoid triggering false unsaved changes indicator - const deletedId = Number(agent.id); - if (enabledAgentIds.includes(deletedId)) { - const updatedEnabledAgentIds = enabledAgentIds.filter( - (id) => id !== deletedId - ); - setEnabledAgentIds(updatedEnabledAgentIds); - // Update baseline to reflect this change so it doesn't trigger unsaved changes - if (baselineRef.current) { - baselineRef.current = { - ...baselineRef.current, - enabledAgentIds: updatedEnabledAgentIds.sort((a, b) => a - b), - }; - } - } - } - // Refresh agent list without clearing tools to avoid triggering false unsaved changes indicator - refreshAgentList(t, false); - } else { - message.error( - result.message || t("businessLogic.config.error.agentDeleteFailed") - ); - } - } catch (error) { - log.error(t("agentConfig.agents.deleteFailed"), error); - message.error(t("businessLogic.config.error.agentDeleteFailed")); - } - }; - - // Handle export agent from list - const handleExportAgentFromList = async (agent: Agent) => { - try { - const result = await exportAgent(Number(agent.id)); - if (result.success && result.data) { - // Create a blob and download the file - const blob = new Blob([JSON.stringify(result.data, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `${agent.name || "agent"}.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - message.success(t("businessLogic.config.message.agentExportSuccess")); - } else { - message.error( - result.message || t("businessLogic.config.error.agentExportFailed") - ); - } - } catch (error) { - log.error("Failed to export agent:", error); - message.error(t("businessLogic.config.error.agentExportFailed")); - } - }; - - // Handle copy agent from list - const handleCopyAgentFromList = async (agent: Agent) => { - try { - // Fetch source agent detail before duplicating - const detailResult = await searchAgentInfo(Number(agent.id)); - if (!detailResult.success || !detailResult.data) { - message.error(detailResult.message); - return; - } - const detail = detailResult.data; - - // Prepare copy names - const copyName = `${detail.name || "agent"}_copy`; - const copyDisplayName = `${ - detail.display_name || t("agentConfig.agents.defaultDisplayName") - }${t("agent.copySuffix")}`; - - // Gather tool and sub-agent identifiers from the source agent - const tools = Array.isArray(detail.tools) ? detail.tools : []; - const unavailableTools = tools.filter( - (tool: any) => tool && tool.is_available === false - ); - const unavailableToolNames = unavailableTools - .map( - (tool: any) => - tool?.display_name || tool?.name || tool?.tool_name || "" - ) - .filter((name: string) => Boolean(name)); - - const enabledToolIds = tools - .filter((tool: any) => tool && tool.is_available !== false) - .map((tool: any) => Number(tool.id)) - .filter((id: number) => Number.isFinite(id)); - const subAgentIds = (Array.isArray(detail.sub_agent_id_list) - ? detail.sub_agent_id_list - : [] - ) - .map((id: any) => Number(id)) - .filter((id: number) => Number.isFinite(id)); - - // Create a new agent using the source agent fields - const createResult = await updateAgent( - undefined, - copyName, - detail.description, - detail.model, - detail.max_step, - detail.provide_run_summary, - detail.enabled, - detail.business_description, - detail.duty_prompt, - detail.constraint_prompt, - detail.few_shots_prompt, - copyDisplayName, - detail.model_id ?? undefined, - detail.business_logic_model_name ?? undefined, - detail.business_logic_model_id ?? undefined, - enabledToolIds, - subAgentIds - ); - if (!createResult.success || !createResult.data?.agent_id) { - message.error( - createResult.message || - t("agentConfig.agents.copyFailed") - ); - return; - } - const newAgentId = Number(createResult.data.agent_id); - const copiedAgentFallback: Agent = { - ...detail, - id: String(newAgentId), - name: copyName, - display_name: copyDisplayName, - sub_agent_id_list: subAgentIds, - }; - - // Copy tool configuration to the new agent - for (const tool of tools) { - if (!tool || tool.is_available === false) { - continue; - } - const params = - tool.initParams?.reduce((acc: Record, param: any) => { - acc[param.name] = param.value; - return acc; - }, {}) || {}; - try { - await updateToolConfig(Number(tool.id), newAgentId, params, true); - } catch (error) { - log.error("Failed to copy tool configuration while duplicating agent:", error); - message.error( - t("agentConfig.agents.copyFailed") - ); - return; - } - } - - // Refresh UI state and notify user about copy result - await refreshAgentList(t, false); - message.success(t("agentConfig.agents.copySuccess")); - if (unavailableTools.length > 0) { - const names = - unavailableToolNames.join(", ") || - unavailableTools - .map((tool: any) => Number(tool?.id)) - .filter((id: number) => !Number.isNaN(id)) - .join(", "); - message.warning( - t("agentConfig.agents.copyUnavailableTools", { - count: unavailableTools.length, - names, - }) - ); - } - // Auto select the newly copied agent for editing - await handleEditAgent(copiedAgentFallback, t); - } catch (error) { - log.error("Failed to copy agent:", error); - message.error(t("agentConfig.agents.copyFailed")); - } - }; - - const handleCopyAgentWithConfirm = (agent: Agent) => { - confirm({ - title: t("agentConfig.agents.copyConfirmTitle"), - content: t("agentConfig.agents.copyConfirmContent", { - name: agent?.display_name || agent?.name || "", - }), - onOk: () => handleCopyAgentFromList(agent), - }); - }; - - // Handle delete agent from list - const handleDeleteAgentFromList = (agent: Agent) => { - confirm({ - title: t("businessLogic.config.modal.deleteTitle"), - content: t("businessLogic.config.modal.deleteContent", { - name: agent.name, - }), - onOk: () => handleConfirmDelete(agent), - }); - }; - - // Refresh tool list - const handleToolsRefresh = useCallback( - async (showSuccessMessage = true) => { - if (onToolsRefresh) { - // Before refreshing the tool list, synchronize the selected tools using search_info. - const currentAgentId = (isEditingAgent && editingAgent - ? Number(editingAgent.id) - : mainAgentId - ? Number(mainAgentId) - : undefined) as number | undefined; - if (currentAgentId) { - await refreshAgentToolSelectionsFromServer(currentAgentId); - } - const refreshedTools = await onToolsRefresh(showSuccessMessage); - if (refreshedTools) { - shouldRefreshEnabledToolIds.current = true; - } - return refreshedTools; - } - return undefined; - }, - [onToolsRefresh, isEditingAgent, editingAgent, mainAgentId, refreshAgentToolSelectionsFromServer] - ); - - // Handle view call relationship - const handleViewCallRelationship = () => { - const currentAgentId = getCurrentAgentId?.() ?? undefined; - if (currentAgentId) { - setCallRelationshipModalVisible(true); - } - }; - - // Get button tooltip information - const getLocalButtonTitle = () => { - if (!businessLogic || businessLogic.trim() === "") { - return t("businessLogic.config.message.businessDescriptionRequired"); - } - if (!mainAgentModel) { - return t("businessLogic.config.message.selectModelRequired"); - } - if ( - !dutyContent?.trim() && - !constraintContent?.trim() && - !fewShotsContent?.trim() - ) { - return t("businessLogic.config.message.generatePromptFirst"); - } - if (!agentName || agentName.trim() === "") { - return t("businessLogic.config.message.completeAgentInfo"); - } - return ""; - }; - - // Check if agent can be saved - const localCanSaveAgent = !!( - businessLogic?.trim() && - agentName?.trim() && - mainAgentModel && - (dutyContent?.trim() || - constraintContent?.trim() || - fewShotsContent?.trim()) - ); - - const isForceDuplicateDisabled = - isImporting && importingAction === "regenerate"; - const isRegenerateDuplicateDisabled = - isImporting && importingAction === "force"; - const isForceDuplicateLoading = isImporting && importingAction === "force"; - - return ( - -
- {/* Three-column layout using Ant Design Grid */} - - {/* Left column: SubAgentPool */} - - handleEditAgent(agent, t)} - onCreateNewAgent={() => confirmOrRun(handleCreateNewAgent)} - onExitEditMode={handleExitEditMode} - onImportAgent={() => handleImportAgent(t)} - subAgentList={subAgentList} - loadingAgents={loadingAgents} - isImporting={isImporting} - isGeneratingAgent={isGeneratingAgent} - editingAgent={editingAgent} - isCreatingNewAgent={isCreatingNewAgent} - onCopyAgent={handleCopyAgentWithConfirm} - onExportAgent={handleExportAgentFromList} - onDeleteAgent={handleDeleteAgentFromList} - unsavedAgentId={ - hasUnsavedChanges && isEditingAgent && editingAgent - ? Number(editingAgent.id) - : null - } - /> - - - {/* Middle column: Agent capability configuration */} - - {/* Header: Configure Agent Capabilities */} -
-
-
- 2 -
-

- {t("businessLogic.config.title")} -

-
-
- - {/* Content: Two sections */} -
-
- {/* Upper section: Collaborative Agent Display - fixed area */} - - - {/* Lower section: Tool Pool - flexible area */} -
- { - if (isLoadingTools) return; - const toolId = Number(tool.id); - if (isSelected) { - // Avoid duplicate tools - if (!selectedTools.some((t) => t.id === tool.id)) { - setSelectedTools([...selectedTools, tool]); - } - // Sync enabledToolIds, ensure no duplicates - setEnabledToolIds((prev) => { - if (prev.includes(toolId)) { - return prev; - } - return [...prev, toolId].sort((a, b) => a - b); - }); - } else { - setSelectedTools( - selectedTools.filter((t) => t.id !== tool.id) - ); - // Sync enabledToolIds - setEnabledToolIds((prev) => - prev.filter((id) => id !== toolId) - ); - } - }} - tools={tools} - loadingTools={isLoadingTools} - mainAgentId={ - isEditingAgent && editingAgent - ? editingAgent.id - : mainAgentId - } - localIsGenerating={isGeneratingAgent} - onToolsRefresh={handleToolsRefresh} - isEditingMode={isEditingAgent || isCreatingNewAgent} - isGeneratingAgent={isGeneratingAgent} - isEmbeddingConfigured={isEmbeddingConfigured} - agentUnavailableReasons={agentUnavailableReasons} - onToolConfigSave={handleToolConfigDraftSave} - toolConfigDrafts={toolConfigDrafts} - /> -
-
-
- - - {/* Right column: System Prompt Display */} - - confirmOrRun(() => onDebug()) : () => {}} - agentId={ - getCurrentAgentId - ? getCurrentAgentId() - : isEditingAgent && editingAgent - ? Number(editingAgent.id) - : isCreatingNewAgent && mainAgentId - ? Number(mainAgentId) - : undefined - } - businessLogic={businessLogic} - businessLogicError={businessLogicError} - dutyContent={dutyContent} - constraintContent={constraintContent} - fewShotsContent={fewShotsContent} - onDutyContentChange={setDutyContent} - onConstraintContentChange={setConstraintContent} - onFewShotsContentChange={setFewShotsContent} - agentName={agentName} - agentDescription={agentDescription} - onAgentNameChange={setAgentName} - onAgentDescriptionChange={setAgentDescription} - agentDisplayName={agentDisplayName} - onAgentDisplayNameChange={setAgentDisplayName} - agentAuthor={agentAuthor} - onAgentAuthorChange={setAgentAuthor} - isEditingMode={isEditingAgent || isCreatingNewAgent} - mainAgentModel={mainAgentModel ?? undefined} - mainAgentModelId={mainAgentModelId} - mainAgentMaxStep={mainAgentMaxStep} - onModelChange={(value: string, modelId?: number) => - handleModelChange(value, modelId) - } - onMaxStepChange={handleMaxStepChange} - onBusinessLogicChange={(value: string) => setBusinessLogic(value)} - onBusinessLogicModelChange={handleBusinessLogicModelChange} - businessLogicModel={businessLogicModel} - businessLogicModelId={businessLogicModelId} - onGenerateAgent={onGenerateAgent || (() => {})} - onSaveAgent={handleSaveAgent} - isGeneratingAgent={isGeneratingAgent} - isCreatingNewAgent={isCreatingNewAgent} - canSaveAgent={localCanSaveAgent} - getButtonTitle={getLocalButtonTitle} - editingAgent={editingAgentFromParent || editingAgent} - onViewCallRelationship={handleViewCallRelationship} - /> - -
- - - {/* Save confirmation modal for unsaved changes (debug/navigation hooks) */} - { - if (confirmContext === "exitCreate") { - // Discard draft and return to initial state - await handleCancelCreating(); - setIsSaveConfirmOpen(false); - } else { - // Discard while switching/editing: reload backend state of current agent - await reloadCurrentAgentData(); - setHasUnsavedChanges(false); - onUnsavedChange?.(false); - setIsSaveConfirmOpen(false); - const action = pendingAction; - setPendingAction(null); - if (action) { - skipUnsavedCheckRef.current = true; - setTimeout(async () => { - try { - await Promise.resolve(action()); - } finally { - skipUnsavedCheckRef.current = false; - } - }, 0); - } - } - }} - onSave={async () => { - // Save changes: for create mode or edit mode, reuse unified save path - await saveAllChanges(); - setIsSaveConfirmOpen(false); - const action = pendingAction; - setPendingAction(null); - if (action) { - // Continue pending action after save (e.g., switch) - skipUnsavedCheckRef.current = true; - setTimeout(async () => { - try { - await Promise.resolve(action()); - } finally { - skipUnsavedCheckRef.current = false; - } - }, 0); - } - }} - onClose={() => { - // Only close modal, don't execute discard logic - setIsSaveConfirmOpen(false); - }} - canSave={localCanSaveAgent} - invalidReason={ - localCanSaveAgent ? undefined : getLocalButtonTitle() || undefined - } - /> - {/* Duplicate import confirmation */} - - - {t("businessLogic.config.import.duplicateTitle")} -
- } - onCancel={() => { - if (isImporting) { - return; - } - setPendingImportData(null); - }} - maskClosable={!isImporting} - closable={!isImporting} - centered - footer={ -
- - - - - - - -
- } - > -

- {t("businessLogic.config.import.duplicateDescription")} -

- - {/* Agent Import Wizard */} - { - setImportWizardVisible(false); - setImportWizardData(null); - }} - initialData={importWizardData} - onImportComplete={handleImportComplete} - title={undefined} // Use default title - agentDisplayName={ - importWizardData?.agent_info?.[String(importWizardData.agent_id)]?.display_name - } - agentDescription={ - importWizardData?.agent_info?.[String(importWizardData.agent_id)]?.description - } - /> - {/* Auto unselect knowledge_base_search notice when embedding not configured */} - - {/* Agent call relationship modal */} - setCallRelationshipModalVisible(false)} - agentId={ - (getCurrentAgentId - ? getCurrentAgentId() - : isEditingAgent && editingAgent - ? Number(editingAgent.id) - : isCreatingNewAgent && mainAgentId - ? Number(mainAgentId) - : undefined) ?? 0 - } - agentName={ - editingAgentFromParent || editingAgent - ? (editingAgentFromParent || editingAgent)?.display_name || - (editingAgentFromParent || editingAgent)?.name || - "" - : "" - } - /> - -
- ); -} diff --git a/frontend/app/[locale]/agents/components/PromptManager.tsx b/frontend/app/[locale]/agents/components/PromptManager.tsx deleted file mode 100644 index c7d7e45f0..000000000 --- a/frontend/app/[locale]/agents/components/PromptManager.tsx +++ /dev/null @@ -1,730 +0,0 @@ -"use client"; - -import { useState, useRef, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { Modal, Badge, Input, App, Select } from "antd"; -import { Zap, LoaderCircle, Info } from "lucide-react"; - -import { - SimplePromptEditorProps, - ExpandEditModalProps, -} from "@/types/agentConfig"; -import { updateAgent } from "@/services/agentConfigService"; -import { modelService } from "@/services/modelService"; -import { ModelOption } from "@/types/modelConfig"; - -import AgentConfigModal, { AgentConfigModalProps } from "./agent/AgentConfigModal"; - -import log from "@/lib/logger"; - -export function SimplePromptEditor({ - value, - onChange, - height, - bordered = false, -}: SimplePromptEditorProps) { - const [internalValue, setInternalValue] = useState(value); - const isInternalChange = useRef(false); - const onChangeRef = useRef(onChange); - - // Keep onChange ref updated - useEffect(() => { - onChangeRef.current = onChange; - }, [onChange]); - - // Sync external value changes to internal state (only when not from internal change) - useEffect(() => { - // Only update if the change is from external source (not from user input) - if (!isInternalChange.current) { - setInternalValue(value); - } - }, [value]); // Only depend on value prop - internalValue comparison handled via ref flag - - const handleChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - isInternalChange.current = true; - setInternalValue(newValue); - // Use ref to avoid stale closure issues - onChangeRef.current(newValue); - // Reset flag after a microtask to allow state update - Promise.resolve().then(() => { - isInternalChange.current = false; - }); - }; - - return ( - - ); -} - -// Expand edit modal - -function ExpandEditModal({ - open, - title, - content, - index, - onClose, - onSave, -}: ExpandEditModalProps) { - const { t } = useTranslation("common"); - const [editContent, setEditContent] = useState(content); - - // Update edit content when content or open state changes - useEffect(() => { - if (open) { - // Always use the latest content when modal opens - setEditContent(content); - } - }, [content, open]); - - const handleSave = () => { - onSave(editContent); - onClose(); - }; - - const handleClose = () => { - // Close without saving changes - onClose(); - }; - - const getBadgeProps = (index: number) => { - switch (index) { - case 1: - return { status: "success" as const }; - case 2: - return { status: "warning" as const }; - case 3: - return { color: "#1677ff" }; - case 4: - return { status: "default" as const }; - default: - return { status: "default" as const }; - } - }; - - const calculateModalHeight = (content: string) => { - const lineCount = content.split("\n").length; - const contentLength = content.length; - const heightByLines = 25 + Math.floor(lineCount / 8) * 5; - const heightByContent = 25 + Math.floor(contentLength / 200) * 3; - const calculatedHeight = Math.max(heightByLines, heightByContent); - return Math.max(25, Math.min(85, calculatedHeight)); - }; - - return ( - -
- - {title} -
- - - } - open={open} - closeIcon={null} - onCancel={handleClose} - footer={null} - width={1000} - styles={{ - body: { padding: "20px" }, - content: { top: 20 }, - }} - > -
- -
- { - setEditContent(newContent); - }} - bordered={true} - height={"100%"} - /> -
-
-
- ); -} - -// Main prompt manager component -export interface PromptManagerProps { - // Basic data - agentId?: number; - businessLogic?: string; - businessLogicError?: boolean; - dutyContent?: string; - constraintContent?: string; - fewShotsContent?: string; - - // Agent information - agentName?: string; - agentDescription?: string; - agentDisplayName?: string; - mainAgentModel?: string; - mainAgentModelId?: number | null; - mainAgentMaxStep?: number; - - // Business Logic Model (independent from main agent model) - businessLogicModel?: string | null; - businessLogicModelId?: number | null; - - // Edit state - isEditingMode?: boolean; - isGeneratingAgent?: boolean; - isCreatingNewAgent?: boolean; - canSaveAgent?: boolean; - - // Callback functions - onBusinessLogicChange?: (content: string) => void; - onBusinessLogicModelChange?: (value: string, modelId?: number) => void; - onDutyContentChange?: (content: string) => void; - onConstraintContentChange?: (content: string) => void; - onFewShotsContentChange?: (content: string) => void; - onAgentNameChange?: (name: string) => void; - onAgentDescriptionChange?: (description: string) => void; - onAgentDisplayNameChange?: (displayName: string) => void; - agentAuthor?: string; - onAgentAuthorChange?: (author: string) => void; - onModelChange?: (value: string, modelId?: number) => void; - onMaxStepChange?: (value: number | null) => void; - onGenerateAgent?: (model: ModelOption) => void; - onSaveAgent?: () => void; - onDebug?: () => void; - getButtonTitle?: () => string; - onViewCallRelationship?: () => void; - - // Agent being edited - editingAgent?: any; - - // Model selection callbacks - onModelSelect?: (model: ModelOption | null) => void; - selectedGenerateModel?: ModelOption | null; -} - -export default function PromptManager({ - agentId, - businessLogic = "", - businessLogicError = false, - dutyContent = "", - constraintContent = "", - fewShotsContent = "", - agentName = "", - agentDescription = "", - agentDisplayName = "", - agentAuthor = "", - onAgentAuthorChange, - mainAgentModel = "", - mainAgentModelId = null, - mainAgentMaxStep = 5, - businessLogicModel = null, - businessLogicModelId = null, - isEditingMode = false, - isGeneratingAgent = false, - isCreatingNewAgent = false, - canSaveAgent = false, - onBusinessLogicChange, - onBusinessLogicModelChange, - onDutyContentChange, - onConstraintContentChange, - onFewShotsContentChange, - onAgentNameChange, - onAgentDescriptionChange, - onAgentDisplayNameChange, - onModelChange, - onMaxStepChange, - onGenerateAgent, - onSaveAgent, - onDebug, - getButtonTitle, - onViewCallRelationship, - editingAgent, - onModelSelect, - selectedGenerateModel, -}: PromptManagerProps) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - - // Local state for business logic input to enable debouncing - const [localBusinessLogic, setLocalBusinessLogic] = useState(businessLogic); - const businessLogicDebounceTimer = useRef(null); - - // Sync local state when prop changes (from external updates) - useEffect(() => { - setLocalBusinessLogic(businessLogic); - }, [businessLogic]); - - // Cleanup timer on unmount - useEffect(() => { - return () => { - if (businessLogicDebounceTimer.current) { - clearTimeout(businessLogicDebounceTimer.current); - } - }; - }, []); - - // Modal states - const [expandModalOpen, setExpandModalOpen] = useState(false); - const [expandIndex, setExpandIndex] = useState(0); - - // Model selection states - const [availableModels, setAvailableModels] = useState([]); - const [loadingModels, setLoadingModels] = useState(false); - // Fallback internal selection when parent does not control selection - const [internalSelectedModel, setInternalSelectedModel] = useState< - ModelOption | null - >(selectedGenerateModel ?? null); - - // Keep internal state in sync when parent-controlled value changes - useEffect(() => { - if (selectedGenerateModel && selectedGenerateModel?.id !== internalSelectedModel?.id) { - setInternalSelectedModel(selectedGenerateModel); - } - if (!selectedGenerateModel && internalSelectedModel) { - // Parent cleared selection; keep internal unless explicitly needed - } - }, [selectedGenerateModel]); - - // Load available models on component mount - useEffect(() => { - loadAvailableModels(); - }, []); - - const loadAvailableModels = async () => { - setLoadingModels(true); - try { - const models = await modelService.getLLMModels(); - setAvailableModels(models); - } catch (error) { - log.error("Failed to load available models:", error); - message.error(t("businessLogic.config.error.loadModelsFailed")); - } finally { - setLoadingModels(false); - } - }; - - // Ensure a separate Business Logic LLM default selection using global default on creation - // IMPORTANT: Only read from localStorage when creating a NEW agent, not when editing existing agent - useEffect(() => { - if (!isCreatingNewAgent) return; // Only apply to new agents - if (!availableModels || availableModels.length === 0) return; - if (businessLogicModelId) return; // Already set - - try { - const storedModelConfig = localStorage.getItem("model"); - const parsed = storedModelConfig ? JSON.parse(storedModelConfig) : null; - const defaultDisplayName = parsed?.llm?.displayName || ""; - const defaultModelName = parsed?.llm?.modelName || ""; - - let target = null as ModelOption | null; - if (defaultDisplayName) { - target = availableModels.find((m) => m.displayName === defaultDisplayName) || null; - } - if (!target && defaultModelName) { - target = availableModels.find((m) => m.name === defaultModelName) || null; - } - if (!target) { - target = availableModels[0] || null; - } - if (target && onBusinessLogicModelChange) { - onBusinessLogicModelChange(target.displayName, target.id); - } else if (target) { - if (onModelSelect) { - onModelSelect(target); - } else { - setInternalSelectedModel(target); - } - } - } catch (_e) { - // ignore parse errors - } - }, [isCreatingNewAgent, availableModels, businessLogicModelId, onBusinessLogicModelChange, onModelSelect]); - - // When editing an existing agent, load previously selected business logic model - useEffect(() => { - if (isCreatingNewAgent) return; - if (!availableModels || availableModels.length === 0) return; - if (selectedGenerateModel) return; // already set by parent/user - - let target: ModelOption | null = null; - if (businessLogicModelId) { - target = availableModels.find((m) => m.id === businessLogicModelId) || null; - } - if (!target && businessLogicModel) { - target = - availableModels.find((m) => m.displayName === businessLogicModel) || - availableModels.find((m) => m.name === businessLogicModel) || - null; - } - if (target) { - if (onModelSelect) { - onModelSelect(target); - } else { - setInternalSelectedModel(target); - } - } - }, [ - isCreatingNewAgent, - availableModels, - selectedGenerateModel, - businessLogicModelId, - businessLogicModel, - onModelSelect, - ]); - - // Handle model selection for prompt generation - const handleModelSelect = (modelId: number) => { - const model = availableModels.find((m) => m.id === modelId); - if (!model) return; - if (onBusinessLogicModelChange) { - onBusinessLogicModelChange(model.displayName, model.id); - } else if (onModelSelect) { - onModelSelect(model); - } else { - setInternalSelectedModel(model); - } - }; - - // Handle generate button click - const handleGenerateClick = () => { - if (availableModels.length === 0) { - message.warning(t("businessLogic.config.error.noAvailableModels")); - return; - } - - // Check if a model is selected: priority order is businessLogicModelId, selectedGenerateModel, internalSelectedModel - let chosen: ModelOption | null = null; - if (businessLogicModelId) { - chosen = availableModels.find((m) => m.id === businessLogicModelId) || null; - } - if (!chosen && selectedGenerateModel) { - chosen = selectedGenerateModel; - } - if (!chosen && internalSelectedModel) { - chosen = internalSelectedModel; - } - - if (!chosen) { - message.warning(t("businessLogic.config.modelPlaceholder")); - return; - } - if (onGenerateAgent) { - onGenerateAgent(chosen); - } - }; - - // Select options for available models - const modelSelectOptions = availableModels.map((model) => ({ - value: model.id, - label: model.displayName || model.name, - disabled: model.connect_status !== "available", - })); - - // Handle expand edit - const handleExpandCard = (index: number) => { - setExpandIndex(index); - setExpandModalOpen(true); - }; - - // Handle expand edit save - const handleExpandSave = (newContent: string) => { - switch (expandIndex) { - case 2: - onDutyContentChange?.(newContent); - break; - case 3: - onConstraintContentChange?.(newContent); - break; - case 4: - onFewShotsContentChange?.(newContent); - break; - } - }; - - // Handle manual save - const handleSavePrompt = async () => { - // Don't call update API if no agent ID or in create mode (agent not saved yet) - if (!agentId || isCreatingNewAgent) return; - - try { - const result = await updateAgent( - Number(agentId), - agentName, - agentDescription, - mainAgentModel, - mainAgentMaxStep, - false, - undefined, - businessLogic, - dutyContent, - constraintContent, - fewShotsContent, - agentDisplayName, - mainAgentModelId ?? undefined - ); - - if (result.success) { - onDutyContentChange?.(dutyContent); - onConstraintContentChange?.(constraintContent); - onFewShotsContentChange?.(fewShotsContent); - onAgentDisplayNameChange?.(agentDisplayName); - message.success(t("systemPrompt.message.save.success")); - } else { - throw new Error(result.message); - } - } catch (error) { - log.error(t("systemPrompt.message.save.error"), error); - message.error(t("systemPrompt.message.save.error")); - } - }; - - return ( -
- - - {/* Non-editing mode overlay */} - {!isEditingMode && ( -
-
-
- -

- {t("systemPrompt.nonEditing.title")} -

-
-

- {t("systemPrompt.nonEditing.subtitle")} -

-
-
- )} - - {/* Main title */} -
-
-
- 3 -
-

- {t("guide.steps.describeBusinessLogic.title")} -

-
-
- - {/* Main content */} -
- {/* Business logic description section */} -
-
-

- {t("businessLogic.title")} -

-
- -
- {/* Textarea content area */} -
- { - const newValue = e.target.value; - // Update local state immediately for responsive UI - setLocalBusinessLogic(newValue); - // Clear existing timer - if (businessLogicDebounceTimer.current) { - clearTimeout(businessLogicDebounceTimer.current); - } - // Debounce the parent update to reduce re-renders - businessLogicDebounceTimer.current = setTimeout(() => { - onBusinessLogicChange?.(newValue); - }, 150); // 150ms debounce delay - }} - placeholder={t("businessLogic.placeholder")} - className="w-full resize-none text-sm transition-all duration-300" - style={{ - minHeight: "80px", - maxHeight: "160px", - border: "none", - boxShadow: "none", - padding: 0, - background: "transparent", - overflowX: "hidden", - overflowY: "auto", - }} - autoSize={false} - disabled={!isEditingMode || isGeneratingAgent} - /> -
- - {/* Control area */} -
-
- {t("businessLogic.config.model")}: - { - handleAgentDisplayNameChange(e.target.value); - }} - placeholder={t("agent.displayNamePlaceholder")} - size="large" - disabled={!isEditingMode} - status={ - agentDisplayNameError || - ((isCreatingNewAgent || currentDisplayName !== originalDisplayName) && - agentDisplayNameStatus === NAME_CHECK_STATUS.EXISTS_IN_TENANT) || - shouldShowDuplicateDisplayNameReason - ? "error" - : "" - } - /> - {agentDisplayNameError && ( -

{agentDisplayNameError}

- )} - {!agentDisplayNameError && - (isCreatingNewAgent || currentDisplayName !== originalDisplayName) && - agentDisplayNameStatus === NAME_CHECK_STATUS.EXISTS_IN_TENANT && ( -

- {t("agent.error.displayNameExists", { - displayName: agentDisplayName, - })} -

- )} - {!agentDisplayNameError && - agentDisplayNameStatus !== NAME_CHECK_STATUS.EXISTS_IN_TENANT && - shouldShowDuplicateDisplayNameReason && ( -

- {t("agent.error.displayNameExists", { - displayName: agentDisplayName || editingAgent?.display_name || "", - })} -

- )} -
- - {/* Agent Name */} -
- - { - handleAgentNameChange(e.target.value); - }} - placeholder={t("agent.namePlaceholder")} - size="large" - disabled={!isEditingMode} - status={ - agentNameError || - ((isCreatingNewAgent || currentAgentName !== originalAgentName) && - agentNameStatus === NAME_CHECK_STATUS.EXISTS_IN_TENANT) || - shouldShowDuplicateNameReason - ? "error" - : "" - } - /> - {agentNameError && ( -

{agentNameError}

- )} - {!agentNameError && - (isCreatingNewAgent || currentAgentName !== originalAgentName) && - agentNameStatus === NAME_CHECK_STATUS.EXISTS_IN_TENANT && ( -

- {t("agent.error.nameExists", { name: agentName })} -

- )} - {!agentNameError && - agentNameStatus !== NAME_CHECK_STATUS.EXISTS_IN_TENANT && - shouldShowDuplicateNameReason && ( -

- {t("agent.error.nameExists", { - name: agentName || editingAgent?.name || "", - })} -

- )} -
- - {/* Agent Author */} -
- - { - onAgentAuthorChange?.(e.target.value); - }} - placeholder={t("agent.authorPlaceholder")} - size="large" - disabled={!isEditingMode} - /> - {isCreatingNewAgent && !isSpeedMode && !agentAuthor && user?.email && ( -

- {t("agent.author.hint", { defaultValue: "Default: {{email}}", email: user.email })} -

- )} -
- - {/* Model Selection */} -
- - - {shouldShowModelUnavailableReason && ( -

- {t("agent.error.modelUnavailable", { - modelName: effectiveModelName, - })} -

- )} - {llmModels.length === 0 && ( -

- {t("businessLogic.config.error.noAvailableModels")} -

- )} -
- - {/* Max Steps */} -
- - onMaxStepChange?.(value)} - size="large" - disabled={!isEditingMode} - style={{ width: "100%" }} - /> -
- - {/* Agent Description */} -
- - onAgentDescriptionChange?.(e.target.value)} - placeholder={t("agent.descriptionPlaceholder")} - rows={6} - size="large" - disabled={!isEditingMode} - style={{ - minHeight: "150px", - maxHeight: "200px", - boxShadow: "none", - }} - /> -
-
- ); - - const renderDutyContent = () => ( -
-
- { - setLocalDutyContent(value); - // Immediate update to parent component - if (onDutyContentChange) { - onDutyContentChange(value); - } - }} - /> -
-
- ); - - const renderConstraintContent = () => ( -
-
- { - setLocalConstraintContent(value); - // Immediate update to parent component - if (onConstraintContentChange) { - onConstraintContentChange(value); - } - }} - /> -
-
- ); - - const renderFewShotsContent = () => ( -
-
- { - setLocalFewShotsContent(value); - // Immediate update to parent component - if (onFewShotsContentChange) { - onFewShotsContentChange(value); - } - }} - /> -
-
- ); - - return ( -
- {/* Section Title */} -
-
-

- {t("agent.detailContent.title")} -

-
-
- - {/* Segmented Control */} -
-
-
- - - - -
-
-
- - {/* Content area - flexible height */} -
- {/* Floating expand buttons - positioned outside scrollable content */} - {(activeSegment === "duty" || - activeSegment === "constraint" || - activeSegment === "few-shots") && ( -
- - {/* Action Buttons - Fixed at bottom - Only show in editing mode */} - {isEditingMode && ( -
- {/*
*/} -
- {/* Debug Button - Always show in editing mode */} - - - - {/* Save Button - Different logic for new agent vs existing agent */} - {isCreatingNewAgent ? ( - - ) : ( - - )} -
-
- )} - - {/* Generating prompt overlay */} - {isGeneratingAgent && ( -
-
- -
- {t("agent.generating.title")} -
-
- {t("agent.generating.subtitle")} -
-
-
- )} -
- ); -} diff --git a/frontend/app/[locale]/agents/components/agent/CollaborativeAgentDisplay.tsx b/frontend/app/[locale]/agents/components/agent/CollaborativeAgentDisplay.tsx deleted file mode 100644 index 0e6ed669a..000000000 --- a/frontend/app/[locale]/agents/components/agent/CollaborativeAgentDisplay.tsx +++ /dev/null @@ -1,210 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { Tag, App } from "antd"; -import { Plus, X } from "lucide-react"; - -import { CollaborativeAgentDisplayProps } from "@/types/agentConfig"; - -export default function CollaborativeAgentDisplay({ - availableAgents, - selectedAgentIds, - parentAgentId, - onAgentIdsChange, - isEditingMode, - isGeneratingAgent, - className, - style, -}: CollaborativeAgentDisplayProps) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const [isDropdownVisible, setIsDropdownVisible] = useState(false); - const [selectedAgentToAdd, setSelectedAgentToAdd] = useState( - null - ); - const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); - - // Click outside to close dropdown - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Element; - // Check if the clicked element is inside the dropdown - if (isDropdownVisible && !target.closest(".collaborative-dropdown")) { - setIsDropdownVisible(false); - } - }; - - if (isDropdownVisible) { - document.addEventListener("mousedown", handleClickOutside); - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [isDropdownVisible]); - - // Get detailed information of selected agents - const selectedAgents = availableAgents.filter((agent) => - selectedAgentIds.includes(Number(agent.id)) - ); - - // Get selectable agents (excluding already selected and self) - const availableAgentsToSelect = availableAgents.filter( - (agent) => - !selectedAgentIds.includes(Number(agent.id)) && - agent.is_available !== false && - Number(agent.id) !== parentAgentId - ); - - // Handle adding collaborative agent - const handleAddCollaborativeAgent = (agentIdToAdd?: string) => { - const targetAgentId = agentIdToAdd || selectedAgentToAdd; - - if (!targetAgentId) { - message.warning(t("collaborativeAgent.message.selectAgentFirst")); - return; - } - - // Update local state only - will be saved when agent is saved - const newSelectedAgentIds = [...selectedAgentIds, Number(targetAgentId)]; - onAgentIdsChange(newSelectedAgentIds); - setIsDropdownVisible(false); - setSelectedAgentToAdd(null); - }; - - // Handle removing collaborative agent - const handleRemoveCollaborativeAgent = (agentId: number) => { - // Update local state only - will be saved when agent is saved - const newSelectedAgentIds = selectedAgentIds.filter((id) => id !== agentId); - onAgentIdsChange(newSelectedAgentIds); - }; - - // Handle add button click - const handleAddButtonClick = (event: React.MouseEvent) => { - if (!isEditingMode) { - message.warning(t("collaborativeAgent.message.notInEditMode")); - return; - } - if (isGeneratingAgent) { - message.warning(t("collaborativeAgent.message.generatingInProgress")); - return; - } - - if (!isDropdownVisible) { - // Calculate dropdown position - const rect = event.currentTarget.getBoundingClientRect(); - setDropdownPosition({ - top: rect.bottom + window.scrollY + 4, - left: rect.left + window.scrollX, - }); - } - - setIsDropdownVisible(!isDropdownVisible); - }; - - // Render dropdown component - const renderDropdown = () => { - if (!isDropdownVisible) return null; - - return ( -
- {availableAgentsToSelect.length === 0 ? ( -
- {t("collaborativeAgent.select.noOptions")} -
- ) : ( -
- {availableAgentsToSelect.map((agent) => ( -
{ - handleAddCollaborativeAgent(agent.id); - }} - > - {agent.display_name || agent.name} - {agent.display_name && ( - - ({agent.name}) - - )} -
- ))} -
- )} -
- ); - }; - - return ( -
-
-

- {t("collaborativeAgent.title")} -

-
- - {/* Tag display area - fixed height to avoid layout jumping */} -
-
- {/* Add button always exists, just invisible in non-editing mode */} -
- - {/* Dropdown only renders in editing mode */} - {isEditingMode && renderDropdown()} -
- {selectedAgents.map((agent) => ( - handleRemoveCollaborativeAgent(Number(agent.id))} - closeIcon={} - style={{ - maxWidth: "200px", - }} - > - - {agent.display_name || agent.name} - - - ))} -
-
-
- ); -} - diff --git a/frontend/app/[locale]/agents/components/agent/SubAgentPool.tsx b/frontend/app/[locale]/agents/components/agent/SubAgentPool.tsx deleted file mode 100644 index 0f8bc8b9e..000000000 --- a/frontend/app/[locale]/agents/components/agent/SubAgentPool.tsx +++ /dev/null @@ -1,454 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { Button, Row, Col } from "antd"; -import { - AlertCircle, - Copy, - FileOutput, - Network, - FileInput, - Trash2, - Plus, - X, -} from "lucide-react"; - -import { ScrollArea } from "@/components/ui/scrollArea"; -import { Tooltip, TooltipProvider } from "@/components/ui/tooltip"; -import { Agent, SubAgentPoolProps } from "@/types/agentConfig"; - -import AgentCallRelationshipModal from "@/components/ui/AgentCallRelationshipModal"; - -/** - * Sub Agent Pool Component - */ -type ExtendedSubAgentPoolProps = SubAgentPoolProps & { - /** Agent id that currently has unsaved changes to show blue indicator */ - unsavedAgentId?: number | null; -}; - -export default function SubAgentPool({ - onEditAgent, - onCreateNewAgent, - onImportAgent, - onExitEditMode, - subAgentList = [], - loadingAgents = false, - isImporting = false, - isGeneratingAgent = false, - editingAgent = null, - isCreatingNewAgent = false, - onCopyAgent, - onExportAgent, - onDeleteAgent, - unsavedAgentId = null, -}: ExtendedSubAgentPoolProps) { - const { t } = useTranslation("common"); - - // Call relationship related state - const [callRelationshipModalVisible, setCallRelationshipModalVisible] = - useState(false); - const [selectedAgentForRelationship, setSelectedAgentForRelationship] = - useState(null); - - // Open call relationship modal - const handleViewCallRelationship = (agent: Agent) => { - setSelectedAgentForRelationship(agent); - setCallRelationshipModalVisible(true); - }; - - // Close call relationship modal - const handleCloseCallRelationshipModal = () => { - setCallRelationshipModalVisible(false); - setSelectedAgentForRelationship(null); - }; - - return ( - - -
-
-
-
- 1 -
-

- {t("subAgentPool.management")} -

-
-
- {loadingAgents && ( - - {t("subAgentPool.loading")} - - )} -
-
- -
- {/* Function operation block */} -
- - - -
{ - if (isCreatingNewAgent) { - // If currently in creation mode, click to exit creation mode - onExitEditMode?.(); - } else { - // Otherwise enter creation mode - onCreateNewAgent(); - } - }} - > -
-
- {/* Smoothly cross-fade and scale between Plus and X */} -
-
-
- {isCreatingNewAgent - ? t("subAgentPool.button.exitCreate") - : t("subAgentPool.button.create")} -
-
- {isCreatingNewAgent - ? t("subAgentPool.description.exitCreate") - : t("subAgentPool.description.createAgent")} -
-
-
-
-
- - - - -
-
-
- -
-
-
- {isImporting - ? t("subAgentPool.button.importing") - : t("subAgentPool.button.import")} -
-
- {isImporting - ? t("subAgentPool.description.importing") - : t("subAgentPool.description.importAgent")} -
-
-
-
-
- -
-
- - {/* Agent list block */} -
-
- {t("subAgentPool.section.agentList")} ({subAgentList.length}) -
-
- {subAgentList.map((agent) => { - const isAvailable = agent.is_available !== false; // Default is true, only unavailable when explicitly false - const isCurrentlyEditing = - editingAgent && - String(editingAgent.id) === String(agent.id); // Ensure type matching - const displayName = agent.display_name || agent.name; - - const agentItem = ( -
{ - // Prevent event bubbling - e.preventDefault(); - e.stopPropagation(); - - if (!isGeneratingAgent) { - // Allow all unavailable agents to enter edit mode for configuration - if (isCurrentlyEditing) { - // If currently editing this Agent, click to exit edit mode - onExitEditMode?.(); - } else { - // Enter edit mode (all agents can be edited) - onEditAgent(agent); - } - } - }} - > -
-
-
-
- {!isAvailable && ( - - )} - {displayName && ( - - {displayName} - - )} - {unsavedAgentId !== null && - String(unsavedAgentId) === String(agent.id) && ( - - )} -
-
-
- {agent.description} -
-
- - {/* Operation button area */} -
- {/* Copy agent button */} - {onCopyAgent && ( - -
-
-
- ); - - return
{agentItem}
; - })} -
-
-
-
- - {/* Agent call relationship modal */} - {selectedAgentForRelationship && ( - - )} -
-
- ); -} diff --git a/frontend/app/[locale]/agents/components/agentConfig/CollaborativeAgent.tsx b/frontend/app/[locale]/agents/components/agentConfig/CollaborativeAgent.tsx new file mode 100644 index 000000000..b39da4580 --- /dev/null +++ b/frontend/app/[locale]/agents/components/agentConfig/CollaborativeAgent.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Tag, App, Card, Flex, Dropdown, Space, Col } from "antd"; +import { Plus, X } from "lucide-react"; +import { Agent } from "@/types/agentConfig"; +import { useAgentConfigStore } from "@/stores/agentConfigStore"; +import { useAgentList } from "@/hooks/agent/useAgentList"; +import { useAgentInfo } from "@/hooks/agent/useAgentInfo"; + +interface CollaborativeAgentProps {} + +export default function CollaborativeAgent({}: CollaborativeAgentProps) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + + const currentAgentId = useAgentConfigStore((state) => state.currentAgentId); + const isCreatingMode = useAgentConfigStore((state) => state.isCreatingMode); + const editedAgent = useAgentConfigStore((state) => state.editedAgent); + const updateSubAgentIds = useAgentConfigStore( + (state) => state.updateSubAgentIds + ); + + const { availableAgents } = useAgentList(); + + const editable = currentAgentId || isCreatingMode; + + // Get related agents - use edited agent state (which includes current agent data when editing) + const relatedAgentIds = Array.isArray(editedAgent?.sub_agent_id_list) + ? editedAgent.sub_agent_id_list + : []; + + const relatedAgents = ( + Array.isArray(availableAgents) ? availableAgents : [] + ).filter((agent: Agent) => relatedAgentIds.includes(Number(agent.id))); + + // Filter available agents (exclude already related ones and current agent) + const availableAgentsForMenu = ( + Array.isArray(availableAgents) ? availableAgents : [] + ).filter( + (agent: Agent) => + !relatedAgentIds.includes(Number(agent.id)) && + Number(agent.id) !== currentAgentId + ); + + const handleAddAgent = (agentId: number) => { + const newRelatedAgentIds = [ + ...(Array.isArray(relatedAgentIds) ? relatedAgentIds : []), + agentId, + ]; + updateSubAgentIds(newRelatedAgentIds); + }; + + const handleRemoveAgent = (agentId: number) => { + const newRelatedAgentIds = ( + Array.isArray(relatedAgentIds) ? relatedAgentIds : [] + ).filter((id: number) => id !== agentId); + updateSubAgentIds(newRelatedAgentIds); + }; + + const addRelatedAgent = (event: React.MouseEvent) => {}; + + const menuItems = Array.isArray(availableAgentsForMenu) + ? availableAgentsForMenu.map((agent: Agent) => ({ + key: String(agent.id), + label: ( + <> + {agent.display_name || agent.name} + {agent.display_name && ( + ({agent.name}) + )} + + ), + onClick: () => handleAddAgent(Number(agent.id)), + })) + : []; + + return ( + <> + +

+ {t("collaborativeAgent.title")} +

+ + + + + + + + +
+ + {relatedAgents.map((agent: Agent) => ( + handleRemoveAgent(Number(agent.id))} + className="bg-blue-50 text-blue-700 border-blue-200 truncate" + style={{ + maxWidth: "200px", + }} + > + {agent.display_name || agent.name} + + ))} + +
+
+
+
+ + + ); +} diff --git a/frontend/app/[locale]/agents/components/McpConfigModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx similarity index 70% rename from frontend/app/[locale]/agents/components/McpConfigModal.tsx rename to frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx index ffc315a8d..e217c9aee 100644 --- a/frontend/app/[locale]/agents/components/McpConfigModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx @@ -13,6 +13,8 @@ import { Divider, Tooltip, App, + Upload, + Tabs, } from "antd"; import { Trash, @@ -24,6 +26,8 @@ import { RefreshCw, FileText, Container, + Upload as UploadIcon, + Unplug, } from "lucide-react"; import { McpConfigModalProps, AgentRefreshEvent } from "@/types/agentConfig"; @@ -35,6 +39,7 @@ import { updateToolList, checkMcpServerHealth, addMcpFromConfig, + uploadMcpImage, getMcpContainers, getMcpContainerLogs, deleteMcpContainer, @@ -42,6 +47,8 @@ import { import { McpServer, McpTool, McpContainer } from "@/types/agentConfig"; import { useConfirmModal } from "@/hooks/useConfirmModal"; import log from "@/lib/logger"; +import { UploadFile } from "antd/es/upload/interface"; +import { TabsProps } from "antd"; const { Text, Title } = Typography; @@ -55,6 +62,7 @@ export default function McpConfigModal({ const [serverList, setServerList] = useState([]); const [loading, setLoading] = useState(false); const [addingServer, setAddingServer] = useState(false); + const [enableUploadImage, setEnableUploadImage] = useState(false); const [newServerName, setNewServerName] = useState(""); const [newServerUrl, setNewServerUrl] = useState(""); const [toolsModalVisible, setToolsModalVisible] = useState(false); @@ -81,7 +89,13 @@ export default function McpConfigModal({ const [loadingLogs, setLoadingLogs] = useState(false); const delayedContainerRefreshRef = useRef(undefined); - const actionsLocked = updatingTools || addingContainer; + // Upload image related state + const [uploadingImage, setUploadingImage] = useState(false); + const [uploadFileList, setUploadFileList] = useState([]); + const [uploadPort, setUploadPort] = useState(undefined); + const [uploadServiceName, setUploadServiceName] = useState(""); + + const actionsLocked = updatingTools || addingContainer || uploadingImage; // Helper function to refresh tools and agents asynchronously const refreshToolsAndAgents = async () => { @@ -113,6 +127,7 @@ export default function McpConfigModal({ const result = await getMcpServerList(); if (result.success) { setServerList(result.data); + setEnableUploadImage(result.enable_upload_image || false); } else { message.error(result.message); } @@ -459,6 +474,62 @@ export default function McpConfigModal({ } }; + // Upload MCP image + const handleUploadImage = async () => { + if (uploadFileList.length === 0) { + message.error(t("mcpConfig.message.uploadImageFileRequired")); + return; + } + + if (!uploadPort || uploadPort < 1 || uploadPort > 65535) { + message.error(t("mcpConfig.message.uploadImageValidPortRequired")); + return; + } + + const file = uploadFileList[0].originFileObj; + if (!file) { + message.error(t("mcpConfig.message.uploadImageFileRequired")); + return; + } + + // Validate file type + if (!file.name.toLowerCase().endsWith('.tar')) { + message.error(t("mcpConfig.message.uploadImageInvalidFileType")); + return; + } + + setUploadingImage(true); + try { + const result = await uploadMcpImage( + file, + uploadPort, + uploadServiceName.trim() || undefined + ); + + if (result.success) { + // Clear form + setUploadFileList([]); + setUploadPort(undefined); + setUploadServiceName(""); + + // Refresh lists + await loadContainerList(); + await loadServerList(); + + // Refresh tools and agents + await refreshToolsAndAgents(); + + message.success(t("mcpService.message.uploadImageSuccess")); + } else { + message.error(result.message); + } + } catch (error) { + message.error(t("mcpConfig.message.uploadImageFailed")); + } finally { + setUploadingImage(false); + } + }; + // Add containerized MCP server const handleAddContainer = async () => { if (!containerConfigJson.trim()) { @@ -643,118 +714,204 @@ export default function McpConfigModal({
)} - {/* Add server section */} - - - {t("mcpConfig.addServer.title")} - - -
- setNewServerName(e.target.value)} - style={{ flex: 1 }} - maxLength={20} - disabled={actionsLocked || addingServer} - /> - setNewServerUrl(e.target.value)} - style={{ flex: 2 }} - disabled={actionsLocked || addingServer} - /> - -
-
-
- - {/* Add containerized MCP server section */} - - - <span - style={{ - display: "inline-flex", - alignItems: "center", - gap: 8, - }} - > - <Container style={{ width: 16, height: 16 }} /> - {t("mcpConfig.addContainer.title")} - </span> - - -
- - {t("mcpConfig.addContainer.configHint")} - - setContainerConfigJson(e.target.value)} - rows={6} - disabled={actionsLocked} - style={{ fontFamily: "monospace", fontSize: 12 }} - /> -
-
- {t("mcpConfig.addContainer.port")}: - { - const value = e.target.value; - if (value === "") { - setContainerPort(undefined); - return; - } - const port = parseInt(value); - if (!isNaN(port) && port >= 1 && port <= 65535) { - setContainerPort(port); - } - // If invalid input, keep the previous valid value - }} - style={{ width: 150 }} - disabled={actionsLocked} - /> -
- -
- - + {/* Add MCP server tabs */} + + + {t("mcpConfig.addServer.title")} + + ), + children: ( + + +
+ setNewServerName(e.target.value)} + style={{ flex: 1 }} + maxLength={20} + disabled={actionsLocked || addingServer} + /> + setNewServerUrl(e.target.value)} + style={{ flex: 2 }} + disabled={actionsLocked || addingServer} + /> + +
+
+
+ ), + }, + { + key: "container", + label: ( + + + {t("mcpConfig.addContainer.title")} + + ), + children: ( + + +
+ + {t("mcpConfig.addContainer.configHint")} + + setContainerConfigJson(e.target.value)} + rows={6} + disabled={actionsLocked} + style={{ fontFamily: "monospace", fontSize: 12 }} + /> +
+
+ {t("mcpConfig.addContainer.port")}: + { + const port = parseInt(e.target.value); + + setContainerPort(isNaN(port) ? undefined :port); + }} + min={1} + max={65535} + style={{ width: 150 }} + disabled={actionsLocked} + /> +
+ +
+ + + ), + }, + ...(enableUploadImage ? [{ + key: "upload", + label: ( + + + {t("mcpConfig.uploadImage.title")} + + ), + children: ( + + +
+ + {t("mcpConfig.uploadImage.fileHint")} + + setUploadFileList(fileList)} + beforeUpload={() => false} // Prevent auto upload + accept=".tar" + maxCount={1} + disabled={actionsLocked} + > + + +
+
+ { + const value = e.target.value; + if (value === "") { + setUploadPort(undefined); + return; + } + const port = parseInt(value); + if (!isNaN(port) && port >= 1 && port <= 65535) { + setUploadPort(port); + } + // If invalid input, keep the previous valid value + }} + style={{ width: 150 }} + disabled={actionsLocked} + /> + setUploadServiceName(e.target.value)} + style={{ flex: 1 }} + disabled={actionsLocked} + /> + +
+
+
+ ), + }] : []), + ]} + /> diff --git a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx new file mode 100644 index 000000000..a56df9bd4 --- /dev/null +++ b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx @@ -0,0 +1,380 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import ToolConfigModal from "./tool/ToolConfigModal"; +import { ToolGroup, Tool, ToolParam } from "@/types/agentConfig"; +import { Tabs, Collapse } from "antd"; +import { useAgentConfigStore } from "@/stores/agentConfigStore"; +import { useToolList } from "@/hooks/agent/useToolList"; +import { updateToolConfig } from "@/services/agentConfigService"; +import { useToolInfo } from "@/hooks/tool/useToolInfo"; +import { message } from "antd"; +import { useQueryClient } from "@tanstack/react-query"; + +import { Settings } from "lucide-react"; + +interface ToolManagementProps { + toolGroups: ToolGroup[]; + isCreatingMode?: boolean; + currentAgentId?: number | undefined; +} + +/** + * ToolManagement - Component for displaying tools in tabs + * Provides a tabbed interface for tool organization + */ +export default function ToolManagement({ + toolGroups, + isCreatingMode = true, + currentAgentId, +}: ToolManagementProps) { + const { t } = useTranslation("common"); + const queryClient = useQueryClient(); + + const editable = currentAgentId !== null || isCreatingMode; + // Get state from store + const usedTools = useAgentConfigStore((state) => state.editedAgent.tools); + const updateTools = useAgentConfigStore((state) => state.updateTools); + + // Use tool list hook for data management + const { availableTools } = useToolList(); + + const [activeTabKey, setActiveTabKey] = useState(""); + const [expandedCategories, setExpandedCategories] = useState>( + new Set() + ); + const [isToolModalOpen, setIsToolModalOpen] = useState(false); + const [isClickSetting, setIsClickSetting] = useState(false); + const [selectedTool, setSelectedTool] = useState(null); + const [toolParams, setToolParams] = useState([]); + + // Get tool info for selected tool (when checking if config is needed) + const { data: selectedToolInfo, isLoading: isToolInfoLoading } = useToolInfo( + (selectedTool) ? parseInt(selectedTool.id) : null, + currentAgentId ?? null + ); + + // Effect to handle tool selection when tool info is loaded + useEffect(() => { + let mergedParams: ToolParam[]; + + if (isCreatingMode && selectedTool) { + mergedParams = selectedTool.initParams || []; + } else if (selectedTool && selectedToolInfo) { + mergedParams = selectedTool.initParams?.map((param: ToolParam) => { + const instanceValue = selectedToolInfo?.params?.[param.name]; + return { + ...param, + value: instanceValue !== undefined ? instanceValue : param.value, + }; + }) || []; + } else { + return; + } + setToolParams(mergedParams); + const hasEmptyRequiredParams = mergedParams.some( + (param: ToolParam) => param.required && + (param.value === undefined || param.value === '' || param.value === null) + ); + if (isClickSetting || hasEmptyRequiredParams) { + // Open modal for configuration with pre-calculated params + setIsToolModalOpen(true); + setIsClickSetting(false) + } else { + // Add tool directly + const newSelectedTools = [...usedTools, { + ...selectedTool, + initParams: mergedParams + }]; + updateTools(newSelectedTools); + setSelectedTool(null); // Clear selected tool + setIsClickSetting(false) + } + + + }, [selectedTool, isToolInfoLoading]); + + // Create selected tool ID set for efficient lookup + const selectedToolIdsSet = new Set( + usedTools.map((tool) => tool.id) + ); + + // Set default active tab + useEffect(() => { + if (toolGroups.length > 0 && !activeTabKey) { + setActiveTabKey(toolGroups[0].key); + } + }, [toolGroups, activeTabKey]); + + const handleToolModalCancel = () => { + setIsToolModalOpen(false); + setSelectedTool(null); + setToolParams([]); + setIsClickSetting(false) + }; + + const handleToolModalSave = async (params: ToolParam[]) => { + if (!selectedTool) return; + + // Convert params to backend format + const paramsObj = params.reduce((acc, param) => { + acc[param.name] = param.value; + return acc; + }, {} as Record); + + if (isCreatingMode) { + saveToolConfig(params); + } else if (currentAgentId) { + + try { + const isEnabled = true; // New tool is enabled by default + const result = await updateToolConfig( + parseInt(selectedTool.id), + currentAgentId, + paramsObj, + isEnabled + ); + + if (result.success) { + saveToolConfig(params); + queryClient.invalidateQueries({ + queryKey: ["toolInfo", parseInt(selectedTool.id), currentAgentId] + }); + } else { + message.error(result.message || t("toolConfig.message.saveError")); + } + } catch (error) { + message.error(t("toolConfig.message.saveError")); + } + } + }; + + + const saveToolConfig = async (params: ToolParam[]) => { + // Add tool to selected tools with updated params + const updatedTool = { ...selectedTool!, initParams: params }; + const newSelectedTools = [...usedTools, updatedTool]; + updateTools(newSelectedTools); + + message.success(t("toolConfig.message.saveSuccess")); + + setIsToolModalOpen(false); + setSelectedTool(null); + setToolParams([]); + setIsClickSetting(false) + } + const handleToolSettingsClick = (tool: Tool) => { + setIsClickSetting(true) + setSelectedTool(tool); + }; + + const handleToolSelect = (toolId: number) => { + // Find the tool from available tools + const tool = availableTools.find((t) => parseInt(t.id) === toolId); + if (!tool) return; + + const isCurrentlySelected = usedTools.some( + (t) => parseInt(t.id) === toolId + ); + if (isCurrentlySelected) { + const newSelectedTools = usedTools.filter((t) => parseInt(t.id) !== toolId); + updateTools(newSelectedTools); + } else { + setSelectedTool(tool); + } + } + + const handleToolClick = (toolId: string) => { + const numericId = parseInt(toolId, 10); + handleToolSelect(numericId); + }; + + // Generate Tabs configuration + const tabItems = toolGroups.map((group) => { + // Limit tab display to maximum 7 characters + const displayLabel = + t(group.label).length > 7 + ? `${t(group.label).substring(0, 7)}...` + : t(group.label); + + return { + key: group.key, + label: ( + + {displayLabel} + + ), + children: ( +
+ {group.subGroups ? ( + <> + {/* Collapsible categories using Ant Design Collapse */} +
+ { + const newSet = new Set( + typeof keys === "string" ? [keys] : keys + ); + setExpandedCategories(newSet); + }} + ghost + size="small" + className="tool-categories-collapse mt-1" + items={group.subGroups.map((subGroup, index) => ({ + key: subGroup.key, + label: ( + + {subGroup.label} + + ), + className: `tool-category-panel ${ + index === 0 ? "mt-1" : "mt-3" + }`, + children: ( +
+ {subGroup.tools.map((tool) => { + const isSelected = selectedToolIdsSet.has(tool.id); + return ( +
handleToolClick(tool.id) + : undefined + } + > + {tool.name} + { + e.stopPropagation(); + handleToolSettingsClick(tool); + } + : undefined + } + /> +
+ ); + })} +
+ ), + }))} + /> +
+ + ) : ( + // Regular layout for non-local tools +
+ {group.tools.map((tool) => { + const isSelected = selectedToolIdsSet.has(tool.id); + return ( +
handleToolClick(tool.id) : undefined + } + > + {tool.name} + { + e.stopPropagation(); + handleToolSettingsClick(tool); + } + : undefined + } + /> +
+ ); + })} +
+ )} +
+ ), + }; + }); + + return ( +
+ {toolGroups.length === 0 ? ( +
+ {t("toolPool.noTools")} +
+ ) : ( + + )} + + +
+ ); +} diff --git a/frontend/app/[locale]/agents/components/tool/ToolConfigModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx similarity index 70% rename from frontend/app/[locale]/agents/components/tool/ToolConfigModal.tsx rename to frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx index 81e45a6d9..b4ab08266 100644 --- a/frontend/app/[locale]/agents/components/tool/ToolConfigModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx @@ -13,24 +13,23 @@ import { } from "antd"; import { TOOL_PARAM_TYPES } from "@/const/agentConfig"; -import { ToolParam, ToolConfigModalProps } from "@/types/agentConfig"; -import { - updateToolConfig, - searchToolConfig, - loadLastToolConfig, -} from "@/services/agentConfigService"; -import log from "@/lib/logger"; +import { ToolParam, Tool } from "@/types/agentConfig"; import { useModalPosition } from "@/hooks/useModalPosition"; import ToolTestPanel from "./ToolTestPanel"; +export interface ToolConfigModalProps { + isOpen: boolean; + onCancel: () => void; + onSave: (params: ToolParam[]) => void; // 修改:返回参数数组 + tool?: Tool; + initialParams: ToolParam[]; // 修改:变为必需,移除currentAgentId +} export default function ToolConfigModal({ isOpen, onCancel, onSave, tool, - mainAgentId, - selectedTools = [], - isEditingMode = true, + initialParams, }: ToolConfigModalProps) { const [currentParams, setCurrentParams] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -42,13 +41,6 @@ export default function ToolConfigModal({ const { windowWidth, mainModalTop, mainModalRight } = useModalPosition(isOpen); - const normalizedAgentId = - typeof mainAgentId === "number" && !Number.isNaN(mainAgentId) - ? mainAgentId - : null; - const canPersistToolConfig = - typeof normalizedAgentId === "number" && normalizedAgentId > 0; - // Apply transform to modal when test panel is visible // Move main modal to the left to center both panels together useEffect(() => { @@ -91,65 +83,15 @@ export default function ToolConfigModal({ }; }, [testPanelVisible, isOpen, windowWidth]); - // load tool config + // Initialize with provided params useEffect(() => { - const buildDefaultParams = () => - (tool?.initParams || []).map((param) => ({ - ...param, - value: param.value, - })); - - const loadToolConfig = async () => { - if (!tool) { - setCurrentParams([]); - return; - } - - // In creation mode (no agent ID), always use backend-provided default params - if (!normalizedAgentId) { - setCurrentParams(buildDefaultParams()); - return; - } - - setIsLoading(true); - try { - // In edit mode, load tool config for the specific agent - const result = await searchToolConfig( - parseInt(tool.id), - normalizedAgentId - ); - if (result.success) { - if (result.data?.params) { - const savedParams = tool.initParams.map((param) => { - const savedValue = result.data.params[param.name]; - return { - ...param, - value: savedValue !== undefined ? savedValue : param.value, - }; - }); - setCurrentParams(savedParams); - } else { - setCurrentParams(buildDefaultParams()); - } - } else { - message.error(result.message || t("toolConfig.message.loadError")); - setCurrentParams(buildDefaultParams()); - } - } catch (error) { - log.error(t("toolConfig.message.loadError"), error); - message.error(t("toolConfig.message.loadErrorUseDefault")); - setCurrentParams(buildDefaultParams()); - } finally { - setIsLoading(false); - } - }; - - if (isOpen && tool) { - loadToolConfig(); + if (isOpen && tool && initialParams) { + setCurrentParams(initialParams); + setIsLoading(false); } else { setCurrentParams([]); } - }, [isOpen, tool, normalizedAgentId, t, message]); + }, [tool, initialParams, isOpen]); // check required fields const checkRequiredFields = () => { @@ -182,78 +124,10 @@ export default function ToolConfigModal({ setCurrentParams(newParams); }; - // load last tool config - const handleLoadLastConfig = async () => { - if (!tool) return; - - try { - const result = await loadLastToolConfig(parseInt(tool.id)); - if (result.success && result.data) { - // Parse the last config data - const lastConfig = result.data; - - // Update current params with last config values - const updatedParams = currentParams.map((param) => { - const lastValue = lastConfig[param.name]; - return { - ...param, - value: lastValue !== undefined ? lastValue : param.value, - }; - }); - - setCurrentParams(updatedParams); - message.success(t("toolConfig.message.loadLastConfigSuccess")); - } else { - message.warning(t("toolConfig.message.loadLastConfigNotFound")); - } - } catch (error) { - log.error(t("toolConfig.message.loadLastConfigFailed"), error); - message.error(t("toolConfig.message.loadLastConfigFailed")); - } - }; - - const handleSave = async () => { - if (!tool || !checkRequiredFields()) return; - - try { - // convert params to backend format - const params = currentParams.reduce((acc, param) => { - acc[param.name] = param.value; - return acc; - }, {} as Record); - - if (!canPersistToolConfig) { - message.success(t("toolConfig.message.saveSuccess")); - onSave({ - ...tool, - initParams: currentParams, - }); - return; - } - - // decide enabled status based on whether the tool is in selectedTools - const isEnabled = selectedTools.some((t) => t.id === tool.id); - const result = await updateToolConfig( - parseInt(tool.id), - normalizedAgentId, - params, - isEnabled - ); - - if (result.success) { - message.success(t("toolConfig.message.saveSuccess")); - onSave({ - ...tool, - initParams: currentParams, - }); - } else { - message.error(result.message || t("toolConfig.message.saveError")); - } - } catch (error) { - log.error(t("toolConfig.message.saveFailed"), error); - message.error(t("toolConfig.message.saveFailed")); - } + const handleSave = () => { + if (!checkRequiredFields()) return; + onSave(currentParams); }; // Handle tool testing - open test panel @@ -372,12 +246,6 @@ export default function ToolConfigModal({
{`${tool?.name}`}
- - {isEditingMode && ( + {(