From 1302fe23ff81ccf0ed916fe1e87ec7334be42023 Mon Sep 17 00:00:00 2001 From: zhizhi <928570418@qq.com> Date: Fri, 26 Dec 2025 15:53:24 +0800 Subject: [PATCH 01/62] =?UTF-8?q?=E2=9C=A8config=20ModelEngine=20Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/consts/model.py | 1 + backend/services/model_health_service.py | 7 ++-- backend/services/model_management_service.py | 14 +++++--- backend/services/model_provider_service.py | 21 +++++++----- .../components/model/ModelAddDialog.tsx | 33 ++++++++++++++++--- frontend/hooks/model/useSiliconModelList.ts | 27 +++++++++++---- frontend/public/locales/en/common.json | 1 + frontend/public/locales/zh/common.json | 1 + frontend/services/modelService.ts | 4 +++ 9 files changed, 83 insertions(+), 26 deletions(-) diff --git a/backend/consts/model.py b/backend/consts/model.py index cf22afbf2..46652ec99 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): 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..2a64ece88 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -45,6 +45,10 @@ 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( @@ -286,7 +290,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 +347,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 +360,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..3dfa70c52 100644 --- a/backend/services/model_provider_service.py +++ b/backend/services/model_provider_service.py @@ -85,19 +85,24 @@ 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") + # Allow overriding host and api key via provider_config (from frontend). + # Fall back to environment-configured values. + model_type: str = provider_config.get("model_type", "") + host = provider_config.get("base_url") or MODEL_ENGINE_HOST + api_key = provider_config.get("api_key") or MODEL_ENGINE_APIKEY + + 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"{host.rstrip('/')}/open/router/v1/models", headers=headers ) as response: response.raise_for_status() @@ -130,9 +135,9 @@ 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, + # ModelEngine models will get base_url and api_key from provider_config (or env) + "base_url": host, + "api_key": api_key, }) return filtered_models diff --git a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx index f2257e03d..951d6f201 100644 --- a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx @@ -4,10 +4,10 @@ import { useTranslation } from "react-i18next"; import { Modal, Select, Input, Button, Switch, Tooltip, App } from "antd"; import { InfoCircleFilled } from "@ant-design/icons"; import { - LoaderCircle, - ChevronRight, - ChevronDown, - Settings + LoaderCircle, + ChevronRight, + ChevronDown, + Settings } from "lucide-react"; import { useConfig } from "@/hooks/useConfig"; @@ -169,6 +169,7 @@ export const ModelAddDialog = ({ // Whether to import multiple models at once isBatchImport: false, provider: "modelengine", + modelEngineUrl: "", vectorDimension: "1024", // Default chunk size range for embedding models chunkSizeRange: [ @@ -306,6 +307,14 @@ export const ModelAddDialog = ({ // Check if the form is valid const isFormValid = () => { if (form.isBatchImport) { + // If provider is ModelEngine, require the ModelEngine URL as well. + if (form.provider === "modelengine") { + return ( + form.provider.trim() !== "" && + form.apiKey.trim() !== "" && + ((form as any).modelEngineUrl || "").toString().trim() !== "" + ); + } return form.provider.trim() !== "" && form.apiKey.trim() !== ""; } if (form.type === MODEL_TYPES.EMBEDDING) { @@ -602,6 +611,7 @@ export const ModelAddDialog = ({ isMultimodal: false, isBatchImport: false, provider: "silicon", + modelEngineUrl: "", vectorDimension: "1024", chunkSizeRange: [ DEFAULT_EXPECTED_CHUNK_SIZE, @@ -675,6 +685,21 @@ export const ModelAddDialog = ({ + {/* ModelEngine URL input (only when provider is ModelEngine) */} + {form.provider === "modelengine" && ( +
+ + + handleFormChange("modelEngineUrl", e.target.value) + } + /> +
+ )} )} diff --git a/frontend/hooks/model/useSiliconModelList.ts b/frontend/hooks/model/useSiliconModelList.ts index 32bfd25ca..d82d1d51e 100644 --- a/frontend/hooks/model/useSiliconModelList.ts +++ b/frontend/hooks/model/useSiliconModelList.ts @@ -32,14 +32,18 @@ export const useSiliconModelList = ({ const getModelList = async () => { setShowModelList(true) setLoadingModelList(true) - const modelType = form.type === "embedding" && form.isMultimodal ? - "multi_embedding" as ModelType : + const modelType = form.type === "embedding" && form.isMultimodal ? + "multi_embedding" as ModelType : form.type try { const result = await modelService.addProviderModel({ provider: form.provider, type: modelType, - apiKey: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey + apiKey: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey, + baseUrl: + form.provider === "modelengine" && form.apiKey.trim() !== "" + ? (form as any).modelEngineUrl || "" + : undefined, }) // Ensure each model has a default max_tokens value const modelsWithDefaults = result.map((model: any) => ({ @@ -68,20 +72,29 @@ export const useSiliconModelList = ({ } const getProviderSelectedModalList = async () => { - const modelType = form.type === "embedding" && form.isMultimodal ? - "multi_embedding" as ModelType : + const modelType = form.type === "embedding" && form.isMultimodal ? + "multi_embedding" as ModelType : form.type const result = await modelService.getProviderSelectedModalList({ provider: form.provider, type: modelType, - api_key: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey + api_key: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey, + baseUrl: + form.provider === "modelengine" && form.apiKey.trim() !== "" + ? (form as any).modelEngineUrl || "" + : undefined, }) return result } // Auto-fetch model list when batch import is enabled and API key is provided useEffect(() => { - if (form.isBatchImport && form.apiKey.trim() !== "") { + const requiresUrl = + form.provider === "modelengine" + ? ((form as any).modelEngineUrl || "").toString().trim() !== "" + : true; + + if (form.isBatchImport && form.apiKey.trim() !== "" && requiresUrl) { getModelList() } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 32b17603e..d4f03759f 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -598,6 +598,7 @@ "model.dialog.placeholder.name": "Enter model name as in request body", "model.dialog.placeholder.displayName": "Enter display name for the model", "model.dialog.placeholder.url": "Enter model URL, e.g. https://api.openai.com/v1", + "model.dialog.placeholder.modelEngineUrl": "Enter ModelEngine host URL, e.g. https://120.253.225.102:50001", "model.dialog.placeholder.url.embedding": "Enter model URL, e.g. https://api.openai.com/v1/embeddings", "model.dialog.placeholder.apiKey": "Enter API Key", "model.dialog.placeholder.maxTokens": "Enter maximum tokens", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index f448db8f7..ccc05c165 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -599,6 +599,7 @@ "model.dialog.placeholder.name": "请输入请求体中的模型名称", "model.dialog.placeholder.displayName": "请输入模型的展示名称", "model.dialog.placeholder.url": "请输入模型URL, 例如: https://api.openai.com/v1", + "model.dialog.placeholder.modelEngineUrl": "请输入 ModelEngine 主机地址,例如:https://120.253.225.102:50001", "model.dialog.placeholder.url.embedding": "请输入模型URL, 例如: https://api.openai.com/v1/embeddings", "model.dialog.placeholder.apiKey": "请输入API Key", "model.dialog.placeholder.maxTokens": "请输入最大Token数", diff --git a/frontend/services/modelService.ts b/frontend/services/modelService.ts index 3599bc939..f17d41d2b 100644 --- a/frontend/services/modelService.ts +++ b/frontend/services/modelService.ts @@ -136,6 +136,7 @@ export const modelService = { provider: string; type: ModelType; apiKey: string; + baseUrl?: string; }): Promise => { try { const response = await fetch( @@ -147,6 +148,7 @@ export const modelService = { provider: model.provider, model_type: model.type, api_key: model.apiKey, + ...(model.baseUrl ? { base_url: model.baseUrl } : {}), }), } ); @@ -202,6 +204,7 @@ export const modelService = { provider: string; type: ModelType; api_key: string; + baseUrl?: string; }): Promise => { try { const response = await fetch( @@ -213,6 +216,7 @@ export const modelService = { provider: model.provider, model_type: model.type, api_key: model.api_key, + ...(model.baseUrl ? { base_url: model.baseUrl } : {}), }), } ); From 77b52c773ddddb95241ab504424bdf7e1ea44c45 Mon Sep 17 00:00:00 2001 From: zhizhi <928570418@qq.com> Date: Sat, 27 Dec 2025 11:10:55 +0800 Subject: [PATCH 02/62] =?UTF-8?q?=E2=9C=A8config=20ModelEngine=20Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/model_provider_service.py | 7 ++--- .../services/test_model_health_service.py | 4 --- .../services/test_model_management_service.py | 8 ++--- .../services/test_model_provider_service.py | 18 ++++------- test/backend/test_model_consts.py | 30 +++++++++++++++++++ 5 files changed, 42 insertions(+), 25 deletions(-) create mode 100644 test/backend/test_model_consts.py diff --git a/backend/services/model_provider_service.py b/backend/services/model_provider_service.py index 3dfa70c52..77a9a0d24 100644 --- a/backend/services/model_provider_service.py +++ b/backend/services/model_provider_service.py @@ -85,11 +85,9 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: List of models with canonical fields """ try: - # Allow overriding host and api key via provider_config (from frontend). - # Fall back to environment-configured values. model_type: str = provider_config.get("model_type", "") - host = provider_config.get("base_url") or MODEL_ENGINE_HOST - api_key = provider_config.get("api_key") or MODEL_ENGINE_APIKEY + host = provider_config.get("base_url") + api_key = provider_config.get("api_key") if not host or not api_key: logger.warning("ModelEngine host or api key not configured") @@ -135,7 +133,6 @@ 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 provider_config (or env) "base_url": host, "api_key": api_key, }) diff --git a/test/backend/services/test_model_health_service.py b/test/backend/services/test_model_health_service.py index 737a415d7..4cd08fc1f 100644 --- a/test/backend/services/test_model_health_service.py +++ b/test/backend/services/test_model_health_service.py @@ -1,5 +1,3 @@ -from consts.exceptions import TimeoutException -import asyncio import os import sys from unittest import mock @@ -792,5 +790,3 @@ async def test_embedding_dimension_check_wrapper_value_error(): mock_logger.error.assert_called_once_with( "Error checking embedding dimension: Unsupported model type" ) - - diff --git a/test/backend/services/test_model_management_service.py b/test/backend/services/test_model_management_service.py index 48741a0f8..f1063ad5e 100644 --- a/test/backend/services/test_model_management_service.py +++ b/test/backend/services/test_model_management_service.py @@ -459,7 +459,7 @@ async def test_create_model_for_tenant_multi_embedding_sets_default_chunk_batch( mock_dim.assert_awaited_once() # Should create two records: multi_embedding and its embedding variant assert mock_create.call_count == 2 - + # Verify chunk_batch was set to 10 for both records create_calls = mock_create.call_args_list # First call is for multi_embedding @@ -519,7 +519,7 @@ async def test_batch_create_models_for_tenant_other_provider(): if not hasattr(svc.ProviderEnum, 'MODELENGINE'): modelengine_item = _EnumItem("modelengine") svc.ProviderEnum.MODELENGINE = modelengine_item - + with mock.patch.object(svc, "get_models_by_tenant_factory_type", return_value=[]), \ mock.patch.object(svc, "delete_model_record"), \ mock.patch.object(svc, "split_repo_name", return_value=("openai", "gpt-4")), \ @@ -529,7 +529,7 @@ async def test_batch_create_models_for_tenant_other_provider(): mock.patch.object(svc, "create_model_record", return_value=True): await svc.batch_create_models_for_tenant("u1", "t1", batch_payload) - + # Verify prepare_model_dict was called with empty model_url for non-Silicon/ModelEngine provider call_args = svc.prepare_model_dict.call_args assert call_args[1]["model_url"] == "" # Should be empty for other providers @@ -618,7 +618,7 @@ def get_by_display(display_name, tenant_id): update_calls = [call for call in mock_update.call_args_list if call[0][0] == "id1"] if update_calls: assert update_calls[0][0][1] == {"max_tokens": 8192} - + # Should NOT update model2 (max_tokens same) or model3 (new max_tokens is None) # Verify model2 and model3 were not updated model2_calls = [call for call in mock_update.call_args_list if call[0][0] == "id2"] diff --git a/test/backend/services/test_model_provider_service.py b/test/backend/services/test_model_provider_service.py index b057a2402..5cf9db3a1 100644 --- a/test/backend/services/test_model_provider_service.py +++ b/test/backend/services/test_model_provider_service.py @@ -776,11 +776,9 @@ async def test_modelengine_get_models_llm_success(): """ModelEngine provider should return LLM models with correct type mapping.""" from backend.services.model_provider_service import ModelEngineProvider - provider_config = {"model_type": "llm"} + provider_config = {"model_type": "llm", "base_url": "https://model-engine.com", "api_key": "test-key"} - with mock.patch("backend.services.model_provider_service.MODEL_ENGINE_HOST", "https://model-engine.com"), \ - mock.patch("backend.services.model_provider_service.MODEL_ENGINE_APIKEY", "test-key"), \ - mock.patch("backend.services.model_provider_service.aiohttp.ClientSession") as mock_session_class, \ + with mock.patch("backend.services.model_provider_service.aiohttp.ClientSession") as mock_session_class, \ mock.patch("backend.services.model_provider_service.aiohttp.ClientTimeout"), \ mock.patch("backend.services.model_provider_service.aiohttp.TCPConnector"): @@ -825,11 +823,9 @@ async def test_modelengine_get_models_embedding_success(): """ModelEngine provider should return embedding models with correct type mapping.""" from backend.services.model_provider_service import ModelEngineProvider - provider_config = {"model_type": "embedding"} + provider_config = {"model_type": "embedding", "base_url": "https://model-engine.com", "api_key": "test-key"} - with mock.patch("backend.services.model_provider_service.MODEL_ENGINE_HOST", "https://model-engine.com"), \ - mock.patch("backend.services.model_provider_service.MODEL_ENGINE_APIKEY", "test-key"), \ - mock.patch("backend.services.model_provider_service.aiohttp.ClientSession") as mock_session_class, \ + with mock.patch("backend.services.model_provider_service.aiohttp.ClientSession") as mock_session_class, \ mock.patch("backend.services.model_provider_service.aiohttp.ClientTimeout"), \ mock.patch("backend.services.model_provider_service.aiohttp.TCPConnector"): @@ -871,11 +867,9 @@ async def test_modelengine_get_models_all_types(): """ModelEngine provider should return all models when no type filter specified.""" from backend.services.model_provider_service import ModelEngineProvider - provider_config = {} # No model_type filter + provider_config = {"base_url": "https://model-engine.com", "api_key": "test-key"} # No model_type filter - with mock.patch("backend.services.model_provider_service.MODEL_ENGINE_HOST", "https://model-engine.com"), \ - mock.patch("backend.services.model_provider_service.MODEL_ENGINE_APIKEY", "test-key"), \ - mock.patch("backend.services.model_provider_service.aiohttp.ClientSession") as mock_session_class, \ + with mock.patch("backend.services.model_provider_service.aiohttp.ClientSession") as mock_session_class, \ mock.patch("backend.services.model_provider_service.aiohttp.ClientTimeout"), \ mock.patch("backend.services.model_provider_service.aiohttp.TCPConnector"): diff --git a/test/backend/test_model_consts.py b/test/backend/test_model_consts.py new file mode 100644 index 000000000..78e77ce77 --- /dev/null +++ b/test/backend/test_model_consts.py @@ -0,0 +1,30 @@ +import pytest +from pydantic import ValidationError + +from backend.consts import model as model_consts + + +def test_model_connect_status_enum_defaults_and_get_value(): + assert model_consts.ModelConnectStatusEnum.get_default() == "not_detected" + assert model_consts.ModelConnectStatusEnum.get_value("") == "not_detected" + assert model_consts.ModelConnectStatusEnum.get_value(None) == "not_detected" + assert model_consts.ModelConnectStatusEnum.get_value("available") == "available" + + +def test_model_request_and_validation(): + # Basic construction + mr = model_consts.ModelRequest(model_name="mymodel", model_type="llm") + assert mr.model_name == "mymodel" + assert mr.model_type == "llm" + + # Chunk create request requires non-empty content + with pytest.raises(ValidationError): + model_consts.ChunkCreateRequest(content="") + + # Valid chunk create + req = model_consts.ChunkCreateRequest(content="a", title="t", filename="f") + assert req.content == "a" + assert req.title == "t" + assert req.filename == "f" + + From 98fb7d994b7705f4c43214383062894df39d859a Mon Sep 17 00:00:00 2001 From: zhizhi <928570418@qq.com> Date: Sat, 27 Dec 2025 11:10:55 +0800 Subject: [PATCH 03/62] =?UTF-8?q?=E2=9C=A8config=20ModelEngine=20Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/model_management_service.py | 2 -- backend/services/model_provider_service.py | 7 ++-- .../services/test_model_health_service.py | 4 --- .../services/test_model_management_service.py | 35 ++++++++++++++++--- .../services/test_model_provider_service.py | 18 ++++------ test/backend/test_model_consts.py | 30 ++++++++++++++++ 6 files changed, 69 insertions(+), 27 deletions(-) create mode 100644 test/backend/test_model_consts.py diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index 2a64ece88..dc38026d8 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -48,8 +48,6 @@ async def create_model_for_tenant(user_id: str, tenant_id: str, model_data: Dict 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 ("", "") diff --git a/backend/services/model_provider_service.py b/backend/services/model_provider_service.py index 3dfa70c52..77a9a0d24 100644 --- a/backend/services/model_provider_service.py +++ b/backend/services/model_provider_service.py @@ -85,11 +85,9 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: List of models with canonical fields """ try: - # Allow overriding host and api key via provider_config (from frontend). - # Fall back to environment-configured values. model_type: str = provider_config.get("model_type", "") - host = provider_config.get("base_url") or MODEL_ENGINE_HOST - api_key = provider_config.get("api_key") or MODEL_ENGINE_APIKEY + host = provider_config.get("base_url") + api_key = provider_config.get("api_key") if not host or not api_key: logger.warning("ModelEngine host or api key not configured") @@ -135,7 +133,6 @@ 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 provider_config (or env) "base_url": host, "api_key": api_key, }) diff --git a/test/backend/services/test_model_health_service.py b/test/backend/services/test_model_health_service.py index 737a415d7..4cd08fc1f 100644 --- a/test/backend/services/test_model_health_service.py +++ b/test/backend/services/test_model_health_service.py @@ -1,5 +1,3 @@ -from consts.exceptions import TimeoutException -import asyncio import os import sys from unittest import mock @@ -792,5 +790,3 @@ async def test_embedding_dimension_check_wrapper_value_error(): mock_logger.error.assert_called_once_with( "Error checking embedding dimension: Unsupported model type" ) - - diff --git a/test/backend/services/test_model_management_service.py b/test/backend/services/test_model_management_service.py index 48741a0f8..19a731dca 100644 --- a/test/backend/services/test_model_management_service.py +++ b/test/backend/services/test_model_management_service.py @@ -307,6 +307,7 @@ async def test_create_model_for_tenant_success_llm(): "base_url": "http://localhost:8000", "model_type": "llm", } + model_data['ssl_verify'] = False await svc.create_model_for_tenant(user_id, tenant_id, model_data) @@ -316,6 +317,32 @@ async def test_create_model_for_tenant_success_llm(): assert mock_create.call_count == 1 +@pytest.mark.asyncio +async def test_create_model_for_tenant_open_router_disables_ssl(): + """When base_url contains 'open/router' ssl_verify should be set to False.""" + svc = import_svc() + + with mock.patch.object(svc, "get_model_by_display_name", return_value=None), \ + mock.patch.object(svc, "create_model_record") as mock_create, \ + mock.patch.object(svc, "split_repo_name", return_value=("modelengine", "m")): + + user_id = "u1" + tenant_id = "t1" + model_data = { + "model_name": "modelengine/m", + "display_name": None, + "base_url": "https://api.example.com/open/router/v1", + "model_type": "llm", + } + + await svc.create_model_for_tenant(user_id, tenant_id, model_data) + + # Ensure a single record created and ssl_verify was disabled + assert mock_create.call_count == 1 + create_args = mock_create.call_args[0][0] + assert create_args["ssl_verify"] is False + + @pytest.mark.asyncio async def test_create_model_for_tenant_conflict_raises(): svc = import_svc() @@ -459,7 +486,7 @@ async def test_create_model_for_tenant_multi_embedding_sets_default_chunk_batch( mock_dim.assert_awaited_once() # Should create two records: multi_embedding and its embedding variant assert mock_create.call_count == 2 - + # Verify chunk_batch was set to 10 for both records create_calls = mock_create.call_args_list # First call is for multi_embedding @@ -519,7 +546,7 @@ async def test_batch_create_models_for_tenant_other_provider(): if not hasattr(svc.ProviderEnum, 'MODELENGINE'): modelengine_item = _EnumItem("modelengine") svc.ProviderEnum.MODELENGINE = modelengine_item - + with mock.patch.object(svc, "get_models_by_tenant_factory_type", return_value=[]), \ mock.patch.object(svc, "delete_model_record"), \ mock.patch.object(svc, "split_repo_name", return_value=("openai", "gpt-4")), \ @@ -529,7 +556,7 @@ async def test_batch_create_models_for_tenant_other_provider(): mock.patch.object(svc, "create_model_record", return_value=True): await svc.batch_create_models_for_tenant("u1", "t1", batch_payload) - + # Verify prepare_model_dict was called with empty model_url for non-Silicon/ModelEngine provider call_args = svc.prepare_model_dict.call_args assert call_args[1]["model_url"] == "" # Should be empty for other providers @@ -618,7 +645,7 @@ def get_by_display(display_name, tenant_id): update_calls = [call for call in mock_update.call_args_list if call[0][0] == "id1"] if update_calls: assert update_calls[0][0][1] == {"max_tokens": 8192} - + # Should NOT update model2 (max_tokens same) or model3 (new max_tokens is None) # Verify model2 and model3 were not updated model2_calls = [call for call in mock_update.call_args_list if call[0][0] == "id2"] diff --git a/test/backend/services/test_model_provider_service.py b/test/backend/services/test_model_provider_service.py index b057a2402..5cf9db3a1 100644 --- a/test/backend/services/test_model_provider_service.py +++ b/test/backend/services/test_model_provider_service.py @@ -776,11 +776,9 @@ async def test_modelengine_get_models_llm_success(): """ModelEngine provider should return LLM models with correct type mapping.""" from backend.services.model_provider_service import ModelEngineProvider - provider_config = {"model_type": "llm"} + provider_config = {"model_type": "llm", "base_url": "https://model-engine.com", "api_key": "test-key"} - with mock.patch("backend.services.model_provider_service.MODEL_ENGINE_HOST", "https://model-engine.com"), \ - mock.patch("backend.services.model_provider_service.MODEL_ENGINE_APIKEY", "test-key"), \ - mock.patch("backend.services.model_provider_service.aiohttp.ClientSession") as mock_session_class, \ + with mock.patch("backend.services.model_provider_service.aiohttp.ClientSession") as mock_session_class, \ mock.patch("backend.services.model_provider_service.aiohttp.ClientTimeout"), \ mock.patch("backend.services.model_provider_service.aiohttp.TCPConnector"): @@ -825,11 +823,9 @@ async def test_modelengine_get_models_embedding_success(): """ModelEngine provider should return embedding models with correct type mapping.""" from backend.services.model_provider_service import ModelEngineProvider - provider_config = {"model_type": "embedding"} + provider_config = {"model_type": "embedding", "base_url": "https://model-engine.com", "api_key": "test-key"} - with mock.patch("backend.services.model_provider_service.MODEL_ENGINE_HOST", "https://model-engine.com"), \ - mock.patch("backend.services.model_provider_service.MODEL_ENGINE_APIKEY", "test-key"), \ - mock.patch("backend.services.model_provider_service.aiohttp.ClientSession") as mock_session_class, \ + with mock.patch("backend.services.model_provider_service.aiohttp.ClientSession") as mock_session_class, \ mock.patch("backend.services.model_provider_service.aiohttp.ClientTimeout"), \ mock.patch("backend.services.model_provider_service.aiohttp.TCPConnector"): @@ -871,11 +867,9 @@ async def test_modelengine_get_models_all_types(): """ModelEngine provider should return all models when no type filter specified.""" from backend.services.model_provider_service import ModelEngineProvider - provider_config = {} # No model_type filter + provider_config = {"base_url": "https://model-engine.com", "api_key": "test-key"} # No model_type filter - with mock.patch("backend.services.model_provider_service.MODEL_ENGINE_HOST", "https://model-engine.com"), \ - mock.patch("backend.services.model_provider_service.MODEL_ENGINE_APIKEY", "test-key"), \ - mock.patch("backend.services.model_provider_service.aiohttp.ClientSession") as mock_session_class, \ + with mock.patch("backend.services.model_provider_service.aiohttp.ClientSession") as mock_session_class, \ mock.patch("backend.services.model_provider_service.aiohttp.ClientTimeout"), \ mock.patch("backend.services.model_provider_service.aiohttp.TCPConnector"): diff --git a/test/backend/test_model_consts.py b/test/backend/test_model_consts.py new file mode 100644 index 000000000..78e77ce77 --- /dev/null +++ b/test/backend/test_model_consts.py @@ -0,0 +1,30 @@ +import pytest +from pydantic import ValidationError + +from backend.consts import model as model_consts + + +def test_model_connect_status_enum_defaults_and_get_value(): + assert model_consts.ModelConnectStatusEnum.get_default() == "not_detected" + assert model_consts.ModelConnectStatusEnum.get_value("") == "not_detected" + assert model_consts.ModelConnectStatusEnum.get_value(None) == "not_detected" + assert model_consts.ModelConnectStatusEnum.get_value("available") == "available" + + +def test_model_request_and_validation(): + # Basic construction + mr = model_consts.ModelRequest(model_name="mymodel", model_type="llm") + assert mr.model_name == "mymodel" + assert mr.model_type == "llm" + + # Chunk create request requires non-empty content + with pytest.raises(ValidationError): + model_consts.ChunkCreateRequest(content="") + + # Valid chunk create + req = model_consts.ChunkCreateRequest(content="a", title="t", filename="f") + assert req.content == "a" + assert req.title == "t" + assert req.filename == "f" + + From 50b041cfd08d3af9751b9717a59d161037309331 Mon Sep 17 00:00:00 2001 From: zhizhi <928570418@qq.com> Date: Sat, 27 Dec 2025 14:38:53 +0800 Subject: [PATCH 04/62] =?UTF-8?q?=E2=9C=A8config=20ModelEngine=20Service?= =?UTF-8?q?=20part2=20delete=20model=20engine=20healthy=20check=20interfac?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/config_app.py | 2 - backend/apps/me_model_managment_app.py | 47 --- .../services/me_model_management_service.py | 55 ---- frontend/app/[locale]/page.tsx | 131 +------- frontend/app/[locale]/setup/SetupLayout.tsx | 85 ++--- frontend/public/locales/en/common.json | 4 - frontend/public/locales/zh/common.json | 4 - frontend/services/api.ts | 9 +- .../app/test_me_model_managment_app.py | 302 ------------------ .../test_me_model_management_service.py | 159 --------- 10 files changed, 36 insertions(+), 762 deletions(-) delete mode 100644 backend/apps/me_model_managment_app.py delete mode 100644 backend/services/me_model_management_service.py delete mode 100644 test/backend/app/test_me_model_managment_app.py delete mode 100644 test/backend/services/test_me_model_management_service.py 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/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/frontend/app/[locale]/page.tsx b/frontend/app/[locale]/page.tsx index 3b43c74c6..2657b5f02 100644 --- a/frontend/app/[locale]/page.tsx +++ b/frontend/app/[locale]/page.tsx @@ -9,8 +9,6 @@ import { LoginModal } from "@/components/auth/loginModal"; import { RegisterModal } from "@/components/auth/registerModal"; import { useAuth } from "@/hooks/useAuth"; import { ConfigProvider, App } from "antd"; -import modelEngineService from "@/services/modelEngineService"; -import { CONNECTION_STATUS, ConnectionStatus } from "@/const/modelConfig"; import log from "@/lib/logger"; // Import content components @@ -26,8 +24,6 @@ import SetupLayout from "./setup/SetupLayout"; import AgentImportWizard from "@/components/agent/AgentImportWizard"; import { ChatContent } from "./chat/internal/ChatContent"; import { ChatTopNavContent } from "./chat/internal/ChatTopNavContent"; -import { Badge, Button as AntButton } from "antd"; -import { RefreshCw } from "lucide-react"; import { USER_ROLES } from "@/const/modelConfig"; import MarketContent from "./market/MarketContent"; import UsersContent from "./users/UsersContent"; @@ -86,11 +82,7 @@ export default function Home() { // View state management with localStorage persistence const [currentView, setCurrentView] = useState(getSavedView); - // Connection status for model-dependent views - const [connectionStatus, setConnectionStatus] = useState( - CONNECTION_STATUS.PROCESSING - ); - const [isCheckingConnection, setIsCheckingConnection] = useState(false); + // Connection status removed per request. // Space-specific states const [agents, setAgents] = useState([]); @@ -167,19 +159,7 @@ export default function Home() { } }; - // Check ModelEngine connection status - const checkModelEngineConnection = async () => { - setIsCheckingConnection(true); - try { - const result = await modelEngineService.checkConnection(); - setConnectionStatus(result.status); - } catch (error) { - log.error(t("setup.page.error.checkConnection"), error); - setConnectionStatus(CONNECTION_STATUS.ERROR); - } finally { - setIsCheckingConnection(false); - } - }; + // Connection check removed. // Load agents for space view const loadAgents = async () => { @@ -381,34 +361,21 @@ export default function Home() { case "models": return (
- +
); case "agents": return (
- +
); case "knowledges": return (
- +
); @@ -462,44 +429,28 @@ export default function Home() { case "market": return (
- +
); case "users": return (
- +
); case "mcpTools": return (
- +
); case "monitoring": return (
- +
); @@ -518,33 +469,15 @@ export default function Home() { completeText={t("setup.navigation.button.complete")} > {currentSetupStep === "models" && isAdmin && ( - + )} {currentSetupStep === "knowledges" && ( - + )} {currentSetupStep === "agents" && isAdmin && ( - + )} ); @@ -554,42 +487,7 @@ export default function Home() { } }; - // Get status text for connection badge - const getStatusText = () => { - switch (connectionStatus) { - case CONNECTION_STATUS.SUCCESS: - return t("setup.header.status.connected"); - case CONNECTION_STATUS.ERROR: - return t("setup.header.status.disconnected"); - case CONNECTION_STATUS.PROCESSING: - return t("setup.header.status.checking"); - default: - return t("setup.header.status.unknown"); - } - }; - - // Render status badge for setup view - const renderStatusBadge = () => ( -
- - - } - size="small" - type="text" - onClick={checkModelEngineConnection} - disabled={isCheckingConnection} - className="ml-1.5 !p-0 !h-auto !min-w-0" - /> -
- ); + // Note: Setup connection status UI removed (badge + refresh) per request. return ( : undefined } - topNavbarAdditionalRightContent={ - currentView === "setup" ? renderStatusBadge() : undefined - } > {renderContent()} diff --git a/frontend/app/[locale]/setup/SetupLayout.tsx b/frontend/app/[locale]/setup/SetupLayout.tsx index 3707ac665..d2d98a3e1 100644 --- a/frontend/app/[locale]/setup/SetupLayout.tsx +++ b/frontend/app/[locale]/setup/SetupLayout.tsx @@ -3,11 +3,10 @@ import {ReactNode} from "react"; import {useTranslation} from "react-i18next"; -import {Badge, Button, Dropdown} from "antd"; +import { Dropdown } from "antd"; import { ChevronDown, Globe, - RefreshCw, } from "lucide-react"; import {languageOptions} from "@/const/constants"; import {useLanguageSwitch} from "@/lib/language"; @@ -16,73 +15,29 @@ import {CONNECTION_STATUS, ConnectionStatus,} from "@/const/modelConfig"; // ================ Setup Header Content Components ================ // These components are exported so they can be used to customize the TopNavbar -interface SetupHeaderRightContentProps { - connectionStatus: ConnectionStatus; - isCheckingConnection: boolean; - onCheckConnection: () => void; -} - -export function SetupHeaderRightContent({ - connectionStatus, - isCheckingConnection, - onCheckConnection, -}: SetupHeaderRightContentProps) { +export function SetupHeaderRightContent() { const { t } = useTranslation(); const { currentLanguage, handleLanguageChange } = useLanguageSwitch(); - // Get status text - const getStatusText = () => { - switch (connectionStatus) { - case CONNECTION_STATUS.SUCCESS: - return t("setup.header.status.connected"); - case CONNECTION_STATUS.ERROR: - return t("setup.header.status.disconnected"); - case CONNECTION_STATUS.PROCESSING: - return t("setup.header.status.checking"); - default: - return t("setup.header.status.unknown"); - } - }; - return ( -
- ({ - key: opt.value, - label: opt.label, - })), - onClick: ({ key }) => handleLanguageChange(key as string), - }} - > - - - {languageOptions.find((o) => o.value === currentLanguage)?.label || - currentLanguage} - - - - {/* ModelEngine connectivity status */} -
- -
-
+
+ ({ + key: opt.value, + label: opt.label, + })), + onClick: ({ key }) => handleLanguageChange(key as string), + }} + > + + + {languageOptions.find((o) => o.value === currentLanguage)?.label || + currentLanguage} + + + +
); } diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 045111a26..0c8561e4c 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -260,10 +260,6 @@ "taskWindow.noTaskMessages": "No task messages yet", "taskWindow.taskDetails": "Task Details", - "setup.header.status.connected": "ModelEngine Connected", - "setup.header.status.disconnected": "ModelEngine Disconnected", - "setup.header.status.checking": "Checking", - "setup.header.status.unknown": "Unknown Status", "setup.header.button.back": "Back to Home", "setup.header.title": "Quick Setup", "setup.header.description": "Smartly build any assistant, accurately solve every challenge", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 2a6cf1c27..8f35e9662 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -260,10 +260,6 @@ "taskWindow.noTaskMessages": "暂无任务消息", "taskWindow.taskDetails": "任务详情", - "setup.header.status.connected": "ModelEngine 已连接", - "setup.header.status.disconnected": "ModelEngine 未连接", - "setup.header.status.checking": "检查中", - "setup.header.status.unknown": "未知状态", "setup.header.button.back": "返回首页", "setup.header.title": "快速配置", "setup.header.description": "智能生成每一种助手,精准解决每一个问题", diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 81856de87..86ffeacda 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -93,13 +93,10 @@ export const API_ENDPOINTS = { `${API_BASE_URL}/image?url=${encodeURIComponent(url)}&format=${format}`, }, model: { - // ModelEngine health check - modelEngineHealthcheck: `${API_BASE_URL}/me/healthcheck`, - // Model lists officialModelList: `${API_BASE_URL}/model/list`, // ModelEngine models are also in this list customModelList: `${API_BASE_URL}/model/list`, - + // Custom model service customModelCreate: `${API_BASE_URL}/model/create`, customModelCreateProvider: `${API_BASE_URL}/model/provider/create`, @@ -207,7 +204,7 @@ export const API_ENDPOINTS = { if (params?.category) queryParams.append('category', params.category); if (params?.tag) queryParams.append('tag', params.tag); if (params?.search) queryParams.append('search', params.search); - + const queryString = queryParams.toString(); return `${API_BASE_URL}/market/agents${queryString ? `?${queryString}` : ''}`; }, @@ -315,4 +312,4 @@ declare global { interface Window { __isHandlingSessionExpired?: boolean; } -} \ No newline at end of file +} diff --git a/test/backend/app/test_me_model_managment_app.py b/test/backend/app/test_me_model_managment_app.py deleted file mode 100644 index 141e41e1e..000000000 --- a/test/backend/app/test_me_model_managment_app.py +++ /dev/null @@ -1,302 +0,0 @@ -import os -import sys -from enum import Enum -from http import HTTPStatus -from typing import Any -from unittest.mock import patch, MagicMock, AsyncMock - -import pytest -from pydantic import BaseModel - - -# Dynamically determine the backend path -current_dir = os.path.dirname(os.path.abspath(__file__)) -backend_dir = os.path.abspath(os.path.join(current_dir, "../../../backend")) -sys.path.append(backend_dir) - -# Apply critical patches before importing any modules -# This prevents real AWS/MinIO/Elasticsearch calls during import -patch('botocore.client.BaseClient._make_api_call', return_value={}).start() - -# Patch storage factory and MinIO config validation to avoid errors during initialization -# These patches must be started before any imports that use MinioClient -storage_client_mock = MagicMock() -minio_mock = MagicMock() -minio_mock._ensure_bucket_exists = MagicMock() -minio_mock.client = MagicMock() -patch('nexent.storage.storage_client_factory.create_storage_client_from_config', return_value=storage_client_mock).start() -patch('nexent.storage.minio_config.MinIOStorageConfig.validate', lambda self: None).start() -patch('backend.database.client.MinioClient', return_value=minio_mock).start() -patch('database.client.MinioClient', return_value=minio_mock).start() -patch('backend.database.client.minio_client', minio_mock).start() -patch('backend.database.client.db_client', MagicMock()).start() -patch('elasticsearch.Elasticsearch', return_value=MagicMock()).start() -patch('aiohttp.ClientSession', MagicMock()).start() - -from consts.exceptions import MEConnectionException, NotFoundException, TimeoutException - - -class ModelConnectStatusEnum(str, Enum): - AVAILABLE = "AVAILABLE" - DETECTING = "DETECTING" - UNAVAILABLE = "UNAVAILABLE" - -# Define response models - - -class ModelResponse(BaseModel): - code: int - message: str - data: Any = None - -# Now import the module after mocking dependencies -from fastapi.testclient import TestClient -from fastapi import FastAPI - -# Import the router after all mocks are in place -from backend.apps.me_model_managment_app import router - -# Create a FastAPI app and include the router for testing -app = FastAPI() -app.include_router(router) -client = TestClient(app) - - -@pytest.fixture -def model_data(): - """Fixture providing sample model data""" - return { - "data": [ - {"name": "model1", "type": "embed", "version": "1.0"}, - {"name": "model2", "type": "chat", "version": "1.0"}, - {"name": "model3", "type": "rerank", "version": "1.0"}, - {"name": "model4", "type": "embed", "version": "2.0"} - ] - } - - -@pytest.fixture -def mock_session_response(model_data): - """Fixture providing mock session and response""" - mock_session = AsyncMock() - mock_response = AsyncMock() - - # Setup default response - mock_response.status = 200 - mock_response.json = AsyncMock(return_value=model_data) - mock_response.__aenter__.return_value = mock_response - - mock_session.get.return_value = mock_response - - return mock_session, mock_response - - -@pytest.mark.asyncio -async def test_get_me_models_success(): - """Test successful model list retrieval""" - # Create a test-specific FastAPI app with a mocked route - test_app = FastAPI() - - @test_app.get("/me/model/list") - async def mock_list_models(): - # Define test data - test_data = [ - {"name": "model1", "type": "embed", "version": "1.0"}, - {"name": "model2", "type": "chat", "version": "1.0"}, - {"name": "model3", "type": "rerank", "version": "1.0"}, - {"name": "model4", "type": "embed", "version": "2.0"} - ] - - # Return a successful response - return { - "code": 200, - "message": "Successfully retrieved", - "data": test_data - } - - # Create a test client with our mocked app - from fastapi.testclient import TestClient - test_client = TestClient(test_app) - - # Test with our test-specific client - response = test_client.get("/me/model/list") - - # Assertions - assert response.status_code == 200 - response_data = response.json() - assert response_data["message"] == "Successfully retrieved" - assert len(response_data["data"]) == 4 # All models returned - - -@pytest.mark.asyncio -async def test_get_me_models_with_filter(): - """Test model list retrieval with type filter""" - # Create a test-specific FastAPI app with a mocked route - from fastapi import FastAPI, Query - - test_app = FastAPI() - - @test_app.get("/me/model/list") - async def mock_list_models_with_filter(type: str = Query(None)): - # Define test data - all_models = [ - {"name": "model1", "type": "embed", "version": "1.0"}, - {"name": "model2", "type": "chat", "version": "1.0"}, - {"name": "model3", "type": "rerank", "version": "1.0"}, - {"name": "model4", "type": "embed", "version": "2.0"} - ] - - # Filter models if type is provided - if type: - filtered_models = [ - model for model in all_models if model["type"] == type] - - # Return 404 if no models found with this type - if not filtered_models: - return { - "code": 404, - "message": f"No models found with type {type}", - "data": [] - } - - # Return filtered models - return { - "code": 200, - "message": "Successfully retrieved", - "data": filtered_models - } - - # Return all models if no filter - return { - "code": 200, - "message": "Successfully retrieved", - "data": all_models - } - - # Create a test client with our mocked app - from fastapi.testclient import TestClient - test_client = TestClient(test_app) - - # Test with TestClient for embed type - response = test_client.get("/me/model/list?type=embed") - - # Assertions for embed type - assert response.status_code == 200 - response_data = response.json() - assert len(response_data["data"]) == 2 # Only embed models - model_names = [model["name"] for model in response_data["data"]] - assert "model1" in model_names - assert "model4" in model_names - - # Test with TestClient for chat type - response = test_client.get("/me/model/list?type=chat") - - # Assertions for chat type - assert response.status_code == 200 - response_data = response.json() - assert len(response_data["data"]) == 1 # Only chat models - assert response_data["data"][0]["name"] == "model2" - - -# NOTE: The following tests are disabled because /me/model/list endpoint has been removed -# Model listing is now handled through the main model management endpoints - -# @pytest.mark.asyncio -# async def test_get_me_models_env_not_configured_returns_skip_message_and_empty_list(): -# """When ME env not configured, endpoint returns 200 with skip message and empty data.""" -# pass - -# @pytest.mark.asyncio -# async def test_get_me_models_not_found_filter(): -# """Test model list retrieval with not found filter""" -# pass - -# @pytest.mark.asyncio -# async def test_get_me_models_timeout(): -# """Test model list retrieval with timeout via real route""" -# pass - -# @pytest.mark.asyncio -# async def test_get_me_models_exception(): -# """Test model list retrieval with generic exception""" -# pass - -# @pytest.mark.asyncio -# async def test_get_me_models_success_response(): -# """Test successful model list retrieval with proper JSONResponse format""" -# pass - - -@pytest.mark.asyncio -async def test_check_me_connectivity_env_not_configured_returns_skip_message(): - """When ME env not configured, healthcheck returns connectivity False and skip message.""" - with patch('backend.apps.me_model_managment_app.check_me_variable_set', AsyncMock(return_value=False)): - response = client.get("/me/healthcheck") - - assert response.status_code == HTTPStatus.OK - body = response.json() - assert body["connectivity"] is False - assert body["message"] == "ModelEngine platform environment variables not configured. Healthcheck skipped." - - -@pytest.mark.asyncio -async def test_check_me_connectivity_success(): - """Test successful ME connectivity check""" - # Mock the check_me_connectivity function from the service - with patch('backend.apps.me_model_managment_app.check_me_variable_set', AsyncMock(return_value=True)): - with patch('backend.apps.me_model_managment_app.check_me_connectivity', AsyncMock(return_value=None)): - # Test with TestClient - response = client.get("/me/healthcheck") - - # Assertions - assert response.status_code == HTTPStatus.OK - response_data = response.json() - assert response_data["connectivity"] is True - assert response_data["message"] == "ModelEngine platform connected successfully." - - -@pytest.mark.asyncio -async def test_check_me_connectivity_failure(): - """Trigger MEConnectionException to simulate connectivity failure""" - # Patch the connectivity check to raise MEConnectionException so the route returns 503 - with patch('backend.apps.me_model_managment_app.check_me_variable_set', AsyncMock(return_value=True)): - with patch('backend.apps.me_model_managment_app.check_me_connectivity', AsyncMock(side_effect=MEConnectionException("Downstream 404 or similar"))): - response = client.get("/me/healthcheck") - - assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - body = response.json() - assert "ModelEngine connection failed" in body["detail"] - - -@pytest.mark.asyncio -async def test_check_me_connectivity_timeout(): - """Test ME connectivity check with timeout""" - # Mock the connectivity check to raise TimeoutException so the route returns 408 - with patch('backend.apps.me_model_managment_app.check_me_variable_set', AsyncMock(return_value=True)): - with patch('backend.apps.me_model_managment_app.check_me_connectivity', AsyncMock(side_effect=TimeoutException("timeout simulated"))): - response = client.get("/me/healthcheck") - - # Assertions - route maps TimeoutException -> 408 - assert response.status_code == HTTPStatus.REQUEST_TIMEOUT - body = response.json() - assert body["detail"] == "ModelEngine connection timeout." - - -@pytest.mark.asyncio -async def test_check_me_connectivity_generic_exception(): - """Test ME connectivity check with generic exception""" - # Mock the connectivity check to raise a generic Exception so the route returns 500 - with patch('backend.apps.me_model_managment_app.check_me_variable_set', AsyncMock(return_value=True)): - with patch('backend.apps.me_model_managment_app.check_me_connectivity', AsyncMock(side_effect=Exception("Unexpected error occurred"))): - response = client.get("/me/healthcheck") - - # Assertions - route maps generic Exception -> 500 - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - body = response.json() - assert "ModelEngine healthcheck failed" in body["detail"] - - -@pytest.mark.asyncio -async def test_save_config_with_error(): - # This is a placeholder for the example test function the user requested - pass diff --git a/test/backend/services/test_me_model_management_service.py b/test/backend/services/test_me_model_management_service.py deleted file mode 100644 index 01676e57a..000000000 --- a/test/backend/services/test_me_model_management_service.py +++ /dev/null @@ -1,159 +0,0 @@ -import backend.services.me_model_management_service as svc -from consts.exceptions import MEConnectionException, TimeoutException -import sys -import os -import asyncio - -import pytest -from unittest.mock import patch, AsyncMock, MagicMock - -# Add the project root directory to sys.path -sys.path.insert(0, os.path.abspath( - os.path.join(os.path.dirname(__file__), '../../..'))) - - -@pytest.mark.asyncio -async def test_check_me_variable_set_truthy_when_both_present(): - # Patch service module constants to have non-empty values - with patch.object(svc, 'MODEL_ENGINE_APIKEY', 'k'), \ - patch.object(svc, 'MODEL_ENGINE_HOST', 'http://mock-model-engine-host'): - assert await svc.check_me_variable_set() - - -@pytest.mark.asyncio -async def test_check_me_variable_set_falsy_when_api_key_missing(): - with patch.object(svc, 'MODEL_ENGINE_APIKEY', ''), \ - patch.object(svc, 'MODEL_ENGINE_HOST', 'http://mock-model-engine-host'): - assert not await svc.check_me_variable_set() - - -@pytest.mark.asyncio -async def test_check_me_variable_set_falsy_when_host_missing(): - with patch.object(svc, 'MODEL_ENGINE_APIKEY', 'k'), \ - patch.object(svc, 'MODEL_ENGINE_HOST', ''): - assert not await svc.check_me_variable_set() - - -@pytest.mark.asyncio -async def test_check_me_connectivity_success(): - """Test successful ME connectivity check""" - with patch.object(svc, 'MODEL_ENGINE_APIKEY', 'mock-api-key'), \ - patch.object(svc, 'MODEL_ENGINE_HOST', 'https://me-host.com'), \ - patch('backend.services.me_model_management_service.aiohttp.ClientSession') as mock_session_class: - - # Create mock response - mock_response = AsyncMock() - mock_response.status = 200 - - # Create mock session - mock_session = AsyncMock() - mock_get = AsyncMock() - mock_get.__aenter__.return_value = mock_response - mock_session.get = MagicMock(return_value=mock_get) - - # Create mock session factory - mock_client_session = AsyncMock() - mock_client_session.__aenter__.return_value = mock_session - mock_session_class.return_value = mock_client_session - - # Execute - result = await svc.check_me_connectivity(timeout=30) - - # Assert - assert result is True - - -@pytest.mark.asyncio -async def test_check_me_connectivity_http_error(): - """Test ME connectivity check with HTTP error response""" - with patch.object(svc, 'MODEL_ENGINE_APIKEY', 'mock-api-key'), \ - patch.object(svc, 'MODEL_ENGINE_HOST', 'https://me-host.com'), \ - patch('backend.services.me_model_management_service.aiohttp.ClientSession') as mock_session_class: - - # Create mock response with error status - mock_response = AsyncMock() - mock_response.status = 500 - - # Create mock session - mock_session = AsyncMock() - mock_get = AsyncMock() - mock_get.__aenter__.return_value = mock_response - mock_session.get = MagicMock(return_value=mock_get) - - # Create mock session factory - mock_client_session = AsyncMock() - mock_client_session.__aenter__.return_value = mock_session - mock_session_class.return_value = mock_client_session - - # Execute and expect an exception - with pytest.raises(MEConnectionException) as exc_info: - await svc.check_me_connectivity(timeout=30) - - # Assert the exception message - assert "Connection failed, error code: 500" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_check_me_connectivity_timeout(): - """Test ME connectivity check with timeout error""" - with patch.object(svc, 'MODEL_ENGINE_APIKEY', 'mock-api-key'), \ - patch.object(svc, 'MODEL_ENGINE_HOST', 'https://me-host.com'), \ - patch('backend.services.me_model_management_service.aiohttp.ClientSession') as mock_session_class: - - # Create mock session that raises TimeoutError - mock_session = AsyncMock() - mock_get = AsyncMock() - mock_get.__aenter__.side_effect = asyncio.TimeoutError() - mock_session.get = MagicMock(return_value=mock_get) - - # Create mock session factory - mock_client_session = AsyncMock() - mock_client_session.__aenter__.return_value = mock_session - mock_session_class.return_value = mock_client_session - - # Execute and expect a TimeoutException - with pytest.raises(TimeoutException) as exc_info: - await svc.check_me_connectivity(timeout=30) - - # Assert the exception message - assert "Connection timed out" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_check_me_connectivity_variables_not_set(): - """Test ME connectivity check when environment variables not set""" - with patch.object(svc, 'MODEL_ENGINE_APIKEY', ''), \ - patch.object(svc, 'MODEL_ENGINE_HOST', ''): - - # Execute - should return False when env vars not set - result = await svc.check_me_connectivity(timeout=30) - - # Assert - assert result is False - - -@pytest.mark.asyncio -async def test_check_me_connectivity_general_exception(): - """Test ME connectivity check with general exception (covers lines 54-55)""" - with patch.object(svc, 'MODEL_ENGINE_APIKEY', 'mock-api-key'), \ - patch.object(svc, 'MODEL_ENGINE_HOST', 'https://me-host.com'), \ - patch('backend.services.me_model_management_service.aiohttp.ClientSession') as mock_session_class: - - # Create mock session that raises a general exception - mock_session = AsyncMock() - mock_get = AsyncMock() - mock_get.__aenter__.side_effect = ValueError("Unexpected error") - mock_session.get = MagicMock(return_value=mock_get) - - # Create mock session factory - mock_client_session = AsyncMock() - mock_client_session.__aenter__.return_value = mock_session - mock_session_class.return_value = mock_client_session - - # Execute and expect a generic Exception - with pytest.raises(Exception) as exc_info: - await svc.check_me_connectivity(timeout=30) - - # Assert the exception message contains "Unknown error occurred" - assert "Unknown error occurred" in str(exc_info.value) - assert "Unexpected error" in str(exc_info.value) From 9fafe3be81665475b8c1d9dab3e5a30a2d1a78eb Mon Sep 17 00:00:00 2001 From: zhizhi <928570418@qq.com> Date: Sat, 27 Dec 2025 14:56:52 +0800 Subject: [PATCH 05/62] =?UTF-8?q?=E2=9C=A8config=20ModelEngine=20Service?= =?UTF-8?q?=20part3=20repair=20model=20engine=20edit=20and=20delete=20web?= =?UTF-8?q?=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/model/ModelAddDialog.tsx | 22 +++++++++++-------- .../models/components/modelConfig.tsx | 8 ++++++- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx index 951d6f201..4c182ebcd 100644 --- a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx @@ -37,6 +37,7 @@ interface ModelAddDialogProps { onClose: () => void; onSuccess: (model?: AddedModel) => Promise; defaultProvider?: string; // Default provider to select when dialog opens + defaultIsBatchImport?: boolean; } // Connectivity status type comes from utils @@ -131,6 +132,7 @@ export const ModelAddDialog = ({ onClose, onSuccess, defaultProvider, + defaultIsBatchImport, }: ModelAddDialogProps) => { const { t } = useTranslation(); const { message } = App.useApp(); @@ -223,16 +225,18 @@ export const ModelAddDialog = ({ setLoadingModelList, }); - // Handle default provider when dialog opens + // When dialog opens, apply default provider and optional default batch mode useEffect(() => { - if (isOpen && defaultProvider) { - setForm((prev) => ({ - ...prev, - provider: defaultProvider, - isBatchImport: true, - })); - } - }, [isOpen, defaultProvider]); + if (!isOpen) return; + setForm((prev) => ({ + ...prev, + provider: defaultProvider || prev.provider, + isBatchImport: + typeof defaultIsBatchImport !== "undefined" + ? Boolean(defaultIsBatchImport) + : prev.isBatchImport, + })); + }, [isOpen, defaultProvider, defaultIsBatchImport]); const parseModelName = (name: string): string => { if (!name) return ""; diff --git a/frontend/app/[locale]/models/components/modelConfig.tsx b/frontend/app/[locale]/models/components/modelConfig.tsx index 33e89df03..6018763d4 100644 --- a/frontend/app/[locale]/models/components/modelConfig.tsx +++ b/frontend/app/[locale]/models/components/modelConfig.tsx @@ -103,6 +103,7 @@ export const ModelConfigSection = forwardRef< // State management const [models, setModels] = useState([]); const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [addModalDefaultIsBatch, setAddModalDefaultIsBatch] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isVerifying, setIsVerifying] = useState(false); @@ -595,6 +596,7 @@ export const ModelConfigSection = forwardRef< // Open batch add dialog with ModelEngine provider pre-selected const handleSyncModels = () => { + setAddModalDefaultIsBatch(true); setIsAddModalOpen(true); }; @@ -864,7 +866,10 @@ export const ModelConfigSection = forwardRef< type="primary" size="middle" icon={} - onClick={() => setIsAddModalOpen(true)} + onClick={() => { + setAddModalDefaultIsBatch(false); + setIsAddModalOpen(true); + }} style={{ width: "100%" }} block > @@ -1036,6 +1041,7 @@ export const ModelConfigSection = forwardRef< } }} defaultProvider="modelengine" + defaultIsBatchImport={addModalDefaultIsBatch} /> Date: Mon, 29 Dec 2025 09:20:30 +0800 Subject: [PATCH 06/62] =?UTF-8?q?=E2=9C=A8config=20ModelEngine=20Service?= =?UTF-8?q?=20part3=20repair=20model=20engine=20edit=20and=20delete=20web?= =?UTF-8?q?=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/model_provider_service.py | 24 +- .../components/model/ModelDeleteDialog.tsx | 243 ++++++++++++++---- frontend/services/modelService.ts | 26 +- 3 files changed, 217 insertions(+), 76 deletions(-) diff --git a/backend/services/model_provider_service.py b/backend/services/model_provider_service.py index 77a9a0d24..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.""" @@ -88,7 +89,7 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: 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 [] @@ -100,7 +101,7 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: connector=aiohttp.TCPConnector(ssl=False) ) as session: async with session.get( - f"{host.rstrip('/')}/open/router/v1/models", + f"{model_engine_url.rstrip('/')}/{MODEL_ENGINE_NORTH_PREFIX}/models", headers=headers ) as response: response.raise_for_status() @@ -180,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. @@ -218,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 @@ -297,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/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx b/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx index c7ad51146..37670674b 100644 --- a/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx @@ -262,40 +262,105 @@ export const ModelDeleteDialog = ({ } }; - // Get API key by model type - const getApiKeyByType = (type: ModelType | null): string => { + // Get API key by model type, optionally scoped to a provider + const getApiKeyByType = ( + type: ModelType | null, + provider?: ModelSource + ): string => { if (!type) return ""; - // Prioritize silicon models of the current type - const byType = models.find( + + // If a provider is specified, return the first model for that provider+type + if (provider) { + const byProvider = models.find( + (m) => m.source === provider && m.type === type && m.apiKey + ); + if (byProvider?.apiKey) return byProvider.apiKey; + } + + // Prefer provider entries in order: Silicon, ModelEngine + const bySilicon = models.find( (m) => m.source === MODEL_SOURCES.SILICON && m.type === type && m.apiKey ); - if (byType?.apiKey) return byType.apiKey; - // Fall back to any available silicon model - const anySilicon = models.find( - (m) => m.source === MODEL_SOURCES.SILICON && m.apiKey + if (bySilicon?.apiKey) return bySilicon.apiKey; + + const byModelEngine = models.find( + (m) => m.source === MODEL_SOURCES.MODELENGINE && m.type === type && m.apiKey + ); + if (byModelEngine?.apiKey) return byModelEngine.apiKey; + + // Fallback: any model that has apiKey + const anyWithKey = models.find((m) => m.apiKey); + return anyWithKey?.apiKey || ""; + }; + + // Get provider base URL by model type (prefer ModelEngine entries) + const getProviderBaseUrlByType = (type: ModelType | null): string | undefined => { + if (!type) return undefined; + // Prefer provider entries (ModelEngine) first, then explicit modelConfig, then any model + const engineModel = models.find( + (m) => m.source === MODEL_SOURCES.MODELENGINE && m.type === type && m.apiUrl ); - return anySilicon?.apiKey || ""; + if (engineModel?.apiUrl) return engineModel.apiUrl; + + try { + if (type === MODEL_TYPES.EMBEDDING) { + const cfgUrl = modelConfig?.embedding?.apiConfig?.modelUrl; + if (cfgUrl && cfgUrl.trim() !== "") return cfgUrl; + } + if (type === MODEL_TYPES.MULTI_EMBEDDING) { + const cfgUrl = modelConfig?.multiEmbedding?.apiConfig?.modelUrl; + if (cfgUrl && cfgUrl.trim() !== "") return cfgUrl; + } + if (type === MODEL_TYPES.VLM) { + const cfgUrl = modelConfig?.vlm?.apiConfig?.modelUrl; + if (cfgUrl && cfgUrl.trim() !== "") return cfgUrl; + } + if (type === MODEL_TYPES.LLM) { + const cfgUrl = modelConfig?.llm?.apiConfig?.modelUrl; + if (cfgUrl && cfgUrl.trim() !== "") return cfgUrl; + } + } catch (e) { + // ignore and continue + } + + const anyModelWithUrl = models.find((m) => m.apiUrl); + return anyModelWithUrl?.apiUrl || undefined; }; - // Prefetch SiliconCloud provider model list - const prefetchSiliconProviderModels = async ( + // Prefetch provider model list (supports Silicon and ModelEngine) + const prefetchProviderModels = async ( + provider: ModelSource, modelType: ModelType | null ): Promise => { if (!modelType) return; try { - const apiKey = getApiKeyByType(modelType); - const result = await modelService.addProviderModel({ - provider: MODEL_SOURCES.SILICON, - type: modelType, - apiKey: apiKey && apiKey.trim() !== "" ? apiKey : "sk-no-api-key", - }); + let result: any[] = []; + if (provider === MODEL_SOURCES.SILICON) { + const apiKey = getApiKeyByType(modelType, MODEL_SOURCES.SILICON); + result = await modelService.addProviderModel({ + provider: MODEL_SOURCES.SILICON, + type: modelType, + apiKey: apiKey && apiKey.trim() !== "" ? apiKey : "sk-no-api-key", + }); + } else if (provider === MODEL_SOURCES.MODELENGINE) { + const apiKey = getApiKeyByType(modelType, MODEL_SOURCES.MODELENGINE); + const baseUrl = getProviderBaseUrlByType(modelType); + result = await modelService.addProviderModel({ + provider: MODEL_SOURCES.MODELENGINE, + type: modelType, + apiKey: apiKey && apiKey.trim() !== "" ? apiKey : "sk-no-api-key", + baseUrl: baseUrl || undefined, + }); + } else { + // Unsupported provider for prefetching + return; + } + setProviderModels(result || []); // Initialize pending selected switch states (based on current models status) const currentIds = new Set( models - .filter( - (m) => m.type === modelType && m.source === MODEL_SOURCES.SILICON - ) + .filter((m) => m.type === modelType && m.source === provider) .map((m) => m.name) ); setPendingSelectedProviderIds( @@ -310,24 +375,24 @@ export const ModelDeleteDialog = ({ } } catch (e) { message.error(t("model.dialog.error.noModelsFetched")); - log.error("Failed to prefetch Silicon provider models", e); + log.error("Failed to prefetch provider models", e); } }; // Handle source selection const handleSourceSelect = async (source: ModelSource) => { - if (source === MODEL_SOURCES.SILICON) { - setLoadingSource(source); - try { - await prefetchSiliconProviderModels(deletingModelType); - } finally { - setLoadingSource(null); + setLoadingSource(source); + try { + if (source === MODEL_SOURCES.SILICON || source === MODEL_SOURCES.MODELENGINE) { + await prefetchProviderModels(source, deletingModelType); + } else if (source === MODEL_SOURCES.OPENAI) { + // For OpenAI source, just set the selected source without prefetching + // TODO: Call the relevant API to fetch OpenAI models + setSelectedSource(source); + return; } - } else if (source === MODEL_SOURCES.OPENAI) { - // For OpenAI source, just set the selected source without prefetching - // TODO: Call the relevant API to fetch OpenAI models - setSelectedSource(source); - return; + } finally { + setLoadingSource(null); } setSelectedSource(source); setProviderModelSearchTerm(""); @@ -338,10 +403,14 @@ export const ModelDeleteDialog = ({ }; // Handle model deletion - const handleDeleteModel = async (displayName: string) => { + const handleDeleteModel = async (displayName: string, provider?: ModelSource) => { setDeletingModels((prev) => new Set(prev).add(displayName)); try { - await modelService.deleteCustomModel(displayName); + // Prefer explicit provider passed in, fall back to selectedSource + await modelService.deleteCustomModel( + displayName, + provider || selectedSource || undefined + ); let configUpdates: any = {}; // Check each model configuration, if currently using a deleted model, clear the configuration @@ -472,24 +541,28 @@ export const ModelDeleteDialog = ({ maxTokens: number; }) => { setMaxTokens(maxTokens); - if (selectedSource === MODEL_SOURCES.SILICON && deletingModelType) { + if ( + (selectedSource === MODEL_SOURCES.SILICON || + selectedSource === MODEL_SOURCES.MODELENGINE) && + deletingModelType + ) { try { const currentIds = new Set( models .filter( (m) => m.type === deletingModelType && - m.source === MODEL_SOURCES.SILICON + m.source === (selectedSource as ModelSource) ) .map((m) => m.name) ); - // Build payload items for the current silicon models in required format + // Build payload items for the current provider models in required format const currentModelPayloads = models .filter( (m) => m.type === deletingModelType && - m.source === MODEL_SOURCES.SILICON && + m.source === (selectedSource as ModelSource) && currentIds.has(m.name) ) .map((m) => ({ @@ -498,7 +571,10 @@ export const ModelDeleteDialog = ({ maxTokens: maxTokens || m.maxTokens, })); - await modelService.updateBatchModel(currentModelPayloads); + await modelService.updateBatchModel( + currentModelPayloads, + selectedSource as ModelSource + ); // Show success message since no exception was thrown message.success(t("model.dialog.success.updateSuccess")); @@ -510,8 +586,6 @@ export const ModelDeleteDialog = ({ max_tokens: maxTokens || model.max_tokens || 4096, })) ); - - // Optionally use currentModelPayloads for subsequent API calls if needed } catch (e) { message.error(t("model.dialog.error.noModelsFetched")); } @@ -610,7 +684,11 @@ export const ModelDeleteDialog = ({ const displayName = selectedEmbeddingModel.displayName || selectedEmbeddingModel.name; const apiKey = - selectedEmbeddingModel.apiKey || getApiKeyByType(deletingModelType); + selectedEmbeddingModel.apiKey || + getApiKeyByType( + deletingModelType, + (selectedEmbeddingModel?.source as ModelSource) || selectedSource || undefined + ); await modelService.updateSingleModel({ currentDisplayName: displayName, @@ -674,18 +752,67 @@ export const ModelDeleteDialog = ({ ); if (allEnabledModels) { - const apiKey = getApiKeyByType(deletingModelType); + const apiKey = getApiKeyByType(deletingModelType, MODEL_SOURCES.SILICON); const isEmbeddingType = deletingModelType === MODEL_TYPES.EMBEDDING || deletingModelType === MODEL_TYPES.MULTI_EMBEDDING; // Pass all currently enabled models // For embedding/multi_embedding models, explicitly exclude max_tokens as backend will set it via connectivity check + await modelService.addBatchCustomModel({ + api_key: + apiKey && apiKey.trim() !== "" + ? apiKey + : "sk-no-api-key", + provider: MODEL_SOURCES.SILICON, + type: deletingModelType, + models: allEnabledModels.map((model) => { + if (isEmbeddingType) { + const { max_tokens, ...modelWithoutMaxTokens } = + model; + return modelWithoutMaxTokens; + } else { + return { + ...model, + max_tokens: model.max_tokens || 4096, + }; + } + }), + }); + } + + // Refresh list + await onSuccess(); + // Re-fetch provider models and sync switch states + await prefetchProviderModels(selectedSource, deletingModelType); + message.success(t("model.dialog.success.updateSuccess")); + // Close dialog + handleClose(); + } catch (e) { + log.error("Failed to apply model updates", e); + message.error( + t("model.dialog.error.addFailed", { error: e as any }) + ); + } + } else if ( + selectedSource === MODEL_SOURCES.MODELENGINE && + deletingModelType + ) { + try { + const allEnabledModels = providerModels.filter( + (pm: any) => pendingSelectedProviderIds.has(pm.id) + ); + + if (allEnabledModels) { + const apiKey = getApiKeyByType(deletingModelType, MODEL_SOURCES.MODELENGINE); + const isEmbeddingType = + deletingModelType === MODEL_TYPES.EMBEDDING || + deletingModelType === MODEL_TYPES.MULTI_EMBEDDING; await modelService.addBatchCustomModel({ api_key: apiKey && apiKey.trim() !== "" ? apiKey : "sk-no-api-key", - provider: MODEL_SOURCES.SILICON, + provider: MODEL_SOURCES.MODELENGINE, type: deletingModelType, models: allEnabledModels.map((model) => { if (isEmbeddingType) { @@ -702,15 +829,12 @@ export const ModelDeleteDialog = ({ }); } - // Refresh list await onSuccess(); - // Re-fetch provider models and sync switch states - await prefetchSiliconProviderModels(deletingModelType); + await prefetchProviderModels(selectedSource, deletingModelType); message.success(t("model.dialog.success.updateSuccess")); - // Close dialog handleClose(); } catch (e) { - log.error("Failed to apply model updates", e); + log.error("Failed to apply ModelEngine model updates", e); message.error( t("model.dialog.error.addFailed", { error: e as any }) ); @@ -949,11 +1073,15 @@ export const ModelDeleteDialog = ({ icon={} onClick={async () => { if ( - selectedSource === MODEL_SOURCES.SILICON && + (selectedSource === MODEL_SOURCES.SILICON || + selectedSource === MODEL_SOURCES.MODELENGINE) && deletingModelType ) { try { - await prefetchSiliconProviderModels(deletingModelType); + await prefetchProviderModels( + selectedSource as ModelSource, + deletingModelType + ); message.success(t("common.message.refreshSuccess")); } catch (error) { message.error(t("common.message.refreshFailed")); @@ -972,7 +1100,8 @@ export const ModelDeleteDialog = ({ )} - {selectedSource === MODEL_SOURCES.SILICON && + {(selectedSource === MODEL_SOURCES.SILICON || + selectedSource === MODEL_SOURCES.MODELENGINE) && providerModels.length > 0 ? (
{providerModels.length > 0 && ( @@ -1126,9 +1255,9 @@ export const ModelDeleteDialog = ({
- - - - - - - - } + + + + {/* Middle column: Agent Config */} + -

- {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 || - "" - : "" - } - /> - - + + + + {/* Right column: Agent Info */} + + + + + ); } 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/AgentInfoComp.tsx b/frontend/app/[locale]/agents/components/agent/AgentInfoComp.tsx new file mode 100644 index 000000000..25bfd3733 --- /dev/null +++ b/frontend/app/[locale]/agents/components/agent/AgentInfoComp.tsx @@ -0,0 +1,131 @@ +"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, Maximize2, LoaderCircle } 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"; + +import log from "@/lib/logger"; + +export interface AgentInfoCompProps {} + +export default function AgentInfoComp({}: AgentInfoCompProps) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + + // 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")} +

+
+ +
+ + + + + + + + + + + + + + + + + + +
+ + {/* Debug drawer */} + setIsDebugDrawerOpen(false)} + open={isDebugDrawerOpen} + width={AGENT_SETUP_LAYOUT_DEFAULT.DRAWER_WIDTH} + destroyOnClose={true} + styles={{ + body: { + padding: 0, + height: "100%", + overflow: "hidden", + }, + }} + > +
+ +
+
+ + ); +} diff --git a/frontend/app/[locale]/agents/components/agent/AgentManageComp.tsx b/frontend/app/[locale]/agents/components/agent/AgentManageComp.tsx new file mode 100644 index 000000000..69b39cada --- /dev/null +++ b/frontend/app/[locale]/agents/components/agent/AgentManageComp.tsx @@ -0,0 +1,276 @@ +"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 { + // No props needed - uses hook internally +} + +export default function AgentManageComp({}: 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(); + 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.loadFailed")); + setSelectedAgentId(null); + } + }, [ + selectedAgentId, + agentDetail, + agentInfoLoading, + agentInfoError, + handleAgentSwitch, + message, + t, + ]); + + // Handle select agent + const handleSelectAgent = async (agent: Agent) => { + // Don't switch if already selected + if ( + currentAgentId !== null && + String(currentAgentId) === String(agent.id) + ) { + return; + } + + // Set selected agent id to trigger the hook + setSelectedAgentId(Number(agent.id)); + }; + + // Handle import via Ant Design Upload's `beforeUpload`. + // Read the selected JSON file, parse and call the `importAgent` service, + // then refresh the agent list. Return false to prevent default upload. + const handleBeforeUpload = async (file: File) => { + try { + const fileContent = await file.text(); + const agentData = JSON.parse(fileContent); + + importAgentMutation.mutate(agentData, { + onSuccess: () => { + message.success( + t("agent.import.success") || "Agent imported successfully" + ); + }, + onError: () => { + message.error(t("agent.import.failed") || "Failed to import agent"); + }, + }); + } catch (error) { + log.error("Failed to import agent:", error); + message.error(t("agent.import.failed") || "Failed to import agent"); + } + + // Prevent Upload from performing a network request / showing list + return false; + }; + + 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/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/agent/agentConfig/CollaborativeAgent.tsx b/frontend/app/[locale]/agents/components/agent/agentConfig/CollaborativeAgent.tsx new file mode 100644 index 000000000..b39da4580 --- /dev/null +++ b/frontend/app/[locale]/agents/components/agent/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/agent/agentConfig/McpConfigModal.tsx b/frontend/app/[locale]/agents/components/agent/agentConfig/McpConfigModal.tsx new file mode 100644 index 000000000..e85950a59 --- /dev/null +++ b/frontend/app/[locale]/agents/components/agent/agentConfig/McpConfigModal.tsx @@ -0,0 +1,947 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { + Modal, + Button, + Input, + Table, + Space, + Typography, + Card, + Divider, + Tooltip, + App, +} from "antd"; +import { + Trash, + Eye, + Plus, + LoaderCircle, + Maximize, + Minimize, + RefreshCw, + FileText, + Container, +} from "lucide-react"; + +import { McpConfigModalProps, AgentRefreshEvent } from "@/types/agentConfig"; +import { + getMcpServerList, + addMcpServer, + deleteMcpServer, + getMcpTools, + updateToolList, + checkMcpServerHealth, + addMcpFromConfig, + getMcpContainers, + getMcpContainerLogs, + deleteMcpContainer, +} from "@/services/mcpService"; +import { McpServer, McpTool, McpContainer } from "@/types/agentConfig"; +import { useConfirmModal } from "@/hooks/useConfirmModal"; +import log from "@/lib/logger"; + +const { Text, Title } = Typography; + +export default function McpConfigModal({ + visible, + onCancel, +}: McpConfigModalProps) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const { confirm } = useConfirmModal(); + const [serverList, setServerList] = useState([]); + const [loading, setLoading] = useState(false); + const [addingServer, setAddingServer] = useState(false); + const [newServerName, setNewServerName] = useState(""); + const [newServerUrl, setNewServerUrl] = useState(""); + const [toolsModalVisible, setToolsModalVisible] = useState(false); + const [currentServerTools, setCurrentServerTools] = useState([]); + const [currentServerName, setCurrentServerName] = useState(""); + const [loadingTools, setLoadingTools] = useState(false); + const [expandedDescriptions, setExpandedDescriptions] = useState>( + new Set() + ); + const [updatingTools, setUpdatingTools] = useState(false); + const [healthCheckLoading, setHealthCheckLoading] = useState<{ + [key: string]: boolean; + }>({}); + + // Container-related state + const [containerList, setContainerList] = useState([]); + const [loadingContainers, setLoadingContainers] = useState(false); + const [addingContainer, setAddingContainer] = useState(false); + const [containerConfigJson, setContainerConfigJson] = useState(""); + const [containerPort, setContainerPort] = useState(undefined); + const [logsModalVisible, setLogsModalVisible] = useState(false); + const [currentContainerLogs, setCurrentContainerLogs] = useState(""); + const [currentContainerId, setCurrentContainerId] = useState(""); + const [loadingLogs, setLoadingLogs] = useState(false); + const delayedContainerRefreshRef = useRef(undefined); + + const actionsLocked = updatingTools || addingContainer; + + // Helper function to refresh tools and agents asynchronously + const refreshToolsAndAgents = async () => { + setUpdatingTools(true); + try { + // Update tool list to refresh MCP tool availability status + const updateResult = await updateToolList(); + if (updateResult.success) { + // Notify parent component to update tool list + window.dispatchEvent(new CustomEvent("toolsUpdated")); + } + + // Refresh agent list to update agent availability status + window.dispatchEvent( + new CustomEvent("refreshAgentList") as AgentRefreshEvent + ); + } catch (error) { + // Silently handle errors to avoid interrupting user experience + log.error("Failed to refresh tools and agents:", error); + } finally { + setUpdatingTools(false); + } + }; + + // Load MCP server list + const loadServerList = async () => { + setLoading(true); + try { + const result = await getMcpServerList(); + if (result.success) { + setServerList(result.data); + } else { + message.error(result.message); + } + } catch (error) { + message.error(t("mcpConfig.message.loadServerListFailed")); + } finally { + setLoading(false); + } + }; + + // Add MCP server + const handleAddServer = async () => { + if (!newServerName.trim() || !newServerUrl.trim()) { + message.error(t("mcpConfig.message.completeServerInfo")); + return; + } + + // Validate server name format + const serverName = newServerName.trim(); + const nameRegex = /^[a-zA-Z0-9]+$/; + + if (!nameRegex.test(serverName)) { + message.error(t("mcpConfig.message.invalidServerName")); + return; + } + + if (serverName.length > 20) { + message.error(t("mcpConfig.message.serverNameTooLong")); + return; + } + + // Check if server with same name already exists + const exists = serverList.some( + (server) => + server.service_name === serverName || + server.mcp_url === newServerUrl.trim() + ); + if (exists) { + message.error(t("mcpConfig.message.serverExists")); + return; + } + + setAddingServer(true); + try { + const result = await addMcpServer(newServerUrl.trim(), serverName); + if (result.success) { + setNewServerName(""); + setNewServerUrl(""); + await loadServerList(); // Reload list + + // Refresh tools and agents asynchronously after adding server + // This will update MCP tool availability and agent availability status + await refreshToolsAndAgents(); + + // Show success message after refresh completes to avoid message overlap + message.success(t("mcpService.message.addServerSuccess")); + } else { + message.error(result.message); + } + } catch (error) { + message.error(t("mcpConfig.message.addServerFailed")); + } finally { + setAddingServer(false); + } + }; + + // Delete MCP server + const handleDeleteServer = async (server: McpServer) => { + confirm({ + title: t("mcpConfig.delete.confirmTitle"), + content: t("mcpConfig.delete.confirmContent", { + name: server.service_name, + }), + okText: t("common.delete", "Delete"), + onOk: async () => { + try { + const result = await deleteMcpServer( + server.mcp_url, + server.service_name + ); + if (result.success) { + await loadServerList(); // Reload list + + // Show success message immediately + message.success(t("mcpService.message.deleteServerSuccess")); + + // This will update MCP tool availability and agent availability status + refreshToolsAndAgents().catch((error) => { + log.error("Failed to refresh tools and agents after deletion:", error); + }); + } else { + message.error(result.message); + // Throw error to prevent modal from closing + throw new Error(result.message); + } + } catch (error) { + message.error(t("mcpConfig.message.deleteServerFailed")); + // Throw error to prevent modal from closing + throw error; + } + }, + }); + }; + + // View server tools + const handleViewTools = async (server: McpServer) => { + setCurrentServerName(server.service_name); + setLoadingTools(true); + setToolsModalVisible(true); + setExpandedDescriptions(new Set()); // Reset expand state + + try { + const result = await getMcpTools(server.service_name, server.mcp_url); + if (result.success) { + setCurrentServerTools(result.data); + } else { + message.error(result.message); + setCurrentServerTools([]); + } + } catch (error) { + message.error(t("mcpConfig.message.getToolsFailed")); + setCurrentServerTools([]); + } finally { + setLoadingTools(false); + } + }; + + // Toggle description expand state + const toggleDescription = (toolName: string) => { + const newExpanded = new Set(expandedDescriptions); + if (newExpanded.has(toolName)) { + newExpanded.delete(toolName); + } else { + newExpanded.add(toolName); + } + setExpandedDescriptions(newExpanded); + }; + + // Validate server connectivity + const handleCheckHealth = async (server: McpServer) => { + const key = `${server.service_name}__${server.mcp_url}`; + message.info( + t("mcpConfig.message.healthChecking", { name: server.service_name }) + ); + setHealthCheckLoading((prev) => ({ ...prev, [key]: true })); + try { + const result = await checkMcpServerHealth( + server.mcp_url, + server.service_name + ); + if (result.success) { + message.success(t("mcpConfig.message.healthCheckSuccess")); + await loadServerList(); + + // Refresh tools and agents asynchronously after health check + // This will update MCP tool availability and agent availability status + refreshToolsAndAgents(); + } else { + message.error( + result.message || t("mcpConfig.message.healthCheckFailed") + ); + await loadServerList(); + + // Refresh tools and agents even if health check failed + // This will update MCP tool availability and agent availability status + refreshToolsAndAgents(); + } + } catch (error) { + message.error(t("mcpConfig.message.healthCheckFailed")); + await loadServerList(); + + // Refresh tools and agents even if health check failed + // This will update MCP tool availability and agent availability status + refreshToolsAndAgents(); + } finally { + setHealthCheckLoading((prev) => ({ ...prev, [key]: false })); + } + }; + + // Server list table column definitions + const columns = [ + { + title: t("mcpConfig.serverList.column.name"), + dataIndex: "service_name", + key: "service_name", + width: "25%", + ellipsis: true, + render: (text: string, record: McpServer) => { + const key = `${record.service_name}__${record.mcp_url}`; + return ( +
+
+ {healthCheckLoading[key] ? ( + + ) : null} +
+ + {text} + +
+ ); + }, + }, + { + title: t("mcpConfig.serverList.column.url"), + dataIndex: "mcp_url", + key: "mcp_url", + width: "40%", + ellipsis: true, + }, + { + title: t("mcpConfig.serverList.column.action"), + key: "action", + width: "35%", + render: (_: any, record: McpServer) => { + const key = `${record.service_name}__${record.mcp_url}`; + return ( + + + {record.status ? ( + + ) : ( + + + + )} + + + ); + }, + }, + ]; + + // Tool list table column definitions + const toolColumns = [ + { + title: t("mcpConfig.toolsList.column.name"), + dataIndex: "name", + key: "name", + width: "30%", + }, + { + title: t("mcpConfig.toolsList.column.description"), + dataIndex: "description", + key: "description", + width: "70%", + render: (text: string, record: McpTool) => { + const isExpanded = expandedDescriptions.has(record.name); + const maxLength = 100; // Show expand button when description exceeds 100 characters + const needsExpansion = text && text.length > maxLength; + + return ( +
+
+ {needsExpansion && !isExpanded + ? `${text.substring(0, maxLength)}...` + : text} +
+ {needsExpansion && ( + + )} +
+ ); + }, + }, + ]; + + // Load container list + const loadContainerList = async () => { + setLoadingContainers(true); + try { + const result = await getMcpContainers(); + if (result.success) { + setContainerList(result.data); + } else { + message.error(result.message); + } + } catch (error) { + message.error(t("mcpConfig.message.loadContainerListFailed")); + } finally { + setLoadingContainers(false); + } + }; + + // Add containerized MCP server + const handleAddContainer = async () => { + if (!containerConfigJson.trim()) { + message.error(t("mcpConfig.message.containerConfigRequired")); + return; + } + + if (!containerPort || containerPort < 1 || containerPort > 65535) { + message.error(t("mcpConfig.message.validPortRequired")); + return; + } + + let config; + try { + config = JSON.parse(containerConfigJson); + } catch (error) { + message.error(t("mcpConfig.message.invalidJsonConfig")); + return; + } + + // Validate config structure + if (!config.mcpServers || typeof config.mcpServers !== "object") { + message.error(t("mcpConfig.message.invalidConfigStructure")); + return; + } + + // Add port to each server config + const configWithPorts = { + mcpServers: Object.fromEntries( + Object.entries(config.mcpServers).map(([key, value]: [string, any]) => [ + key, + { ...value, port: containerPort }, + ]) + ), + }; + + // Schedule a delayed container refresh without waiting for creation to finish + if (delayedContainerRefreshRef.current) { + window.clearTimeout(delayedContainerRefreshRef.current); + } + delayedContainerRefreshRef.current = window.setTimeout(() => { + loadContainerList().catch((error) => { + log.error("Failed to refresh containers after add trigger:", error); + }); + }, 3000); + + setAddingContainer(true); + try { + const result = await addMcpFromConfig(configWithPorts); + if (result.success) { + setContainerConfigJson(""); + setContainerPort(undefined); + await loadContainerList(); + await loadServerList(); // Reload server list as containers are registered as servers + + // Refresh tools and agents + await refreshToolsAndAgents(); + + message.success(t("mcpService.message.addContainerSuccess")); + } else { + message.error(result.message); + } + } catch (error) { + message.error(t("mcpConfig.message.addContainerFailed")); + } finally { + setAddingContainer(false); + } + }; + + // View container logs + const handleViewLogs = async (containerId: string) => { + setCurrentContainerId(containerId); + setLoadingLogs(true); + setLogsModalVisible(true); + setCurrentContainerLogs(""); + + try { + const result = await getMcpContainerLogs(containerId, 500); + if (result.success) { + setCurrentContainerLogs(result.data); + } else { + message.error(result.message); + setCurrentContainerLogs(result.message); + } + } catch (error) { + message.error(t("mcpConfig.message.getContainerLogsFailed")); + setCurrentContainerLogs(t("mcpConfig.message.getContainerLogsFailed")); + } finally { + setLoadingLogs(false); + } + }; + + // Delete container + const handleDeleteContainer = async (container: McpContainer) => { + confirm({ + title: t("mcpConfig.deleteContainer.confirmTitle"), + content: t("mcpConfig.deleteContainer.confirmContent", { + name: container.name || container.container_id, + }), + okText: t("common.delete", "Delete"), + onOk: async () => { + try { + const result = await deleteMcpContainer(container.container_id); + if (result.success) { + await loadContainerList(); + await loadServerList(); // Reload server list as container is removed + + message.success(t("mcpService.message.deleteContainerSuccess")); + + // Refresh tools and agents + refreshToolsAndAgents().catch((error) => { + log.error( + "Failed to refresh tools and agents after container deletion:", + error + ); + }); + } else { + message.error(result.message); + // Throw error to prevent modal from closing + throw new Error(result.message); + } + } catch (error) { + message.error(t("mcpConfig.message.deleteContainerFailed")); + // Throw error to prevent modal from closing + throw error; + } + }, + }); + }; + + // Load data when modal opens + useEffect(() => { + if (visible) { + loadServerList(); + loadContainerList(); + } + }, [visible]); + + // Clear delayed refresh timer on unmount + useEffect(() => { + return () => { + if (delayedContainerRefreshRef.current) { + window.clearTimeout(delayedContainerRefreshRef.current); + } + }; + }, []); + + return ( + <> + + {actionsLocked + ? t("mcpConfig.modal.updatingTools") + : t("mcpConfig.modal.close")} + , + ]} + > +
+ {/* Tool update status hint */} + {updatingTools && ( +
+ + + {t("mcpConfig.status.updatingToolsHint")} + +
+ )} + {/* 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 port = parseInt(e.target.value); + setContainerPort(isNaN(port) ? undefined : port); + }} + min={1} + max={65535} + style={{ width: 150 }} + disabled={actionsLocked} + /> +
+ +
+ + + + + + {/* Server list */} +
+
+ + {t("mcpConfig.serverList.title")} + +
+ `${record.service_name}-${record.mcp_url}`} + loading={loading} + size="small" + pagination={false} + locale={{ emptyText: t("mcpConfig.serverList.empty") }} + scroll={{ y: 300 }} + style={{ width: "100%" }} + /> + + + {/* Container list */} +
+
+ + {t("mcpConfig.containerList.title")} + +
+
text || record.container_id?.substring(0, 12), + }, + { + title: t("mcpConfig.containerList.column.containerId"), + dataIndex: "container_id", + key: "container_id", + width: "20%", + ellipsis: true, + render: (text: string) => text || "-", + }, + { + title: t("mcpConfig.containerList.column.port"), + dataIndex: "host_port", + key: "host_port", + width: "15%", + render: (port: number) => port || "-", + }, + { + title: t("mcpConfig.containerList.column.status"), + dataIndex: "status", + key: "status", + width: "15%", + render: (status: string) => ( + + {status || "unknown"} + + ), + }, + { + title: t("mcpConfig.containerList.column.action"), + key: "action", + width: "25%", + render: (_: any, record: any) => ( + + + + + ), + }, + ]} + dataSource={containerList} + rowKey="container_id" + loading={loadingContainers} + size="small" + pagination={false} + locale={{ emptyText: t("mcpConfig.containerList.empty") }} + scroll={{ y: 300 }} + style={{ width: "100%" }} + /> + + + + + {/* Tool list modal */} + setToolsModalVisible(false)} + width={1000} + footer={[ + , + ]} + > +
+ {loadingTools ? ( +
+ + {t("mcpConfig.toolsList.loading")} +
+ ) : ( +
+ )} + + + + {/* Container logs modal */} + setLogsModalVisible(false)} + width={1000} + footer={[ + , + ]} + > +
+ {loadingLogs ? ( +
+ + {t("mcpConfig.containerLogs.loading")} +
+ ) : ( +
+              {currentContainerLogs || t("mcpConfig.containerLogs.empty")}
+            
+ )} +
+
+ + ); +} diff --git a/frontend/app/[locale]/agents/components/agent/agentConfig/ToolManagement.tsx b/frontend/app/[locale]/agents/components/agent/agentConfig/ToolManagement.tsx new file mode 100644 index 000000000..8ef5a8c45 --- /dev/null +++ b/frontend/app/[locale]/agents/components/agent/agentConfig/ToolManagement.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import ToolConfigModal from "./tool/ToolConfigModal"; +import { ToolGroup, Tool } from "@/types/agentConfig"; +import { Tabs, Collapse } from "antd"; + +import { + LoaderCircle, + Settings, + RefreshCw, + Lightbulb, + Plug, +} from "lucide-react"; + +interface ToolManagementProps { + toolGroups: ToolGroup[]; + selectedToolIds?: number[]; + onToolSelect?: (toolId: number) => void; + editable?: boolean; +} + +/** + * ToolManagement - Component for displaying tools in tabs + * Provides a tabbed interface for tool organization + */ +export default function ToolManagement({ + toolGroups, + selectedToolIds = [], + onToolSelect, + editable = true, +}: ToolManagementProps) { + const { t } = useTranslation("common"); + + const [activeTabKey, setActiveTabKey] = useState(""); + const [expandedCategories, setExpandedCategories] = useState>( + new Set() + ); + const [isToolModalOpen, setIsToolModalOpen] = useState(false); + const [selectedTool, setSelectedTool] = useState(null); + + // Create selected tool ID set for efficient lookup + const selectedToolIdsSet = new Set( + selectedToolIds.map((id) => id.toString()) + ); + + // Set default active tab + useEffect(() => { + if (toolGroups.length > 0 && !activeTabKey) { + setActiveTabKey(toolGroups[0].key); + } + }, [toolGroups, activeTabKey]); + + const handleToolModalCancel = () => { + setIsToolModalOpen(false); + setSelectedTool(null); + }; + + const handleToolModalSave = (tool: Tool) => { + setIsToolModalOpen(false); + setSelectedTool(null); + // TODO: Handle tool save logic here + }; + + const handleToolSettingsClick = (tool: Tool) => { + setSelectedTool(tool); + setIsToolModalOpen(true); + }; + + const handleToolClick = (toolId: string) => { + if (onToolSelect) { + const numericId = parseInt(toolId, 10); + onToolSelect(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/agent/agentConfig/tool/ToolConfigModal.tsx b/frontend/app/[locale]/agents/components/agent/agentConfig/tool/ToolConfigModal.tsx new file mode 100644 index 000000000..81e45a6d9 --- /dev/null +++ b/frontend/app/[locale]/agents/components/agent/agentConfig/tool/ToolConfigModal.tsx @@ -0,0 +1,498 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + Modal, + Input, + Switch, + InputNumber, + Tag, + App, + Tooltip +} 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 { useModalPosition } from "@/hooks/useModalPosition"; +import ToolTestPanel from "./ToolTestPanel"; + +export default function ToolConfigModal({ + isOpen, + onCancel, + onSave, + tool, + mainAgentId, + selectedTools = [], + isEditingMode = true, +}: ToolConfigModalProps) { + const [currentParams, setCurrentParams] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const { t } = useTranslation("common"); + const { message } = App.useApp(); + + // Tool test panel visibility state + const [testPanelVisible, setTestPanelVisible] = useState(false); + 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(() => { + if (!isOpen) return; + + const testPanelWidth = 500; + const gap = windowWidth * 0.05; + // Move left by half of (test panel width + gap) to center both panels + const offsetX = testPanelVisible + ? -(testPanelWidth + gap) / 2 + : 0; + + // Find the modal wrap element (Ant Design renders Modal in a wrap container) + // Use a small delay to ensure Modal is rendered + const timer = setTimeout(() => { + const modalContent = document.querySelector( + ".tool-config-modal-content" + ); + if (modalContent) { + const modalWrap = modalContent.closest(".ant-modal-wrap") as HTMLElement; + if (modalWrap) { + modalWrap.style.transform = `translateX(${offsetX}px)`; + modalWrap.style.transition = "transform 0.3s ease-in-out"; + } + } + }, 0); + + return () => { + clearTimeout(timer); + const modalContent = document.querySelector( + ".tool-config-modal-content" + ); + if (modalContent) { + const modalWrap = modalContent.closest(".ant-modal-wrap") as HTMLElement; + if (modalWrap) { + modalWrap.style.transform = ""; + modalWrap.style.transition = ""; + } + } + }; + }, [testPanelVisible, isOpen, windowWidth]); + + // load tool config + 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(); + } else { + setCurrentParams([]); + } + }, [isOpen, tool, normalizedAgentId, t, message]); + + // check required fields + const checkRequiredFields = () => { + if (!tool) return false; + + const missingRequiredFields = currentParams + .filter( + (param) => + param.required && + (param.value === undefined || + param.value === "" || + param.value === null) + ) + .map((param) => param.name); + + if (missingRequiredFields.length > 0) { + message.error( + `${t("toolConfig.message.requiredFields")}${missingRequiredFields.join( + ", " + )}` + ); + return false; + } + return true; + }; + + const handleParamChange = (index: number, value: any) => { + const newParams = [...currentParams]; + newParams[index] = { ...newParams[index], value }; + 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")); + } + }; + + // Handle tool testing - open test panel + const handleTestTool = () => { + if (!tool || !checkRequiredFields()) return; + setTestPanelVisible(true); + }; + + // Close test panel + const handleCloseTestPanel = () => { + setTestPanelVisible(false); + }; + + const renderParamInput = (param: ToolParam, index: number) => { + switch (param.type) { + case TOOL_PARAM_TYPES.STRING: + const stringValue = param.value as string; + // if string length is greater than 15, use TextArea + if (stringValue && stringValue.length > 15) { + return ( + handleParamChange(index, e.target.value)} + placeholder={t("toolConfig.input.string.placeholder", { + name: param.description, + })} + autoSize={{ minRows: 1, maxRows: 8 }} + style={{ resize: "vertical" }} + /> + ); + } + return ( + handleParamChange(index, e.target.value)} + placeholder={t("toolConfig.input.string.placeholder", { + name: param.description, + })} + /> + ); + case TOOL_PARAM_TYPES.NUMBER: + return ( + handleParamChange(index, value)} + placeholder={t("toolConfig.input.string.placeholder", { + name: param.description, + })} + className="w-full" + /> + ); + case TOOL_PARAM_TYPES.BOOLEAN: + return ( + handleParamChange(index, checked)} + /> + ); + case TOOL_PARAM_TYPES.ARRAY: + const arrayValue = Array.isArray(param.value) + ? JSON.stringify(param.value, null, 2) + : (param.value as string); + return ( + { + try { + const value = JSON.parse(e.target.value); + handleParamChange(index, value); + } catch { + handleParamChange(index, e.target.value); + } + }} + placeholder={t("toolConfig.input.array.placeholder")} + autoSize={{ minRows: 1, maxRows: 8 }} + style={{ resize: "vertical" }} + /> + ); + case TOOL_PARAM_TYPES.OBJECT: + const objectValue = + typeof param.value === "object" + ? JSON.stringify(param.value, null, 2) + : (param.value as string); + return ( + { + try { + const value = JSON.parse(e.target.value); + handleParamChange(index, value); + } catch { + handleParamChange(index, e.target.value); + } + }} + placeholder={t("toolConfig.input.object.placeholder")} + autoSize={{ minRows: 1, maxRows: 8 }} + style={{ resize: "vertical" }} + /> + ); + default: + return ( + handleParamChange(index, e.target.value)} + /> + ); + } + }; + + if (!tool) return null; + + return ( + <> + + {`${tool?.name}`} +
+ + + {tool?.source === "mcp" + ? t("toolPool.tag.mcp") + : tool?.source === "langchain" + ? t("toolPool.tag.langchain") + : t("toolPool.tag.local")} + +
+ + } + open={isOpen} + onCancel={onCancel} + onOk={handleSave} + okText={t("common.button.save")} + cancelText={t("common.button.cancel")} + width={600} + confirmLoading={isLoading} + className="tool-config-modal-content" + footer={ +
+ {isEditingMode && ( + + )} +
+ + +
+
+ } + > +
+

{tool?.description}

+
+ {t("toolConfig.title.paramConfig")} +
+
+
+ {currentParams.map((param, index) => ( +
+
+
+ {param.name ? ( +
+ {param.name} + {param.required && ( + * + )} +
+ ) : ( +
+ {param.name} + {param.required && ( + * + )} +
+ )} +
+
+ + {renderParamInput(param, index)} + +
+
+
+ ))} +
+
+
+
+ + {/* Tool Test Panel */} + setTestPanelVisible(visible)} + /> + + ); +} diff --git a/frontend/app/[locale]/agents/components/agent/agentConfig/tool/ToolTestPanel.tsx b/frontend/app/[locale]/agents/components/agent/agentConfig/tool/ToolTestPanel.tsx new file mode 100644 index 000000000..0eed51c22 --- /dev/null +++ b/frontend/app/[locale]/agents/components/agent/agentConfig/tool/ToolTestPanel.tsx @@ -0,0 +1,608 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { motion, AnimatePresence } from "framer-motion"; +import { + Input, + Button, + Card, + Typography, + Tooltip, +} from "antd"; +import { + Settings, + PenLine, + X, +} from "lucide-react"; + +import { ToolParam, Tool } from "@/types/agentConfig"; +import { + validateTool, + parseToolInputs, + extractParameterNames, +} from "@/services/agentConfigService"; +import log from "@/lib/logger"; +import { DEFAULT_TYPE } from "@/const/constants"; + +const { Text, Title } = Typography; + +export interface ToolTestPanelProps { + /** Whether the test panel is visible */ + visible: boolean; + /** Tool to test */ + tool: Tool | null; + /** Current configuration parameters */ + currentParams: ToolParam[]; + /** Main modal top position */ + mainModalTop: number; + /** Main modal right position */ + mainModalRight: number; + /** Window width for position calculation */ + windowWidth: number; + /** Callback when panel is closed */ + onClose: () => void; + /** Callback when panel visibility changes (for parent modal positioning) */ + onVisibilityChange?: (visible: boolean) => void; +} + +export default function ToolTestPanel({ + visible, + tool, + currentParams, + mainModalTop, + mainModalRight, + windowWidth, + onClose, + onVisibilityChange, +}: ToolTestPanelProps) { + const { t } = useTranslation("common"); + + // Tool test related state + const [testExecuting, setTestExecuting] = useState(false); + const [testResult, setTestResult] = useState(""); + const [parsedInputs, setParsedInputs] = useState>({}); + const [paramValues, setParamValues] = useState>({}); + const [dynamicInputParams, setDynamicInputParams] = useState([]); + const [isManualInputMode, setIsManualInputMode] = useState(false); + const [manualJsonInput, setManualJsonInput] = useState(""); + const [isParseSuccessful, setIsParseSuccessful] = useState(false); + + // Notify parent when visibility changes + useEffect(() => { + onVisibilityChange?.(visible); + }, [visible, onVisibilityChange]); + + // Initialize test panel when opened + useEffect(() => { + if (!visible || !tool) { + // Reset state when closed + setTestResult(""); + setParsedInputs({}); + setParamValues({}); + setDynamicInputParams([]); + setTestExecuting(false); + setIsManualInputMode(false); + setManualJsonInput(""); + setIsParseSuccessful(false); + return; + } + + // Parse inputs definition from tool inputs field + try { + const parsedInputs = parseToolInputs(tool.inputs || ""); + const paramNames = extractParameterNames(parsedInputs); + + // Check if parsing was successful (not empty object) + const isSuccessful = Object.keys(parsedInputs).length > 0; + setIsParseSuccessful(isSuccessful); + if (isSuccessful) { + setParsedInputs(parsedInputs); + setDynamicInputParams(paramNames); + + // Initialize parameter values with appropriate defaults based on type + const initialValues: Record = {}; + paramNames.forEach((paramName) => { + const paramInfo = parsedInputs[paramName]; + const paramType = paramInfo?.type || DEFAULT_TYPE; + + if ( + paramInfo && + typeof paramInfo === "object" && + paramInfo.default != null + ) { + // Use provided default value, convert to string for UI display + switch (paramType) { + case "boolean": + initialValues[paramName] = paramInfo.default ? "true" : "false"; + break; + case "array": + case "object": + // JSON.stringify with indentation of 2 spaces for better readability + initialValues[paramName] = JSON.stringify( + paramInfo.default, + null, + 2 + ); + break; + default: + initialValues[paramName] = String(paramInfo.default); + } + } + }); + setParamValues(initialValues); + // Reset to parsed mode when parsing succeeds + setIsManualInputMode(false); + setManualJsonInput(""); + } else { + // Parsing returned empty object, treat as failed + setParsedInputs({}); + setParamValues({}); + setDynamicInputParams([]); + setIsManualInputMode(true); + setManualJsonInput("{}"); + } + } catch (error) { + log.error("Parameter parsing error:", error); + setParsedInputs({}); + setParamValues({}); + setDynamicInputParams([]); + setIsParseSuccessful(false); + // When parsing fails, automatically switch to manual input mode + setIsManualInputMode(true); + setManualJsonInput("{}"); + } + }, [visible, tool]); + + // Close test panel + const handleClose = () => { + onClose(); + }; + + // Execute tool test + const executeTest = async () => { + if (!tool) return; + + setTestExecuting(true); + + try { + // Prepare parameters for tool validation with correct types + const toolParams: Record = {}; + + if (isManualInputMode) { + // Use manual JSON input + try { + const manualParams = JSON.parse(manualJsonInput); + Object.assign(toolParams, manualParams); + } catch (error) { + log.error("Failed to parse manual JSON input:", error); + setTestResult(`Test failed: Invalid JSON format in manual input`); + return; + } + } else { + // Use parsed parameters + dynamicInputParams.forEach((paramName) => { + const value = paramValues[paramName]; + const paramInfo = parsedInputs[paramName]; + const paramType = paramInfo?.type || DEFAULT_TYPE; + + if (value && value.trim() !== "") { + // Convert value to correct type based on parameter type from inputs + switch (paramType) { + case "integer": + case "number": + const numValue = Number(value.trim()); + if (!isNaN(numValue)) { + toolParams[paramName] = numValue; + } else { + toolParams[paramName] = value.trim(); // fallback to string if conversion fails + } + break; + case "boolean": + toolParams[paramName] = value.trim().toLowerCase() === "true"; + break; + case "array": + case "object": + try { + toolParams[paramName] = JSON.parse(value.trim()); + } catch { + toolParams[paramName] = value.trim(); // fallback to string if JSON parsing fails + } + break; + default: + toolParams[paramName] = value.trim(); + } + } + }); + } + + // Prepare configuration parameters from current params + const configParams = currentParams.reduce((acc, param) => { + acc[param.name] = param.value; + return acc; + }, {} as Record); + + // Call validateTool with parameters + const result = await validateTool( + tool.origin_name || tool.name, + tool.source, // Tool source + tool.usage || "", // Tool usage + toolParams, // tool input parameters + configParams // tool configuration parameters + ); + + // Format the JSON string response + let formattedResult: string; + try { + const parsedResult = + typeof result === "string" ? JSON.parse(result) : result; + formattedResult = JSON.stringify(parsedResult, null, 2); + } catch (parseError) { + log.error("Failed to parse JSON result:", parseError); + formattedResult = typeof result === "string" ? result : String(result); + } + setTestResult(formattedResult); + } catch (error) { + log.error("Tool test execution failed:", error); + setTestResult(`Test failed: ${error}`); + } finally { + setTestExecuting(false); + } + }; + + // Calculate test panel position to center both panels together + const testPanelWidth = 500; + const gap = windowWidth * 0.05; + const offsetForCentering = (testPanelWidth + gap) / 2; + + // Calculate test panel left position + const testPanelLeft = mainModalRight > 0 + ? mainModalRight + gap - offsetForCentering + : windowWidth / 2 + 300 + windowWidth * 0.05 - offsetForCentering; + + if (!tool) return null; + + return ( + + {visible && ( + <> + {/* Backdrop */} + + + {/* Test Panel */} + 0 ? `${mainModalTop}px` : "10vh", // Align with main modal top or fallback to 10vh + left: `${testPanelLeft}px`, // Position adjusted to center both panels together + width: "500px", + height: "auto", + maxHeight: "80vh", + overflowY: "auto", + backgroundColor: "#fff", + border: "1px solid #d9d9d9", + borderRadius: "8px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + zIndex: 1001, + display: "flex", + flexDirection: "column", + }} + > + {/* Test panel header */} +
+
+ + {tool?.name} + +
+
+ + {/* Test panel content */} +
+ {t("toolConfig.toolTest.toolInfo")} + + {tool?.description} + + + {/* Test parameter input */} +
+ {/* Show current form parameters */} + {currentParams.length > 0 && ( + <> + + {t("toolConfig.toolTest.configParams")} + +
+ {currentParams.map((param) => ( +
+ {param.name} + + + +
+ ))} +
+ + )} + + {/* Input parameters section with conditional toggle */} + {(dynamicInputParams.length > 0 || isManualInputMode) && ( + <> +
+ {t("toolConfig.toolTest.inputParams")} + {/* Only show toggle button if parsing was successful */} + {isParseSuccessful && ( + + )} +
+ + {isManualInputMode ? ( + // Manual JSON input mode +
+ setManualJsonInput(e.target.value)} + rows={6} + style={{ fontFamily: "monospace" }} + /> +
+ ) : ( + // Parsed parameters mode + dynamicInputParams.length > 0 && ( +
+ {dynamicInputParams.map((paramName) => { + const paramInfo = parsedInputs[paramName]; + const description = + paramInfo && + typeof paramInfo === "object" && + paramInfo.description + ? paramInfo.description + : paramName; + + return ( +
+ + {paramName} + + + { + setParamValues((prev) => ({ + ...prev, + [paramName]: e.target.value, + })); + }} + style={{ flex: 1 }} + /> + +
+ ); + })} +
+ ) + )} + + )} + + +
+ + {/* Test result */} +
+ + {t("toolConfig.toolTest.result")} + + +
+
+
+ + )} +
+ ); +} + diff --git a/frontend/app/[locale]/agents/components/agent/agentInfo/AgentGenerateDetail.tsx b/frontend/app/[locale]/agents/components/agent/agentInfo/AgentGenerateDetail.tsx new file mode 100644 index 000000000..19013c7ba --- /dev/null +++ b/frontend/app/[locale]/agents/components/agent/agentInfo/AgentGenerateDetail.tsx @@ -0,0 +1,665 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + Button, + Tabs, + Form, + Input, + Select, + InputNumber, + Row, + Col, + Flex, + Card, + App, +} from "antd"; +import { Zap } from "lucide-react"; + +import log from "@/lib/logger"; +import { ModelOption } from "@/types/modelConfig"; +import { EditableAgent } from "@/stores/agentConfigStore"; +import { AgentProfileInfo, AgentBusinessInfo } from "@/types/agentConfig"; +import { + checkAgentName, + checkAgentDisplayName, +} from "@/services/agentConfigService"; +import { + NAME_CHECK_STATUS, + GENERATE_PROMPT_STREAM_TYPES, +} from "@/const/agentConfig"; +import { generatePromptStream } from "@/services/promptService"; +import { useAuth } from "@/hooks/useAuth"; +import { useModelList } from "@/hooks/model/useModelList"; + +const { TextArea } = Input; + +export interface AgentGenerateDetailProps { + editable: boolean; + editedAgent: EditableAgent; + currentAgentId?: number | null; + onUpdateProfile: (updates: AgentProfileInfo) => void; + onUpdateBusinessInfo: (updates: AgentBusinessInfo) => void; +} + +export default function AgentGenerateDetail({ + editable = false, + editedAgent, + currentAgentId, + onUpdateProfile, + onUpdateBusinessInfo, +}: AgentGenerateDetailProps) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const { user, isSpeedMode } = useAuth(); + const [form] = Form.useForm(); + + // Model data from React Query + const { availableLlmModels, isLoading: loadingModels } = useModelList(); + + // State management + const [activeTab, setActiveTab] = useState("agent-info"); + + // Generation state + const [isGenerating, setIsGenerating] = useState(false); + + // Local state for business info to avoid frequent updates + const [businessInfo, setBusinessInfo] = useState({ + businessDescription: "", + businessLogicModelName: "", + businessLogicModelId: 0, + }); + + // Initialize form values when component mounts or currentAgentId changes + useEffect(() => { + const initialAgentInfo = { + agentName: editedAgent.name || "", + agentDisplayName: editedAgent.display_name || "", + agentAuthor: editedAgent.author || "", + mainAgentModel: + editedAgent.model || availableLlmModels[0]?.displayName || "", + mainAgentMaxStep: editedAgent.max_step || 5, + agentDescription: editedAgent.description || "", + }; + + const initialBusinessInfo = { + businessDescription: editedAgent.business_description || "", + businessLogicModelName: + editedAgent.business_logic_model_name || + availableLlmModels[0]?.displayName || + "", + businessLogicModelId: + editedAgent.business_logic_model_id || availableLlmModels[0]?.id || 0, + }; + // Initialize local business description state + setBusinessInfo(initialBusinessInfo); + + form.setFieldsValue(initialAgentInfo); + }, [currentAgentId, editedAgent]); + + // Handle business description change + const handleBusinessDescriptionChange = (value: string) => { + onUpdateBusinessInfo({ + business_description: value, + business_logic_model_id: editedAgent.business_logic_model_id || 0, + business_logic_model_name: editedAgent.business_logic_model_name || "", + }); + }; + + // Handle model selection for generation + const handleModelChange = (modelName: string) => { + const selectedModel = availableLlmModels.find( + (m) => m.name === modelName || m.displayName === modelName + ); + onUpdateBusinessInfo({ + business_description: businessInfo.businessDescription || "", + business_logic_model_id: selectedModel?.id || 0, + business_logic_model_name: modelName, + }); + }; + + // Custom validator for agent name uniqueness + const validateAgentNameUnique = async (_: any, value: string) => { + if (!value) return Promise.resolve(); + + try { + const result = await checkAgentName(value, currentAgentId || undefined); + if (result.status === NAME_CHECK_STATUS.EXISTS_IN_TENANT) { + return Promise.reject( + new Error(t("agent.error.nameExists", { name: value })) + ); + } + return Promise.resolve(); + } catch (error) { + return Promise.reject(new Error(t("agent.error.checkNameFail"))); + } + }; + + // Custom validator for agent display name uniqueness + const validateAgentDisplayNameUnique = async (_: any, value: string) => { + if (!value) return Promise.resolve(); + + try { + const result = await checkAgentDisplayName( + value, + currentAgentId || undefined + ); + if (result.status === NAME_CHECK_STATUS.EXISTS_IN_TENANT) { + return Promise.reject( + new Error(t("agent.error.displayNameExists", { displayName: value })) + ); + } + return Promise.resolve(); + } catch (error) { + return Promise.reject(new Error(t("agent.error.checkNameFail"))); + } + }; + + const handleGenerateAgent = async () => { + // Validate business description + if ( + !businessInfo.businessDescription || + businessInfo.businessDescription.trim() === "" + ) { + message.error( + t("businessLogic.config.error.businessDescriptionRequired") + ); + return; + } + + // Validate model selection + if (!businessInfo.businessLogicModelId) { + message.error("Please select a model first"); + return; + } + + setIsGenerating(true); + + try { + await generatePromptStream( + { + agent_id: currentAgentId || 0, + task_description: businessInfo.businessDescription, + model_id: businessInfo.businessLogicModelId.toString(), + sub_agent_ids: editedAgent.sub_agent_id_list, + tool_ids: Array.isArray(editedAgent.tools) + ? editedAgent.tools.map((tool: any) => + typeof tool === "object" && tool.id !== undefined + ? tool.id + : tool + ) + : [], + }, + (data) => { + console.log("ai output", data); + // Process streaming response data + + switch (data.type) { + case GENERATE_PROMPT_STREAM_TYPES.DUTY: + setActiveTab("duty"); + form.setFieldsValue({ dutyPrompt: data.content }); + break; + case GENERATE_PROMPT_STREAM_TYPES.CONSTRAINT: + form.setFieldsValue({ constraintPrompt: data.content }); + setActiveTab("constraint"); + break; + case GENERATE_PROMPT_STREAM_TYPES.FEW_SHOTS: + setActiveTab("few-shots"); + form.setFieldsValue({ fewShotsPrompt: data.content }); + break; + case GENERATE_PROMPT_STREAM_TYPES.AGENT_VAR_NAME: + setActiveTab("agent-info"); + // Only update if current agent name is empty + if (!form.getFieldValue("agentName")?.trim()) { + form.setFieldsValue({ agentName: data.content }); + } + break; + case GENERATE_PROMPT_STREAM_TYPES.AGENT_DESCRIPTION: + setActiveTab("agent-info"); + form.setFieldsValue({ agentDescription: data.content }); + break; + case GENERATE_PROMPT_STREAM_TYPES.AGENT_DISPLAY_NAME: + setActiveTab("agent-info"); + // Only update if current agent display name is empty + if (!form.getFieldValue("agentDisplayName")?.trim()) { + form.setFieldsValue({ agentDisplayName: data.content }); + } + break; + } + }, + (error) => { + log.error("Generate prompt stream error:", error); + message.error(t("businessLogic.config.message.generateError")); + setIsGenerating(false); + }, + () => { + // 生成完成后,获取所有 form 值并更新父组件状态 + const formValues = form.getFieldsValue(); + const profileUpdates: AgentProfileInfo = { + name: formValues.agentName, + display_name: formValues.agentDisplayName, + author: formValues.agentAuthor, + model: formValues.mainAgentModel, + max_step: formValues.mainAgentMaxStep, + description: formValues.agentDescription, + duty_prompt: formValues.dutyPrompt, + constraint_prompt: formValues.constraintPrompt, + few_shots_prompt: formValues.fewShotsPrompt, + }; + + // 更新父组件状态 + onUpdateProfile(profileUpdates); + + message.success(t("businessLogic.config.message.generateSuccess")); + setIsGenerating(false); + } + ); + } catch (error) { + log.error("Generate agent error:", error); + message.error(t("businessLogic.config.message.generateError")); + setIsGenerating(false); + } + }; + + // Select options for available models + const modelSelectOptions = availableLlmModels.map((model) => ({ + value: model.displayName || model.name, + label: model.displayName || model.name, + disabled: model.connect_status !== "available", + })); + + // Tab items configuration + const tabItems = [ + { + key: "agent-info", + label: t("agent.info.title"), + children: ( +
+ +
+ + + + onUpdateProfile({ display_name: e.target.value }) + } + /> + + + + onUpdateProfile({ name: e.target.value })} + /> + + + + onUpdateProfile({ author: e.target.value })} + /> + + + + + + + + { + const value = form.getFieldValue("mainAgentMaxStep"); + onUpdateProfile({ max_step: value || 1 }); + }} + /> + + + +