From 406e061b0e162c007d63bfe2860e70b509b799a4 Mon Sep 17 00:00:00 2001 From: Hankin <837833609@qq.com> Date: Thu, 16 Jul 2026 16:42:42 +0800 Subject: [PATCH 001/126] fix: remove empty docker/init.sql file (#3440) The file was introduced in PR #3396 but remained empty (0 bytes) with no content or references in docker-compose configs. Co-authored-by: Claude Opus 4.7 --- docker/init.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docker/init.sql diff --git a/docker/init.sql b/docker/init.sql deleted file mode 100644 index e69de29bb2..0000000000 From 99a9d652f0c1505080f06907b3bd6c4ecec6280b Mon Sep 17 00:00:00 2001 From: gjc199 <97944442+gjc199@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:59:38 +0800 Subject: [PATCH 002/126] =?UTF-8?q?=F0=9F=90=9B=20Bugfix:=20Fix=20issue:#3?= =?UTF-8?q?167,=20#2896,=20#3265,=20#2882=20(#3441)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix reported issue regressions * test: cover issue permission checks * test: cover remaining knowledge permission branches * test: cover private permission fallback branch --- backend/apps/file_management_app.py | 6 + backend/apps/knowledge_summary_app.py | 11 +- backend/apps/model_managment_app.py | 3 +- backend/apps/permission_utils.py | 18 ++ backend/apps/vectordatabase_app.py | 28 ++- backend/consts/model.py | 4 + backend/database/model_management_db.py | 12 +- backend/services/model_health_service.py | 11 +- backend/services/vectordatabase_service.py | 109 ++++++++- .../knowledges/KnowledgeBaseConfiguration.tsx | 8 + .../components/document/DocumentList.tsx | 28 +-- .../components/resources/ModelList.tsx | 4 +- frontend/public/locales/en/common.json | 101 +++++++- frontend/public/locales/zh/common.json | 90 +++++++- frontend/services/a2aService.ts | 9 +- frontend/services/modelService.ts | 50 ++++ test/backend/app/test_file_management_app.py | 48 ++++ .../backend/app/test_knowledge_summary_app.py | 110 +++++++++ test/backend/app/test_model_managment_app.py | 27 ++- test/backend/app/test_vectordatabase_app.py | 187 ++++++++++++++- .../database/test_model_managment_db.py | 16 ++ .../services/test_model_health_service.py | 29 +++ .../services/test_vectordatabase_service.py | 217 ++++++++++++++++++ 23 files changed, 1088 insertions(+), 38 deletions(-) create mode 100644 backend/apps/permission_utils.py diff --git a/backend/apps/file_management_app.py b/backend/apps/file_management_app.py index 427bde6f3e..e30422b0e3 100644 --- a/backend/apps/file_management_app.py +++ b/backend/apps/file_management_app.py @@ -12,6 +12,7 @@ from consts.exceptions import FileTooLargeException, NotFoundException, UnsupportedFileTypeException from consts.model import ProcessParams +from apps.permission_utils import require_knowledge_base_edit_permission from services.file_management_service import upload_to_minio, upload_files_impl, \ get_file_url_impl, get_file_stream_impl, delete_file_impl, list_files_impl, \ resolve_preview_file, get_preview_stream, check_file_access, check_file_access_batch, \ @@ -102,6 +103,8 @@ async def upload_files( detail="No files in the request") user_id, tenant_id = get_current_user_id(authorization) + if index_name: + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) errors, uploaded_file_paths, uploaded_filenames = await upload_files_impl( destination, file, folder, index_name, user_id, uploader_tenant_id=tenant_id ) @@ -144,6 +147,9 @@ async def process_files( index_name: index name in elasticsearch destination: 'local' or 'minio' """ + user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) + process_params = ProcessParams( chunking_strategy=chunking_strategy, source_type=destination, diff --git a/backend/apps/knowledge_summary_app.py b/backend/apps/knowledge_summary_app.py index ab45170fbb..6f664f5ea0 100644 --- a/backend/apps/knowledge_summary_app.py +++ b/backend/apps/knowledge_summary_app.py @@ -6,6 +6,7 @@ from nexent.vector_database.base import VectorDatabaseCore from consts.model import ChangeSummaryRequest +from apps.permission_utils import require_knowledge_base_edit_permission from services.vectordatabase_service import ElasticSearchService, get_vector_db_core from utils.auth_utils import get_current_user_id, get_current_user_info from utils.config_utils import tenant_config_manager @@ -28,8 +29,9 @@ async def auto_summary( ): """Summary Elasticsearch index_name by model""" try: - _, tenant_id, language = get_current_user_info( + user_id, tenant_id, language = get_current_user_info( authorization, http_request) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) service = ElasticSearchService() # Get model_id from tenant config if not provided @@ -53,6 +55,8 @@ async def auto_summary( language=language, model_id=model_id ) + except HTTPException: + raise except Exception as e: logger.error( f"Knowledge base summary generation failed: {e}", exc_info=True) @@ -73,9 +77,12 @@ def change_summary( ): """Summary Elasticsearch index_name by user""" try: - user_id = get_current_user_id(authorization)[0] + user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) summary_result = change_summary_request.summary_result return ElasticSearchService().change_summary(index_name=index_name, summary_result=summary_result, user_id=user_id) + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=500, detail=f"Knowledge base summary update failed: {str(e)}") diff --git a/backend/apps/model_managment_app.py b/backend/apps/model_managment_app.py index edc64ef148..ce448af6dd 100644 --- a/backend/apps/model_managment_app.py +++ b/backend/apps/model_managment_app.py @@ -517,7 +517,8 @@ async def manage_check_model_health( result = await check_model_connectivity( request.display_name, - request.tenant_id + request.tenant_id, + request.model_type ) return JSONResponse(status_code=HTTPStatus.OK, content={ "message": "Successfully checked model connectivity", diff --git a/backend/apps/permission_utils.py b/backend/apps/permission_utils.py new file mode 100644 index 0000000000..9342f5f187 --- /dev/null +++ b/backend/apps/permission_utils.py @@ -0,0 +1,18 @@ +from http import HTTPStatus + +from fastapi import HTTPException + +from services.vectordatabase_service import ElasticSearchService + + +def require_knowledge_base_edit_permission(index_name: str, user_id: str, tenant_id: str) -> None: + try: + ElasticSearchService.require_knowledge_base_edit_permission( + index_name=index_name, + user_id=user_id, + tenant_id=tenant_id, + ) + except ValueError as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) + except PermissionError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(exc)) diff --git a/backend/apps/vectordatabase_app.py b/backend/apps/vectordatabase_app.py index 505c395590..92023aa443 100644 --- a/backend/apps/vectordatabase_app.py +++ b/backend/apps/vectordatabase_app.py @@ -24,6 +24,7 @@ from utils.file_management_utils import get_all_files_status from database.knowledge_db import get_index_name_by_knowledge_name, get_knowledge_record from database.model_management_db import get_model_by_model_id +from apps.permission_utils import require_knowledge_base_edit_permission router = APIRouter(prefix="/indices") service = ElasticSearchService() @@ -125,9 +126,12 @@ async def delete_index( logger.debug(f"Received request to delete knowledge base: {index_name}") try: user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) # Call the centralized full deletion service result = await ElasticSearchService.full_delete_knowledge_base(index_name, vdb_core, user_id) return result + except HTTPException: + raise except Exception as e: logger.error( f"Error during API call to delete index '{index_name}': {str(e)}", exc_info=True) @@ -147,6 +151,7 @@ async def update_index( user_id, auth_tenant_id = get_current_user_id(authorization) # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id tenant_id = request.get("tenant_id") or auth_tenant_id + require_knowledge_base_edit_permission(index_name, user_id, auth_tenant_id) # Extract update fields knowledge_name = request.get("knowledge_name") @@ -197,6 +202,7 @@ async def update_summary_frequency_endpoint( """Update the auto-summary frequency for a knowledge base.""" try: user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) summary_frequency = request.get("summary_frequency") valid_frequencies = VALID_SUMMARY_FREQUENCIES @@ -337,6 +343,7 @@ def update_embedding_model( """ try: user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) model_id = request.get("model_id") if not model_id: @@ -456,6 +463,7 @@ def create_index_documents( """ try: user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) # Get the knowledge base record to retrieve the saved embedding model knowledge_record = get_knowledge_record({'index_name': index_name}) @@ -477,6 +485,8 @@ def create_index_documents( large_mode=large_mode, model_id=saved_embedding_model_id, ) + except HTTPException: + raise except Exception as e: error_msg = str(e) logger.error(f"Error indexing documents: {error_msg}") @@ -519,10 +529,13 @@ async def delete_documents( "full: delete ES documents, MinIO source, and Redis task records" ), ), - vdb_core: VectorDatabaseCore = Depends(get_vector_db_core) + vdb_core: VectorDatabaseCore = Depends(get_vector_db_core), + authorization: Optional[str] = Header(None), ): """Delete a document by scope: source file only or full removal from the index.""" try: + user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) result = await ElasticSearchService.delete_document_by_scope( index_name, path_or_url, scope, vdb_core ) @@ -567,6 +580,8 @@ async def delete_documents( raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) ) + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, @@ -701,6 +716,7 @@ def create_chunk( """Create a manual chunk.""" try: user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) result = ElasticSearchService.create_chunk( index_name=index_name, chunk_request=payload, @@ -714,6 +730,8 @@ def create_chunk( status_code=HTTPStatus.NOT_FOUND, detail=str(e) ) + except HTTPException: + raise except Exception as exc: logger.error( "Error creating chunk for index %s: %s", index_name, exc, exc_info=True @@ -736,6 +754,7 @@ def update_chunk( """Update an existing chunk.""" try: user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) result = ElasticSearchService.update_chunk( index_name=index_name, chunk_id=chunk_id, @@ -750,6 +769,8 @@ def update_chunk( status_code=HTTPStatus.NOT_FOUND, detail=str(e) ) + except HTTPException: + raise except Exception as exc: logger.error( "Error updating chunk %s for index %s: %s", @@ -773,7 +794,8 @@ def delete_chunk( ): """Delete a chunk.""" try: - get_current_user_id(authorization) + user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) result = ElasticSearchService.delete_chunk( index_name=index_name, chunk_id=chunk_id, @@ -785,6 +807,8 @@ def delete_chunk( status_code=HTTPStatus.NOT_FOUND, detail=str(e) ) + except HTTPException: + raise except Exception as exc: logger.error( "Error deleting chunk %s for index %s: %s", diff --git a/backend/consts/model.py b/backend/consts/model.py index 0ec8ee4079..32df76fbc4 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -1156,6 +1156,10 @@ class ManageTenantModelHealthcheckRequest(BaseModel): """Request model for checking model connectivity in a specific tenant (admin/manage operation)""" tenant_id: str = Field(..., min_length=1, description="Target tenant ID to check model connectivity") display_name: str = Field(..., description="Display name of the model to check") + model_type: Optional[str] = Field( + None, + description="Model type to disambiguate models with the same display name", + ) class ManageBatchCreateModelsRequest(BaseModel): diff --git a/backend/database/model_management_db.py b/backend/database/model_management_db.py index 64b00b9f37..16c76bd2d9 100644 --- a/backend/database/model_management_db.py +++ b/backend/database/model_management_db.py @@ -183,10 +183,12 @@ def get_model_by_display_name(display_name: str, tenant_id: str, model_type: str """ filters = {'display_name': display_name} - if model_type in ["multiEmbedding", "multi_embedding"]: - filters['model_type'] = "multi_embedding" - elif model_type == "embedding": - filters['model_type'] = "embedding" + if model_type: + filters['model_type'] = ( + "multi_embedding" + if model_type in ["multiEmbedding", "multi_embedding"] + else model_type + ) records = get_model_records(filters, tenant_id) if not records: @@ -358,5 +360,3 @@ def get_model_by_name_factory(model_name: str, model_factory: str, tenant_id: st } records = get_model_records(filters, tenant_id) return records[0] if records else None - - diff --git a/backend/services/model_health_service.py b/backend/services/model_health_service.py index 5d472799d0..33a24fca7f 100644 --- a/backend/services/model_health_service.py +++ b/backend/services/model_health_service.py @@ -354,7 +354,13 @@ async def check_model_connectivity(display_name: str, tenant_id: str, model_type "connect_status": ModelConnectStatusEnum.UNAVAILABLE.value} logger.error(f"Error checking model connectivity: {str(e)}") update_model_record(model["model_id"], update_data) - raise e + if isinstance(e, ValueError): + raise e + return { + "connectivity": False, + "model_name": model_name, + "error": str(e), + } if connectivity: logger.info( @@ -367,10 +373,11 @@ async def check_model_connectivity(display_name: str, tenant_id: str, model_type if ssl_verify_fallback: update_data["ssl_verify"] = False update_model_record(model["model_id"], update_data) - return { + result = { "connectivity": connectivity, "model_name": model_name, } + return result except Exception as e: logger.error(f"Error checking model connectivity: {str(e)}") if 'model' in locals() and model: diff --git a/backend/services/vectordatabase_service.py b/backend/services/vectordatabase_service.py index dd2f6e51a7..fcd545b6e6 100644 --- a/backend/services/vectordatabase_service.py +++ b/backend/services/vectordatabase_service.py @@ -27,7 +27,18 @@ from nexent.vector_database.elasticsearch_core import ElasticSearchCore from nexent.vector_database.datamate_core import DataMateCore -from consts.const import DATAMATE_URL, ES_API_KEY, ES_HOST, LANGUAGE, VectorDatabaseType, IS_SPEED_MODE, PERMISSION_EDIT, PERMISSION_READ, ASSET_OWNER_TENANT_ID +from consts.const import ( + ASSET_OWNER_TENANT_ID, + CAN_EDIT_ALL_USER_ROLES, + DATAMATE_URL, + ES_API_KEY, + ES_HOST, + IS_SPEED_MODE, + LANGUAGE, + PERMISSION_EDIT, + PERMISSION_READ, + VectorDatabaseType, +) from consts.model import ChunkCreateRequest, ChunkUpdateRequest from database.attachment_db import delete_file, file_exists, get_file_stream from database.knowledge_db import ( @@ -493,6 +504,102 @@ def get_rerank_model(tenant_id: str, model_name: Optional[str] = None): class ElasticSearchService: + CREATOR_PERMISSION = "CREATOR" + + @staticmethod + def resolve_knowledge_base_permission( + index_name: str, + user_id: str, + tenant_id: Optional[str] = None, + ) -> Optional[str]: + """Resolve the current user's permission for one knowledge base.""" + record = get_knowledge_record({"index_name": index_name}) + if not record: + raise ValueError(f"Knowledge base '{index_name}' not found") + + if record.get("knowledge_sources") == "datamate": + return PERMISSION_READ + + user_tenant = get_user_tenant_by_user_id(user_id) + if not user_tenant and not IS_SPEED_MODE: + return None + + user_role = (user_tenant or {}).get("user_role") + user_tenant_id = str((user_tenant or {}).get("tenant_id") or tenant_id or "") + effective_user_role = user_role + if user_id == user_tenant_id: + effective_user_role = "ADMIN" + logger.info(f"User {user_id} identified as legacy admin") + elif IS_SPEED_MODE: + effective_user_role = "SPEED" + logger.info("User under SPEED version is treated as admin") + + role = (effective_user_role or "").upper() + record_tenant_id = str(record.get("tenant_id") or "") + is_asset_owner_record = record_tenant_id == ASSET_OWNER_TENANT_ID + + if is_asset_owner_record: + if role == "ASSET_OWNER": + return PERMISSION_EDIT + if role in {"SU", "ADMIN", "SPEED", "DEV"}: + return PERMISSION_READ + return None + + if record_tenant_id and user_tenant_id and record_tenant_id != user_tenant_id: + return None + + if role in CAN_EDIT_ALL_USER_ROLES: + return PERMISSION_EDIT + + if role in {"USER", "DEV"}: + kb_group_ids_str = record.get("group_ids") + kb_group_ids = convert_string_to_list(kb_group_ids_str or "") + user_group_ids = query_group_ids_by_user(user_id) + + kb_groups_empty = ( + kb_group_ids_str is None + or (isinstance(kb_group_ids_str, str) and kb_group_ids_str.strip() == "") + or len(kb_group_ids) == 0 + ) + user_groups_empty = len(user_group_ids) == 0 + + has_group_intersection = ( + True + if kb_groups_empty and user_groups_empty + else bool(set(user_group_ids) & set(kb_group_ids)) + ) + if not has_group_intersection: + return None + + if str(record.get("created_by")) == str(user_id): + return ElasticSearchService.CREATOR_PERMISSION + + ingroup_permission = record.get("ingroup_permission") or PERMISSION_READ + if ingroup_permission == PERMISSION_EDIT: + return PERMISSION_EDIT + if ingroup_permission == PERMISSION_READ: + return PERMISSION_READ + if ingroup_permission == "PRIVATE": + return None + + return None + + @staticmethod + def require_knowledge_base_edit_permission( + index_name: str, + user_id: str, + tenant_id: Optional[str] = None, + ) -> str: + """Raise when the current user cannot modify the knowledge base.""" + permission = ElasticSearchService.resolve_knowledge_base_permission( + index_name=index_name, + user_id=user_id, + tenant_id=tenant_id, + ) + if permission not in {PERMISSION_EDIT, ElasticSearchService.CREATOR_PERMISSION}: + raise PermissionError("No permission to modify this knowledge base") + return permission + @staticmethod async def full_delete_knowledge_base(index_name: str, vdb_core: VectorDatabaseCore, user_id: str): """ diff --git a/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx b/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx index dd3a427e08..651b44906f 100644 --- a/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx +++ b/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx @@ -756,6 +756,10 @@ function DataConfig({ isActive }: DataConfigProps) { const handleDeleteDocument = (docId: string) => { const kbId = kbState.activeKnowledgeBase?.id; if (!kbId) return; + if (kbState.activeKnowledgeBase?.permission === "READ_ONLY") { + message.error(t("errorCode.000202", "Access forbidden.")); + return; + } confirm({ title: t("document.modal.deleteConfirm.title"), @@ -776,6 +780,10 @@ function DataConfig({ isActive }: DataConfigProps) { // Handle file upload - in creation mode create knowledge base first then upload, in normal mode upload directly const handleFileUpload = async () => { + if (!isCreatingMode && kbState.activeKnowledgeBase?.permission === "READ_ONLY") { + message.error(t("errorCode.000202", "Access forbidden.")); + return; + } if (!uploadFiles.length) { message.warning(t("document.message.noFiles")); return; diff --git a/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx b/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx index b96a3b59bf..58e394be8a 100644 --- a/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx +++ b/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx @@ -1052,18 +1052,20 @@ const DocumentListContainer = forwardRef( > {t("common.preview")} - + {!isReadOnlyMode && ( + + )} )} @@ -1103,7 +1105,7 @@ const DocumentListContainer = forwardRef( onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop} - disabled={!isCreatingMode && !knowledgeBaseId} + disabled={isReadOnlyMode || (!isCreatingMode && !knowledgeBaseId)} componentHeight={uploadHeight} isCreatingMode={isCreatingMode} // Use internal ID for backend operations; fall back to name in creation mode diff --git a/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx b/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx index bbe404d89a..80ec12b48b 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx @@ -143,11 +143,11 @@ export default function ModelList({ tenantId }: { tenantId: string | null }) { setCheckingConnectivity((prev) => new Set(prev).add(displayName)); try { - const isConnected = await modelService.verifyCustomModel(displayName, modelType); + const isConnected = await modelService.checkManageTenantModelConnectivity(tenantId, displayName, modelType); if (isConnected) { message.success(t("tenantResources.models.connectivitySuccess")); } else { - message.warning(t("tenantResources.models.connectivityFailed")); + message.error(t("tenantResources.models.connectivityFailed")); } refetch(); } catch (error) { diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 3d1e160710..e9e10cbb2b 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -2035,7 +2035,7 @@ "tenantResources.models.deleteFailed": "Delete failed", "tenantResources.models.checkConnectivity": "Check Connectivity", "tenantResources.models.connectivitySuccess": "Model connectivity is healthy", - "tenantResources.models.connectivityFailed": "Model cannot connect", + "tenantResources.models.connectivityFailed": "Model unavailable", "tenantResources.models.connectivityError": "Error checking connectivity", "tenantResources.models.enterDescription": "Enter model description", "tenantResources.models.enterName": "Enter model name", @@ -3364,5 +3364,102 @@ "skillSpace.comingSoon.feature1": "Browse and install community-built skills", "skillSpace.comingSoon.feature2": "Create and publish your own skill packs", "skillSpace.comingSoon.feature3": "Version control and skill dependency management", - "skillSpace.comingSoon.badge": "Coming Soon" + "skillSpace.comingSoon.badge": "Coming Soon", + "a2a.discovery.addToLocalAgent": "Add to Local Agent", + "a2a.discovery.addToLocalAgentFailed": "Failed to add to local agent", + "a2a.discovery.addToLocalAgentSuccess": "Added to local agent", + "a2a.discovery.noDescription": "No description", + "a2a.discovery.scanFailed": "Failed to scan agents", + "a2a.service.addRelationSuccess": "Relation added successfully", + "a2a.service.deleteSuccess": "Deleted successfully", + "a2a.service.updateProtocolFailed": "Failed to update protocol", + "agent.validation.nameRequired": "Please enter agent name", + "agentEvaluation.history.caseCount": "{{count}} cases", + "agentEvaluation.step1.runningProgress": "Running {{current}}/{{total}}", + "chatHeader.share": "Share", + "chatInterface.errorUpdatingTitle": "Failed to update title", + "chatInterface.resumeStreamFailed": "Failed to resume stream", + "chatInterface.shareLoadFailed": "Failed to load shared conversation", + "chatInterface.shareReadOnly": "Shared conversation is read-only", + "chatInterface.sharedConversation": "Shared Conversation", + "chatRightPanel.source.aidp": "Source: AIDP", + "chatStreamMain.noHistory": "No history", + "chatStreamMain.noMessages": "No messages", + "copyButton.copied": "Copied", + "copyButton.copy": "Copy", + "document.chunk.source.datamate": "DataMate", + "document.chunk.source.nexent": "Nexent", + "document.delete.terminateTask": "Terminate task", + "document.message.deleteError": "Failed to delete document", + "document.message.uploadDisabledForDataMate": "DataMate knowledge bases do not support document uploads", + "document.summary.placeholder": "Enter knowledge base summary", + "filePreview.markdownOutline": "Markdown Outline", + "knowledgeBase.create.embeddingModelPlaceholder": "Please select an embedding model", + "knowledgeBase.empty": "No knowledge bases", + "knowledgeBase.total": "{{count}} knowledge bases", + "market.detail.inputParameters": "Input Parameters", + "market.install.mcp.defaultConfigHint": "The MCP service will be installed with default configuration", + "mcpConfig.message.getMcpRecordFailed": "Failed to get MCP record", + "mcpConfig.message.uploadImageFailed": "Failed to upload image", + "mcpService.debug.addFromConfigFailed": "Failed to add MCP service from config:", + "mcpService.debug.deleteContainerFailed": "Failed to delete container:", + "mcpService.debug.getContainerLogsFailed": "Failed to get container logs:", + "mcpService.debug.getContainersFailed": "Failed to get containers:", + "mcpService.debug.getMcpRecordFailed": "Failed to get MCP record:", + "mcpService.debug.healthCheckFailed": "Health check failed:", + "mcpService.debug.streamContainerLogsFailed": "Failed to stream container logs:", + "mcpService.message.addFromConfigFailed": "Failed to add MCP service from config", + "mcpService.message.addFromConfigSuccess": "MCP service added from config", + "mcpService.message.containerNotFound": "Container not found", + "mcpService.message.deleteContainerFailed": "Failed to delete container", + "mcpService.message.dockerServiceUnavailable": "Docker service is unavailable. Please check whether Docker is running.", + "mcpService.message.getContainerLogsFailed": "Failed to get container logs", + "mcpService.message.getContainersFailed": "Failed to get containers", + "mcpService.message.getMcpRecordFailed": "Failed to get MCP record", + "mcpService.message.healthCheckFailed": "Health check failed", + "mcpService.message.healthCheckSuccess": "Health check passed", + "mcpService.message.invalidConfig": "Invalid configuration", + "mcpService.message.mcpRecordNotFound": "MCP record not found", + "mcpService.message.mcpServerNotFound": "MCP service not found", + "mcpTools.mine.reviewProgressService": "MCP Service", + "mcpTools.mine.reviewProgressStepApproved": "Approved", + "mcpTools.mine.reviewProgressStepRejected": "Rejected", + "mcpTools.mine.reviewProgressStepReviewing": "Waiting for admin review", + "mcpTools.mine.reviewProgressStepSubmitted": "Application submitted", + "mcpTools.mine.reviewProgressTitle": "Version Update Review Progress", + "mcpTools.mine.reviewProgressVersion": "Version", + "mcpTools.page.importService": "Import", + "mcpTools.page.role.admin": "Admin", + "mcpTools.page.role.adminHint": "Can manage the repository and review center", + "mcpTools.page.role.user": "User", + "mcpTools.page.role.userHint": "Can install, import, and manage personal MCP tools", + "mcpTools.repository.downloads": "Downloads", + "mcpTools.repository.installCount": "Installs", + "mcpTools.repository.noRating": "No rating", + "mcpTools.repository.offlinePending": "Pending offline", + "mcpTools.repository.rating": "Rating", + "mcpTools.repository.version": "Version", + "mcpTools.review.initialListing": "Initial listing v{{version}}", + "mcpTools.review.status.offline": "Offline", + "mcpTools.review.submitter": "Submitter: {{name}}", + "mcpTools.review.versionUpdate": "Version update v{{oldVersion}} → v{{newVersion}}", + "mcpTools.service.toggle.missingId": "Missing MCP service ID", + "model.dialog.error.apiConnectionFailed": "Model API connection failed", + "model.dialog.error.provider.accessDenied": "Access denied. Please check permissions.", + "model.dialog.error.provider.authenticationFailed": "Authentication failed. Please check the API key.", + "model.dialog.error.provider.connectionFailed": "Failed to connect to model service", + "model.dialog.error.provider.endpointNotFound": "Model service endpoint not found", + "model.dialog.error.provider.noModels": "No available models found", + "model.dialog.error.provider.serverError": "Model service error. Please try again later.", + "model.dialog.error.provider.sslError": "SSL verification failed. Please check certificate configuration.", + "model.dialog.error.provider.timeout": "Connection to model service timed out", + "model.message.updateFailed": "Failed to update model", + "model.message.updateSuccess": "Model updated successfully", + "page.adminPrompt.githubSupport": "Star us on GitHub to support Nexent", + "page.adminPrompt.intro": "You need administrator permissions to continue.", + "page.adminPrompt.title": "Administrator Required", + "setup.navigation.button.saving": "Saving...", + "space.new": "New Space", + "toolConfig.validation.array.invalid": "Please enter valid array JSON", + "toolConfig.validation.object.invalid": "Please enter valid object JSON" } diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 168c75a767..9ab2e7693c 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -2004,7 +2004,7 @@ "tenantResources.models.deleteFailed": "删除模型失败", "tenantResources.models.checkConnectivity": "检查连通性", "tenantResources.models.connectivitySuccess": "模型连通性正常", - "tenantResources.models.connectivityFailed": "模型无法连接", + "tenantResources.models.connectivityFailed": "模型不可用", "tenantResources.models.connectivityError": "检查连通性时发生错误", "tenantResources.models.enterDescription": "输入模型描述", "tenantResources.models.enterName": "输入模型名称", @@ -3375,5 +3375,91 @@ "mcpTools.repository.rating": "评分", "mcpTools.repository.downloads": "下载量", "mcpTools.repository.downloadCount": "下载次数", - "mcpTools.repository.installed": "已安装" + "mcpTools.repository.installed": "已安装", + "a2a.discovery.addToLocalAgent": "添加到本地 Agent", + "a2a.discovery.addToLocalAgentFailed": "添加到本地 Agent 失败", + "a2a.discovery.addToLocalAgentSuccess": "已添加到本地 Agent", + "a2a.discovery.noDescription": "暂无描述", + "a2a.discovery.scanFailed": "扫描 Agent 失败", + "a2a.service.addRelationSuccess": "关联添加成功", + "a2a.service.deleteSuccess": "删除成功", + "agent.validation.nameRequired": "请输入 Agent 名称", + "chatHeader.share": "分享", + "chatInterface.errorUpdatingTitle": "更新标题失败", + "chatInterface.resumeStreamFailed": "恢复流式响应失败", + "chatInterface.shareLoadFailed": "加载分享内容失败", + "chatInterface.shareReadOnly": "分享对话仅可查看", + "chatInterface.sharedConversation": "分享的对话", + "chatRightPanel.source.aidp": "来源: AIDP", + "chatStreamFinalMessage.maxStepsMessage": "已达到最大执行步数,请调整配置后重试", + "chatStreamMain.noHistory": "暂无历史消息", + "chatStreamMain.noMessages": "暂无消息", + "copyButton.copied": "已复制", + "copyButton.copy": "复制", + "document.chunk.source.datamate": "DataMate", + "document.chunk.source.nexent": "Nexent", + "document.delete.terminateTask": "终止任务", + "document.message.deleteError": "删除文档失败", + "document.message.uploadDisabledForDataMate": "DataMate 知识库不支持上传文档", + "document.summary.placeholder": "请输入知识库总结", + "filePreview.markdownOutline": "Markdown 大纲", + "knowledgeBase.create.embeddingModelPlaceholder": "请选择嵌入模型", + "knowledgeBase.empty": "暂无知识库", + "knowledgeBase.filter.clear": "清空筛选", + "knowledgeBase.message.embeddingModelRequired": "请选择嵌入模型", + "knowledgeBase.total": "共 {{count}} 个知识库", + "market.detail.inputParameters": "输入参数", + "market.install.mcp.defaultConfigHint": "将使用默认配置安装 MCP 服务", + "mcpConfig.message.getMcpRecordFailed": "获取 MCP 记录失败", + "mcpConfig.message.uploadImageFailed": "上传镜像失败", + "mcpService.debug.addFromConfigFailed": "从配置添加 MCP 服务失败:", + "mcpService.debug.deleteContainerFailed": "删除容器失败:", + "mcpService.debug.getContainerLogsFailed": "获取容器日志失败:", + "mcpService.debug.getContainersFailed": "获取容器列表失败:", + "mcpService.debug.getMcpRecordFailed": "获取 MCP 记录失败:", + "mcpService.debug.healthCheckFailed": "健康检查失败:", + "mcpService.debug.streamContainerLogsFailed": "拉取容器日志流失败:", + "mcpService.message.addFromConfigFailed": "从配置添加 MCP 服务失败", + "mcpService.message.addFromConfigSuccess": "已从配置添加 MCP 服务", + "mcpService.message.containerNotFound": "容器不存在", + "mcpService.message.deleteContainerFailed": "删除容器失败", + "mcpService.message.dockerServiceUnavailable": "Docker 服务不可用,请检查 Docker 是否正在运行", + "mcpService.message.getContainerLogsFailed": "获取容器日志失败", + "mcpService.message.getContainersFailed": "获取容器列表失败", + "mcpService.message.getMcpRecordFailed": "获取 MCP 记录失败", + "mcpService.message.healthCheckFailed": "健康检查失败", + "mcpService.message.healthCheckSuccess": "健康检查通过", + "mcpService.message.invalidConfig": "配置无效", + "mcpService.message.mcpRecordNotFound": "MCP 记录不存在", + "mcpService.message.mcpServerNotFound": "MCP 服务不存在", + "mcpTools.detail.saving": "保存中...", + "mcpTools.service.toggle.missingId": "缺少 MCP 服务 ID", + "model.dialog.error.provider.accessDenied": "访问被拒绝,请检查权限", + "model.dialog.error.provider.authenticationFailed": "认证失败,请检查 API Key", + "model.dialog.error.provider.connectionFailed": "连接模型服务失败", + "model.dialog.error.provider.endpointNotFound": "模型服务地址不存在", + "model.dialog.error.provider.noModels": "未获取到可用模型", + "model.dialog.error.provider.serverError": "模型服务异常,请稍后重试", + "model.dialog.error.provider.sslError": "SSL 验证失败,请检查证书配置", + "model.dialog.error.provider.timeout": "连接模型服务超时", + "model.dialog.success.connectivityVerified": "连通性验证成功", + "model.message.updateFailed": "更新模型失败", + "model.message.updateSuccess": "模型更新成功", + "page.loginPrompt.header": "登录后继续使用", + "sidebar.knowledgeBase": "知识库", + "sidebar.memoryManagement": "记忆管理", + "sidebar.modelManagement": "模型管理", + "space.new": "新建空间", + "systemPrompt.button.badcase": "Badcase 优化", + "systemPrompt.finetune.title": "微调", + "systemPrompt.finetune.insertPositionLabel": "插入位置(字符索引)", + "systemPrompt.finetune.insertPositionPlaceholder": "例如:50", + "systemPrompt.finetune.positionError": "请输入有效的位置数字", + "systemPrompt.finetune.selectEndLabel": "选择结束位置(字符索引)", + "systemPrompt.finetune.selectEndPlaceholder": "例如:100", + "systemPrompt.finetune.selectStartLabel": "选择开始位置(字符索引)", + "systemPrompt.finetune.selectStartPlaceholder": "例如:10", + "systemPrompt.finetune.selectTip": "在下方编辑器中选择文本以获取位置", + "toolConfig.validation.array.invalid": "请输入有效的数组 JSON", + "toolConfig.validation.object.invalid": "请输入有效的对象 JSON" } diff --git a/frontend/services/a2aService.ts b/frontend/services/a2aService.ts index f2909fa8ec..8e2923d4ab 100644 --- a/frontend/services/a2aService.ts +++ b/frontend/services/a2aService.ts @@ -532,9 +532,14 @@ export const a2aClientService = { const data = await response.json(); if (response.ok && data.status === 'success') { + const testResult = data.data; return { - success: true, - message: data.data?.message || t('a2a.service.testConnectionSuccess') + success: testResult?.success === true, + message: testResult?.message || ( + testResult?.success === true + ? t('a2a.service.testConnectionSuccess') + : t('a2a.service.testConnectionFailed') + ) }; } diff --git a/frontend/services/modelService.ts b/frontend/services/modelService.ts index 66246cb81d..a2f13d296e 100644 --- a/frontend/services/modelService.ts +++ b/frontend/services/modelService.ts @@ -116,6 +116,12 @@ const mapCapacityCoverageFromApi = (coverage: any): CapacityCoverage => ({ })), }); +type ModelConnectivityResult = { + connectivity: boolean; + modelName?: string; + error?: string; +}; + // Error class export class ModelError extends Error { constructor( @@ -626,6 +632,50 @@ export const modelService = { } }, + checkManageTenantModelConnectivityDetail: async ( + tenantId: string, + displayName: string, + modelType: string, + signal?: AbortSignal + ): Promise => { + try { + if (!displayName) return { connectivity: false }; + const response = await fetch(API_ENDPOINTS.model.manageModelHealthcheck, { + method: "POST", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tenant_id: tenantId, + display_name: displayName, + model_type: modelType, + }), + signal, + }); + const result = await response.json(); + if (response.status === 200 && result.data) { + return { + connectivity: Boolean(result.data.connectivity), + modelName: result.data.model_name, + error: result.data.error, + }; + } + return { + connectivity: false, + error: result.detail || result.message, + }; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw error; + } + return { + connectivity: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + // Check model connectivity for a specific tenant (admin/manage operation) checkManageTenantModelConnectivity: async ( tenantId: string, diff --git a/test/backend/app/test_file_management_app.py b/test/backend/app/test_file_management_app.py index 81c4efd4e7..e4d7107e41 100644 --- a/test/backend/app/test_file_management_app.py +++ b/test/backend/app/test_file_management_app.py @@ -116,6 +116,19 @@ def _stub_get_preview_stream(actual_object_name, start=None, end=None): sys.modules["services.file_management_service"] = sfms_stub setattr(services_pkg, "file_management_service", sfms_stub) +vdb_service_stub = types.ModuleType("services.vectordatabase_service") + + +class _StubElasticSearchService: + @staticmethod + def require_knowledge_base_edit_permission(index_name, user_id, tenant_id=None): + return "EDIT" + + +vdb_service_stub.ElasticSearchService = _StubElasticSearchService +sys.modules["services.vectordatabase_service"] = vdb_service_stub +setattr(services_pkg, "vectordatabase_service", vdb_service_stub) + # Stub utils.auth_utils.get_current_user_id (the function actually used in the app) utils_pkg = types.ModuleType("utils") @@ -222,6 +235,41 @@ async def fake_upload_impl(dest, files, folder, index_name, user_id=None, upload assert "a.txt" in content and "/abs/path1" in content +def test_upload_files_forbidden_for_read_only(monkeypatch): + from fastapi import FastAPI, HTTPException + from fastapi.testclient import TestClient + + mock_require_permission = MagicMock( + side_effect=HTTPException( + status_code=403, + detail="No permission to modify this knowledge base", + ) + ) + mock_upload_impl = AsyncMock() + monkeypatch.setattr(file_management_app, "require_knowledge_base_edit_permission", mock_require_permission) + monkeypatch.setattr(file_management_app, "upload_files_impl", mock_upload_impl) + + app = FastAPI() + app.include_router(file_management_app.file_management_config_router) + client = TestClient(app) + + response = client.post( + "/file/upload", + data={ + "destination": "minio", + "folder": "knowledge_base", + "index_name": "test_index", + }, + files=[("file", ("read-only.txt", b"data", "text/plain"))], + headers={"Authorization": MOCK_AUTH}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with("test_index", "user1", "tenant1") + mock_upload_impl.assert_not_called() + + @pytest.mark.asyncio async def test_upload_files_no_files_bad_request(): with pytest.raises(Exception) as ei: diff --git a/test/backend/app/test_knowledge_summary_app.py b/test/backend/app/test_knowledge_summary_app.py index fcbad52db5..b50899918b 100644 --- a/test/backend/app/test_knowledge_summary_app.py +++ b/test/backend/app/test_knowledge_summary_app.py @@ -70,6 +70,10 @@ class MockElasticSearchService: def __init__(self, *args, **kwargs): pass + @staticmethod + def require_knowledge_base_edit_permission(index_name, user_id, tenant_id=None): + return "EDIT" + def mock_get_vector_db_core(): return MagicMock() @@ -112,6 +116,8 @@ class ChangeSummaryRequest(BaseModel): # Import the modules we need from fastapi.testclient import TestClient from fastapi import FastAPI +from fastapi import HTTPException +from apps import permission_utils from apps.knowledge_summary_app import router # Create a test app and client @@ -133,6 +139,44 @@ def test_data(): return data +def test_permission_utils_maps_missing_knowledge_base_to_404(monkeypatch): + def raise_missing(**_kwargs): + raise ValueError("Knowledge base 'missing' not found") + + monkeypatch.setattr( + permission_utils.ElasticSearchService, + "require_knowledge_base_edit_permission", + raise_missing, + ) + + with pytest.raises(HTTPException) as exc_info: + permission_utils.require_knowledge_base_edit_permission( + "missing", "user-1", "tenant-1" + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Knowledge base 'missing' not found" + + +def test_permission_utils_maps_permission_error_to_403(monkeypatch): + def raise_forbidden(**_kwargs): + raise PermissionError("No permission to modify this knowledge base") + + monkeypatch.setattr( + permission_utils.ElasticSearchService, + "require_knowledge_base_edit_permission", + raise_forbidden, + ) + + with pytest.raises(HTTPException) as exc_info: + permission_utils.require_knowledge_base_edit_permission( + "kb", "user-1", "tenant-1" + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "No permission to modify this knowledge base" + + class TestAutoSummary: """Test auto summary generation endpoint""" @@ -166,6 +210,40 @@ def test_auto_summary_success(self, mock_user_info, mock_vdb_core, mock_service_ assert call_kwargs['language'] == mock_user_info_value[2] assert call_kwargs['model_id'] == 1 + @patch('apps.knowledge_summary_app.ElasticSearchService') + @patch('apps.knowledge_summary_app.get_vector_db_core') + @patch('apps.knowledge_summary_app.get_current_user_info') + @patch('apps.knowledge_summary_app.require_knowledge_base_edit_permission') + def test_auto_summary_forbidden_for_read_only( + self, + mock_require_permission, + mock_user_info, + mock_vdb_core, + mock_service_class, + test_data, + ): + """Read-only users must not be able to regenerate knowledge base summary.""" + mock_vdb_core.return_value = MagicMock() + mock_user_info.return_value = test_data["user_info"] + mock_require_permission.side_effect = HTTPException( + status_code=403, + detail="No permission to modify this knowledge base", + ) + + response = client.post( + f"/summary/{test_data['index_name']}/auto_summary", + headers=test_data["auth_header"] + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with( + test_data["index_name"], + test_data["user_info"][0], + test_data["user_info"][1], + ) + mock_service_class.assert_not_called() + @patch('apps.knowledge_summary_app.ElasticSearchService') @patch('apps.knowledge_summary_app.get_vector_db_core') @patch('apps.knowledge_summary_app.get_current_user_info') @@ -352,6 +430,38 @@ def test_change_summary_success(self, mock_get_user_id, mock_service_class, test user_id=test_data["user_id"][0] ) + @patch('apps.knowledge_summary_app.ElasticSearchService') + @patch('apps.knowledge_summary_app.get_current_user_id') + @patch('apps.permission_utils.ElasticSearchService.require_knowledge_base_edit_permission') + def test_change_summary_forbidden_for_read_only( + self, + mock_require_permission, + mock_get_user_id, + mock_service_class, + test_data, + ): + """Read-only users must not be able to update knowledge base summary.""" + mock_get_user_id.return_value = test_data["user_id"] + mock_require_permission.side_effect = PermissionError( + "No permission to modify this knowledge base" + ) + + request_data = {"summary_result": test_data["summary_result"]} + response = client.post( + f"/summary/{test_data['index_name']}/summary", + json=request_data, + headers=test_data["auth_header"] + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with( + index_name=test_data["index_name"], + user_id=test_data["user_id"][0], + tenant_id=test_data["user_id"][1], + ) + mock_service_class.return_value.change_summary.assert_not_called() + @patch('apps.knowledge_summary_app.ElasticSearchService') @patch('apps.knowledge_summary_app.get_current_user_id') def test_change_summary_exception(self, mock_get_user_id, mock_service_class, test_data): diff --git a/test/backend/app/test_model_managment_app.py b/test/backend/app/test_model_managment_app.py index 8def4d3e2f..619586da3f 100644 --- a/test/backend/app/test_model_managment_app.py +++ b/test/backend/app/test_model_managment_app.py @@ -1533,7 +1533,8 @@ async def test_manage_healthcheck_success(client, auth_header, user_credentials, request_data = { "tenant_id": "target_tenant", - "display_name": "test-model" + "display_name": "test-model", + "model_type": "llm" } response = client.post("/model/manage/healthcheck", json=request_data, headers=auth_header) @@ -1541,7 +1542,29 @@ async def test_manage_healthcheck_success(client, auth_header, user_credentials, data = response.json() assert "Successfully checked model connectivity" in data["message"] assert data["data"]["connectivity"] is True - mock_check.assert_called_once_with("test-model", "target_tenant") + mock_check.assert_called_once_with("test-model", "target_tenant", "llm") + + +@pytest.mark.asyncio +async def test_manage_healthcheck_without_model_type_is_backward_compatible( + client, auth_header, user_credentials, mocker +): + """Test model connectivity check still accepts requests without model_type.""" + mocker.patch('backend.apps.model_managment_app.get_current_user_id', return_value=user_credentials) + + mock_check = mocker.patch( + 'backend.apps.model_managment_app.check_model_connectivity', + return_value={"connectivity": True, "connect_status": "available"} + ) + + request_data = { + "tenant_id": "target_tenant", + "display_name": "test-model" + } + response = client.post("/model/manage/healthcheck", json=request_data, headers=auth_header) + + assert response.status_code == HTTPStatus.OK + mock_check.assert_called_once_with("test-model", "target_tenant", None) @pytest.mark.asyncio diff --git a/test/backend/app/test_vectordatabase_app.py b/test/backend/app/test_vectordatabase_app.py index cd684512f4..998316e102 100644 --- a/test/backend/app/test_vectordatabase_app.py +++ b/test/backend/app/test_vectordatabase_app.py @@ -10,7 +10,7 @@ import importlib.machinery from unittest.mock import patch, MagicMock, ANY, AsyncMock from fastapi.testclient import TestClient -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from typing import List, Optional, Any, Dict from pydantic import BaseModel @@ -142,6 +142,15 @@ def auth_data(): "auth_header": {"Authorization": "Bearer test_token"} } + +@pytest.fixture(autouse=True) +def mock_knowledge_base_edit_permission(): + with patch( + "backend.apps.vectordatabase_app.ElasticSearchService.require_knowledge_base_edit_permission", + return_value="EDIT", + ): + yield + # Test cases using pytest-asyncio @@ -358,6 +367,36 @@ async def test_delete_index_success(vdb_core_mock, redis_service_mock, auth_data ) +@pytest.mark.asyncio +async def test_delete_index_forbidden_for_read_only(vdb_core_mock, auth_data): + """Read-only users must not be able to delete a knowledge base.""" + with patch("backend.apps.vectordatabase_app.get_vector_db_core", return_value=vdb_core_mock), \ + patch("backend.apps.vectordatabase_app.get_current_user_id", + return_value=(auth_data["user_id"], auth_data["tenant_id"])), \ + patch( + "apps.permission_utils.ElasticSearchService.require_knowledge_base_edit_permission", + side_effect=PermissionError("No permission to modify this knowledge base"), + ) as mock_require_permission, \ + patch( + "backend.apps.vectordatabase_app.ElasticSearchService.full_delete_knowledge_base", + new_callable=AsyncMock, + ) as mock_full_delete: + + response = client.delete( + f"/indices/{auth_data['index_name']}", + headers=auth_data["auth_header"], + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with( + index_name=auth_data["index_name"], + user_id=auth_data["user_id"], + tenant_id=auth_data["tenant_id"], + ) + mock_full_delete.assert_not_called() + + @pytest.mark.asyncio async def test_delete_index_redis_error(vdb_core_mock, redis_service_mock, auth_data): """ @@ -687,6 +726,37 @@ async def test_create_index_documents_success(vdb_core_mock, auth_data): mock_index.assert_called_once() +@pytest.mark.asyncio +async def test_create_index_documents_forbidden_for_read_only(vdb_core_mock, auth_data): + """Read-only users must not be able to index documents.""" + with patch("backend.apps.vectordatabase_app.get_vector_db_core", return_value=vdb_core_mock), \ + patch("backend.apps.vectordatabase_app.get_current_user_id", + return_value=(auth_data["user_id"], auth_data["tenant_id"])), \ + patch( + "backend.apps.vectordatabase_app.require_knowledge_base_edit_permission", + side_effect=HTTPException( + status_code=403, + detail="No permission to modify this knowledge base", + ), + ) as mock_require_permission, \ + patch("backend.apps.vectordatabase_app.ElasticSearchService.index_documents") as mock_index: + + response = client.post( + f"/indices/{auth_data['index_name']}/documents", + json=[{"id": 1, "text": "test doc"}], + headers=auth_data["auth_header"], + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with( + auth_data["index_name"], + auth_data["user_id"], + auth_data["tenant_id"], + ) + mock_index.assert_not_called() + + @pytest.mark.asyncio async def test_create_index_documents_uses_multimodal_embedding(vdb_core_mock, auth_data): with patch("backend.apps.vectordatabase_app.get_vector_db_core", return_value=vdb_core_mock), \ @@ -1085,6 +1155,37 @@ async def test_create_chunk_success(vdb_core_mock, auth_data): assert call_kwargs["tenant_id"] == auth_data["tenant_id"] +@pytest.mark.asyncio +async def test_create_chunk_forbidden_for_read_only(vdb_core_mock, auth_data): + """Read-only users must not be able to create chunks.""" + with patch("backend.apps.vectordatabase_app.get_vector_db_core", return_value=vdb_core_mock), \ + patch("backend.apps.vectordatabase_app.get_current_user_id", + return_value=(auth_data["user_id"], auth_data["tenant_id"])), \ + patch( + "backend.apps.vectordatabase_app.require_knowledge_base_edit_permission", + side_effect=HTTPException( + status_code=403, + detail="No permission to modify this knowledge base", + ), + ) as mock_require_permission, \ + patch("backend.apps.vectordatabase_app.ElasticSearchService.create_chunk") as mock_create: + + response = client.post( + f"/indices/{auth_data['index_name']}/chunk", + json={"content": "Hello world", "path_or_url": "doc-1"}, + headers=auth_data["auth_header"], + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with( + auth_data["index_name"], + auth_data["user_id"], + auth_data["tenant_id"], + ) + mock_create.assert_not_called() + + @pytest.mark.asyncio async def test_create_chunk_passes_tenant_id_to_service(vdb_core_mock, auth_data): """ @@ -1180,6 +1281,37 @@ async def test_update_chunk_success(vdb_core_mock, auth_data): mock_update.assert_called_once() +@pytest.mark.asyncio +async def test_update_chunk_forbidden_for_read_only(vdb_core_mock, auth_data): + """Read-only users must not be able to update chunks.""" + with patch("backend.apps.vectordatabase_app.get_vector_db_core", return_value=vdb_core_mock), \ + patch("backend.apps.vectordatabase_app.get_current_user_id", + return_value=(auth_data["user_id"], auth_data["tenant_id"])), \ + patch( + "backend.apps.vectordatabase_app.require_knowledge_base_edit_permission", + side_effect=HTTPException( + status_code=403, + detail="No permission to modify this knowledge base", + ), + ) as mock_require_permission, \ + patch("backend.apps.vectordatabase_app.ElasticSearchService.update_chunk") as mock_update: + + response = client.put( + f"/indices/{auth_data['index_name']}/chunk/chunk-1", + json={"content": "Updated content"}, + headers=auth_data["auth_header"], + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with( + auth_data["index_name"], + auth_data["user_id"], + auth_data["tenant_id"], + ) + mock_update.assert_not_called() + + @pytest.mark.asyncio async def test_update_chunk_value_error(vdb_core_mock, auth_data): """ @@ -1261,6 +1393,33 @@ async def test_delete_chunk_success(vdb_core_mock, auth_data): mock_delete.assert_called_once() +@pytest.mark.asyncio +async def test_delete_chunk_forbidden_for_read_only(vdb_core_mock, auth_data): + """Read-only users must not be able to delete chunks.""" + with patch("backend.apps.vectordatabase_app.get_vector_db_core", return_value=vdb_core_mock), \ + patch("backend.apps.vectordatabase_app.get_current_user_id", + return_value=(auth_data["user_id"], auth_data["tenant_id"])), \ + patch( + "apps.permission_utils.ElasticSearchService.require_knowledge_base_edit_permission", + side_effect=PermissionError("No permission to modify this knowledge base"), + ) as mock_require_permission, \ + patch("backend.apps.vectordatabase_app.ElasticSearchService.delete_chunk") as mock_delete: + + response = client.delete( + f"/indices/{auth_data['index_name']}/chunk/chunk-1", + headers=auth_data["auth_header"], + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with( + index_name=auth_data["index_name"], + user_id=auth_data["user_id"], + tenant_id=auth_data["tenant_id"], + ) + mock_delete.assert_not_called() + + @pytest.mark.asyncio async def test_delete_chunk_not_found(vdb_core_mock, auth_data): """ @@ -1679,6 +1838,32 @@ async def test_delete_documents_success(vdb_core_mock, redis_service_mock): index_name, path_or_url) +@pytest.mark.asyncio +async def test_delete_documents_forbidden_for_read_only(vdb_core_mock, auth_data): + """Read-only users must not be able to delete files from a knowledge base.""" + with patch("backend.apps.vectordatabase_app.get_vector_db_core", return_value=vdb_core_mock), \ + patch("backend.apps.vectordatabase_app.get_current_user_id", + return_value=(auth_data["user_id"], auth_data["tenant_id"])), \ + patch( + "backend.apps.vectordatabase_app.ElasticSearchService.require_knowledge_base_edit_permission", + side_effect=PermissionError("No permission to modify this knowledge base"), + ), \ + patch( + "backend.apps.vectordatabase_app.ElasticSearchService.delete_document_by_scope", + new_callable=AsyncMock, + ) as mock_delete_by_scope: + + response = client.delete( + f"/indices/{auth_data['index_name']}/documents", + params={"path_or_url": "test_document.pdf", "scope": "full"}, + headers=auth_data["auth_header"], + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_delete_by_scope.assert_not_called() + + @pytest.mark.asyncio async def test_delete_documents_source_only_skips_redis(vdb_core_mock, redis_service_mock): """source_only scope must not trigger Redis document cleanup.""" diff --git a/test/backend/database/test_model_managment_db.py b/test/backend/database/test_model_managment_db.py index 25692c1cae..ad2583b43e 100644 --- a/test/backend/database/test_model_managment_db.py +++ b/test/backend/database/test_model_managment_db.py @@ -437,6 +437,22 @@ def fake_get_model_records(filters, tenant_id): assert captured["model_type"] == "embedding" +def test_get_model_by_display_name_llm_filter(monkeypatch): + captured = {} + + def fake_get_model_records(filters, tenant_id): + captured.update(filters) + return [{"model_id": 13, "display_name": "Shared Name", "model_type": "llm"}] + + monkeypatch.setattr(model_mgmt_db, "get_model_records", fake_get_model_records) + + result = model_mgmt_db.get_model_by_display_name("Shared Name", "tenant13", model_type="llm") + + assert result["model_id"] == 13 + assert captured["display_name"] == "Shared Name" + assert captured["model_type"] == "llm" + + def test_get_model_by_model_id_not_found(monkeypatch): mock_scalars = MagicMock() mock_scalars.first.return_value = None diff --git a/test/backend/services/test_model_health_service.py b/test/backend/services/test_model_health_service.py index 0411a6f30b..cedf0e57eb 100644 --- a/test/backend/services/test_model_health_service.py +++ b/test/backend/services/test_model_health_service.py @@ -525,6 +525,35 @@ async def test_check_model_connectivity_exception(): "model123", {"connect_status": "unavailable"}) +@pytest.mark.asyncio +async def test_check_model_connectivity_probe_exception_returns_unavailable(): + with mock.patch("backend.services.model_health_service._perform_connectivity_check") as mock_connectivity_check, \ + mock.patch("backend.services.model_health_service.get_model_by_display_name") as mock_get_model, \ + mock.patch("backend.services.model_health_service.update_model_record") as mock_update_model, \ + mock.patch("backend.services.model_health_service.ModelConnectStatusEnum") as mock_enum: + + mock_enum.AVAILABLE.value = "available" + mock_enum.UNAVAILABLE.value = "unavailable" + mock_enum.DETECTING.value = "detecting" + + mock_get_model.return_value = { + "model_id": "model123", + "model_name": "gpt-4", + "model_type": "llm", + "base_url": "https://api.openai.com", + "api_key": "test-key" + } + mock_connectivity_check.side_effect = RuntimeError("connection refused") + + response = await check_model_connectivity("GPT-4", "tenant456") + + assert response["connectivity"] is False + assert response["model_name"] == "gpt-4" + assert response["error"] == "connection refused" + mock_update_model.assert_any_call( + "model123", {"connect_status": "unavailable"}) + + @pytest.mark.asyncio async def test_check_model_connectivity_general_exception(): # Setup diff --git a/test/backend/services/test_vectordatabase_service.py b/test/backend/services/test_vectordatabase_service.py index c6d2ea3e68..05660d92d2 100644 --- a/test/backend/services/test_vectordatabase_service.py +++ b/test/backend/services/test_vectordatabase_service.py @@ -7383,5 +7383,222 @@ def test_search_hybrid_needs_config_raises(self, mock_get_model): self.assertIn("embedding model", str(ctx.exception).lower()) +def _patch_kb_permission_context( + monkeypatch, + record, + user_tenant=None, + user_group_ids=None, + speed_mode=False, +): + import backend.services.vectordatabase_service as vdb_service + + monkeypatch.setattr(vdb_service, "get_knowledge_record", lambda _filters: record) + monkeypatch.setattr( + vdb_service, + "get_user_tenant_by_user_id", + lambda _user_id: user_tenant, + ) + monkeypatch.setattr( + vdb_service, + "query_group_ids_by_user", + lambda _user_id: user_group_ids or [], + ) + monkeypatch.setattr(vdb_service, "IS_SPEED_MODE", speed_mode) + monkeypatch.setattr(vdb_service, "ASSET_OWNER_TENANT_ID", "asset-owner") + + +def test_resolve_knowledge_base_permission_not_found(monkeypatch): + _patch_kb_permission_context(monkeypatch, record=None) + + with pytest.raises(ValueError, match="not found"): + ElasticSearchService.resolve_knowledge_base_permission( + "missing-kb", "user-1", "tenant-1" + ) + + +def test_resolve_knowledge_base_permission_datamate_is_read_only(monkeypatch): + _patch_kb_permission_context( + monkeypatch, + record={"index_name": "datamate-kb", "knowledge_sources": "datamate"}, + ) + + permission = ElasticSearchService.resolve_knowledge_base_permission( + "datamate-kb", "user-1", "tenant-1" + ) + + assert permission == "READ_ONLY" + + +def test_resolve_knowledge_base_permission_without_user_tenant_returns_none(monkeypatch): + _patch_kb_permission_context( + monkeypatch, + record={ + "index_name": "kb", + "knowledge_sources": "elasticsearch", + "tenant_id": "tenant-1", + }, + user_tenant=None, + ) + + assert ( + ElasticSearchService.resolve_knowledge_base_permission("kb", "user-1", "tenant-1") + is None + ) + + +@pytest.mark.parametrize( + "user_id,user_tenant,speed_mode,expected", + [ + ("tenant-1", {"tenant_id": "tenant-1"}, False, "EDIT"), + ("user-speed", None, True, "EDIT"), + ("admin-user", {"user_role": "ADMIN", "tenant_id": "tenant-1"}, False, "EDIT"), + ], +) +def test_resolve_knowledge_base_permission_admin_like_roles_edit( + monkeypatch, + user_id, + user_tenant, + speed_mode, + expected, +): + _patch_kb_permission_context( + monkeypatch, + record={ + "index_name": "kb", + "knowledge_sources": "elasticsearch", + "tenant_id": "tenant-1", + }, + user_tenant=user_tenant, + speed_mode=speed_mode, + ) + + assert ( + ElasticSearchService.resolve_knowledge_base_permission("kb", user_id, "tenant-1") + == expected + ) + + +@pytest.mark.parametrize( + "role,expected", + [ + ("ASSET_OWNER", "EDIT"), + ("SU", "READ_ONLY"), + ("DEV", "READ_ONLY"), + ("USER", None), + ], +) +def test_resolve_knowledge_base_permission_asset_owner_record(monkeypatch, role, expected): + _patch_kb_permission_context( + monkeypatch, + record={ + "index_name": "asset-kb", + "knowledge_sources": "elasticsearch", + "tenant_id": "asset-owner", + }, + user_tenant={"user_role": role, "tenant_id": "tenant-1"}, + ) + + assert ( + ElasticSearchService.resolve_knowledge_base_permission("asset-kb", "user-1", "tenant-1") + == expected + ) + + +def test_resolve_knowledge_base_permission_cross_tenant_returns_none(monkeypatch): + _patch_kb_permission_context( + monkeypatch, + record={ + "index_name": "kb", + "knowledge_sources": "elasticsearch", + "tenant_id": "tenant-2", + }, + user_tenant={"user_role": "ADMIN", "tenant_id": "tenant-1"}, + ) + + assert ( + ElasticSearchService.resolve_knowledge_base_permission("kb", "admin-user", "tenant-1") + is None + ) + + +def test_resolve_knowledge_base_permission_unknown_role_returns_none(monkeypatch): + _patch_kb_permission_context( + monkeypatch, + record={ + "index_name": "kb", + "knowledge_sources": "elasticsearch", + "tenant_id": "tenant-1", + }, + user_tenant={"user_role": "GUEST", "tenant_id": "tenant-1"}, + ) + + assert ( + ElasticSearchService.resolve_knowledge_base_permission("kb", "guest-user", "tenant-1") + is None + ) + + +@pytest.mark.parametrize( + "record,user_group_ids,expected", + [ + ({"group_ids": "1,2", "created_by": "other", "ingroup_permission": "EDIT"}, [2], "EDIT"), + ({"group_ids": "1", "created_by": "other", "ingroup_permission": "READ_ONLY"}, [1], "READ_ONLY"), + ({"group_ids": "1", "created_by": "other", "ingroup_permission": "PRIVATE"}, [1], None), + ({"group_ids": "1", "created_by": "other", "ingroup_permission": "UNKNOWN"}, [1], None), + ({"group_ids": "1", "created_by": "other", "ingroup_permission": "EDIT"}, [3], None), + ({"group_ids": "", "created_by": "user-1", "ingroup_permission": "READ_ONLY"}, [], "CREATOR"), + ({"group_ids": None, "created_by": "other"}, [], "READ_ONLY"), + ], +) +def test_resolve_knowledge_base_permission_user_group_rules( + monkeypatch, + record, + user_group_ids, + expected, +): + record = { + "index_name": "kb", + "knowledge_sources": "elasticsearch", + "tenant_id": "tenant-1", + **record, + } + _patch_kb_permission_context( + monkeypatch, + record=record, + user_tenant={"user_role": "USER", "tenant_id": "tenant-1"}, + user_group_ids=user_group_ids, + ) + + assert ( + ElasticSearchService.resolve_knowledge_base_permission("kb", "user-1", "tenant-1") + == expected + ) + + +@pytest.mark.parametrize("permission", ["EDIT", "CREATOR"]) +def test_require_knowledge_base_edit_permission_allows_editors(monkeypatch, permission): + monkeypatch.setattr( + ElasticSearchService, + "resolve_knowledge_base_permission", + staticmethod(lambda **_kwargs: permission), + ) + + assert ( + ElasticSearchService.require_knowledge_base_edit_permission("kb", "user-1", "tenant-1") + == permission + ) + + +def test_require_knowledge_base_edit_permission_rejects_read_only(monkeypatch): + monkeypatch.setattr( + ElasticSearchService, + "resolve_knowledge_base_permission", + staticmethod(lambda **_kwargs: "READ_ONLY"), + ) + + with pytest.raises(PermissionError, match="No permission"): + ElasticSearchService.require_knowledge_base_edit_permission("kb", "user-1", "tenant-1") + + if __name__ == '__main__': unittest.main() From 9af00c00715dec5b880830ca3e59d16323796595 Mon Sep 17 00:00:00 2001 From: frr <64584192+wuyuanfr@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:17:17 +0800 Subject: [PATCH 003/126] fix: add missing W1 capacity fields to ManageTenantModel request schemas (#3446) * fix: add missing W1 capacity fields to ManageTenantModel request schemas * test: preserve capacity fields in manage model requests --- backend/consts/model.py | 16 ++++++++++++ test/backend/test_model_consts.py | 42 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/backend/consts/model.py b/backend/consts/model.py index 32df76fbc4..0ec263879b 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -1108,6 +1108,14 @@ class ManageTenantModelCreateRequest(BaseModel): access_token: Optional[str] = Field(None, description="Access token for STT models (e.g., Volcano Engine)") timeout_seconds: Optional[int] = Field(None, description="Request timeout in seconds") concurrency_limit: Optional[int] = Field(None, description="Maximum concurrent requests for this model") + # W1 capacity fields (see W1 ADR). All nullable; resolver applies precedence. + context_window_tokens: Optional[int] = Field(None, description="Total combined input/output context window in tokens") + max_input_tokens: Optional[int] = Field(None, description="Provider hard input-token limit") + max_output_tokens: Optional[int] = Field(None, description="Provider-supported completion output cap") + default_output_reserve_tokens: Optional[int] = Field(None, description="Default output allowance reserved per request") + tokenizer_family: Optional[str] = Field(None, description="Token-counting strategy or tokenizer identifier") + capacity_source: Optional[str] = Field(None, description="Source of the persisted capacity value") + capability_profile_version: Optional[str] = Field(None, description="Version of the approved capability profile") # W11 accept-signal fields. Same audit-only contract as ModelRequest: # the app layer pops them off model_data before the dict reaches the # service/DB layer and forwards them to @@ -1139,6 +1147,14 @@ class ManageTenantModelUpdateRequest(BaseModel): access_token: Optional[str] = Field(None, description="Access token for STT models") timeout_seconds: Optional[int] = Field(None, description="Request timeout in seconds") concurrency_limit: Optional[int] = Field(None, description="Maximum concurrent requests for this model") + # W1 capacity fields (see W1 ADR). All nullable; resolver applies precedence. + context_window_tokens: Optional[int] = Field(None, description="Total combined input/output context window in tokens") + max_input_tokens: Optional[int] = Field(None, description="Provider hard input-token limit") + max_output_tokens: Optional[int] = Field(None, description="Provider-supported completion output cap") + default_output_reserve_tokens: Optional[int] = Field(None, description="Default output allowance reserved per request") + tokenizer_family: Optional[str] = Field(None, description="Token-counting strategy or tokenizer identifier") + capacity_source: Optional[str] = Field(None, description="Source of the persisted capacity value") + capability_profile_version: Optional[str] = Field(None, description="Version of the approved capability profile") # W11 accept-signal fields. See ManageTenantModelCreateRequest for the # contract. The app layer pops them before calling the service so # update_model_record never sees them. diff --git a/test/backend/test_model_consts.py b/test/backend/test_model_consts.py index bf3874dce4..3378932538 100644 --- a/test/backend/test_model_consts.py +++ b/test/backend/test_model_consts.py @@ -56,6 +56,48 @@ def test_model_request_threads_w11_capacity_and_accept_fields(): assert not missing, f"ModelRequest missing W11 fields: {missing}" +@pytest.mark.parametrize( + ("request_type", "required_fields"), + [ + ( + model_consts.ManageTenantModelCreateRequest, + {"tenant_id", "model_name", "model_type"}, + ), + ( + model_consts.ManageTenantModelUpdateRequest, + {"tenant_id", "current_display_name"}, + ), + ], +) +def test_manage_model_requests_preserve_capacity_fields(request_type, required_fields): + """Manage create/update must not silently discard capacity fields.""" + capacity_values = { + "context_window_tokens": 128_000, + "max_input_tokens": 120_000, + "max_output_tokens": 8_000, + "default_output_reserve_tokens": 4_000, + "tokenizer_family": "cl100k_base", + "capacity_source": "operator", + "capability_profile_version": "2026-07-17", + } + required_values = { + "tenant_id": "tenant-1", + "model_name": "test-model", + "model_type": "llm", + "current_display_name": "Test Model", + } + request = request_type( + **{ + field: required_values[field] + for field in required_fields + }, + **capacity_values, + ) + + dumped = request.model_dump(exclude_unset=True) + assert {field: dumped[field] for field in capacity_values} == capacity_values + + def test_capacity_suggestion_response_has_required_fields(): """Pin ModelCapacitySuggestionResponse schema so a downstream rename (e.g. suggested_provider -> canonical_provider) trips a test instead From eb7b745ef68919fc87e30b17871f458767606001 Mon Sep 17 00:00:00 2001 From: panyehong <91180085+YehongPan@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:18:34 +0800 Subject: [PATCH 004/126] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20Fixed?= =?UTF-8?q?=20an=20issue=20where=20JSON=20analysis=20was=20not=20supported?= =?UTF-8?q?=20during=20file=20analysis=20in=20the=20session.=20(#3454)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/tools/analyze_text_file_tool.py | 20 +++++++++-- .../core/tools/test_analyze_text_file_tool.py | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/sdk/nexent/core/tools/analyze_text_file_tool.py b/sdk/nexent/core/tools/analyze_text_file_tool.py index 89c285af43..4894435ccb 100644 --- a/sdk/nexent/core/tools/analyze_text_file_tool.py +++ b/sdk/nexent/core/tools/analyze_text_file_tool.py @@ -4,6 +4,7 @@ Extracts content from text files (excluding images) and analyzes it using a large language model. Supports files from S3, HTTP, and HTTPS URLs. """ +import json import logging from typing import List, Optional @@ -156,7 +157,8 @@ def _forward_impl( for index, single_file in enumerate(file_url_list, start=1): logger.info( f"Extracting text content from file #{index}, query: {query}") - filename = f"file_{index}.txt" + extension = ".json" if self._is_valid_json(single_file) else ".txt" + filename = f"file_{index}{extension}" # Step 1: Get file content raw_text = self.process_text_file(filename, single_file) @@ -185,6 +187,15 @@ def _forward_impl( error_msg = f"Error analyzing text file: {str(e)}" raise Exception(error_msg) + @staticmethod + def _is_valid_json(file_content: bytes) -> bool: + """Return whether the file content is a valid JSON document.""" + try: + json.loads(file_content) + except (json.JSONDecodeError, UnicodeDecodeError, TypeError): + return False + return True + def process_text_file(self, filename: str, file_content: bytes,) -> str: """ Process text file, convert to text using external API @@ -196,8 +207,13 @@ def process_text_file(self, filename: str, file_content: bytes,) -> str: raw_text = "" try: # Upload byte data as a file + content_type = ( + "application/json" + if filename.lower().endswith(".json") + else "application/octet-stream" + ) files = { - 'file': (filename, file_content, 'application/octet-stream') + 'file': (filename, file_content, content_type) } data = { 'chunking_strategy': 'basic', diff --git a/test/sdk/core/tools/test_analyze_text_file_tool.py b/test/sdk/core/tools/test_analyze_text_file_tool.py index 2b3461ec5d..4b12cfb1ca 100644 --- a/test/sdk/core/tools/test_analyze_text_file_tool.py +++ b/test/sdk/core/tools/test_analyze_text_file_tool.py @@ -84,8 +84,25 @@ def test_forward_impl_switches_language(self, observer_en, llm_model, monkeypatc result = tool._forward_impl([b"x"], "question") assert result == ["answer"] + tool.process_text_file.assert_called_once_with("file_1.txt", b"x") observer_en.add_message.assert_any_call("", ProcessType.TOOL, "Analyzing file...") + @pytest.mark.parametrize( + "payload", + [ + b'{"name":"alice","age":18}', + b'[{"id":1},{"id":2}]', + ], + ) + def test_forward_impl_preserves_json_format(self, tool, payload): + tool.process_text_file = MagicMock(return_value="text") + tool.analyze_file = MagicMock(return_value=("answer", 0.0)) + + result = tool._forward_impl([payload], "question") + + assert result == ["answer"] + tool.process_text_file.assert_called_once_with("file_1.json", payload) + @pytest.mark.parametrize( "payload,error", [ @@ -120,6 +137,22 @@ def test_process_text_file_success(self, tool): assert result == "converted" tool._mock_http_client.post.assert_called_once() + request_kwargs = tool._mock_http_client.post.call_args.kwargs + assert request_kwargs["files"]["file"] == ( + "doc.txt", b"bytes", "application/octet-stream") + + def test_process_text_file_uses_json_content_type(self, tool): + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"text": "converted"} + tool._mock_http_client.post.return_value = mock_response + payload = b'{"name":"alice"}' + + result = tool.process_text_file("doc.json", payload) + + assert result == "converted" + request_kwargs = tool._mock_http_client.post.call_args.kwargs + assert request_kwargs["files"]["file"] == ( + "doc.json", payload, "application/json") def test_process_text_file_http_error_json_detail(self, tool): mock_response = MagicMock(status_code=400) From 2bf43341a9d258fad02e9c885d9675b8f210ccae Mon Sep 17 00:00:00 2001 From: gjc199 <97944442+gjc199@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:29:24 +0800 Subject: [PATCH 005/126] Improve MCP add error messages (#3448) --- backend/apps/remote_mcp_app.py | 7 +- backend/services/remote_mcp_service.py | 52 ++++++-- frontend/hooks/mcpTools/useMcpAddLocal.ts | 15 +-- .../hooks/mcpTools/useMcpCommunityQuickAdd.ts | 10 +- .../hooks/mcpTools/useMcpRegistryQuickAdd.ts | 10 +- frontend/lib/mcpTools.ts | 126 +++++++++++++++++- frontend/public/locales/en/common.json | 9 +- frontend/public/locales/zh/common.json | 9 +- test/backend/app/test_remote_mcp_app.py | 27 ++++ .../services/test_remote_mcp_service.py | 89 ++++++++++++- 10 files changed, 314 insertions(+), 40 deletions(-) diff --git a/backend/apps/remote_mcp_app.py b/backend/apps/remote_mcp_app.py index aa362fcbda..0c1f41e17b 100644 --- a/backend/apps/remote_mcp_app.py +++ b/backend/apps/remote_mcp_app.py @@ -186,7 +186,10 @@ async def add_mcp_service_endpoint( raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="MCP name already exists") except MCPConnectionError as e: logger.error(f"Failed to add MCP service: {e}") - raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="MCP connection failed") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail=str(e) or "MCP connection failed" + ) except McpValidationError as e: raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: @@ -254,7 +257,7 @@ async def add_container_mcp_service_endpoint( logger.error(f"MCP connection failed when adding container service: {e}") raise HTTPException( status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail="MCP connection failed" + detail=str(e) or "MCP connection failed" ) except Exception as e: logger.error(f"Failed to add container MCP service: {e}") diff --git a/backend/services/remote_mcp_service.py b/backend/services/remote_mcp_service.py index 83e0ae1110..a64f996070 100644 --- a/backend/services/remote_mcp_service.py +++ b/backend/services/remote_mcp_service.py @@ -41,6 +41,36 @@ logger = logging.getLogger("remote_mcp_service") +MCP_HEALTH_CHECK_TIMEOUT_SECONDS = 10 + + +def _iter_exception_chain(exc: BaseException): + seen: set[int] = set() + current: BaseException | None = exc + while current and id(current) not in seen: + seen.add(id(current)) + yield current + current = current.__cause__ or current.__context__ + + +def _format_mcp_connection_error(exc: BaseException) -> str: + for candidate in _iter_exception_chain(exc): + error_type = type(candidate).__name__.lower() + error_text = str(candidate).lower() + if "timeout" in error_type or any(keyword in error_text for keyword in ("timeout", "timed out", "etimedout")): + return "MCP connection timeout" + if any(keyword in error_text for keyword in ("connection refused", "econnrefused", "actively refused")): + return "MCP connection refused" + if any(keyword in error_text for keyword in ("unauthorized", "forbidden", "authentication", "authorization", "401", "403")): + return "MCP authentication failed" + if any(keyword in error_text for keyword in ("404", "not found", "endpoint")): + return "MCP endpoint not found" + if any(keyword in error_text for keyword in ("protocol", "invalid sse")): + return "MCP protocol or endpoint invalid" + if any(keyword in error_text for keyword in ("dns", "getaddrinfo", "enotfound", "eai_again", "network unreachable")): + return "MCP address unreachable" + return "MCP connection failed" + # --------------------------------------------------------------------------- # Health Check @@ -90,16 +120,22 @@ async def _mcp_protocol_health_check(url_stripped: str, headers: dict) -> list[s httpx_client_factory=create_httpx_client ) - client = Client(transport=transport) - async with client: - # Verify the server can actually serve tools. - # This exercises API key validation and end-to-end connectivity, - # unlike is_connected() which only checks the initialize handshake. - tools_result = await asyncio.wait_for(client.list_tools(), timeout=10) - return [t.name for t in tools_result] if tools_result else [] + async def list_mcp_tools() -> list: + client = Client(transport=transport) + async with client: + # Verify the server can actually serve tools. + # This exercises API key validation and end-to-end connectivity, + # unlike is_connected() which only checks the initialize handshake. + return await client.list_tools() + + tools_result = await asyncio.wait_for( + list_mcp_tools(), + timeout=MCP_HEALTH_CHECK_TIMEOUT_SECONDS, + ) + return [t.name for t in tools_result] if tools_result else [] except BaseException as e: logger.debug(f"MCP protocol health check failed: {e}") - return [] + raise MCPConnectionError(_format_mcp_connection_error(e)) async def _mcp_protocol_connect(url_stripped: str, headers: dict) -> bool: diff --git a/frontend/hooks/mcpTools/useMcpAddLocal.ts b/frontend/hooks/mcpTools/useMcpAddLocal.ts index e9214695f0..ce52939d66 100644 --- a/frontend/hooks/mcpTools/useMcpAddLocal.ts +++ b/frontend/hooks/mcpTools/useMcpAddLocal.ts @@ -10,6 +10,7 @@ import { addMcpToolService, parseContainerMcpConfigJson, } from "@/services/mcpToolsService"; +import { getMcpAddErrorMessage } from "@/lib/mcpTools"; import { checkContainerPortAvailable } from "./useContainerPortAvailability"; import { McpDeploymentType, McpSource, MCP_TOOLS_QUERY_KEYS } from "@/const/mcpTools"; import type { LocalAddMcpDraft } from "@/types/mcpTools"; @@ -106,7 +107,10 @@ export function useMcpAddLocal({ onSuccess }: UseMcpAddLocalParams) { ? JSON.stringify({ authorization_token: draft.authorizationToken.trim() }) : undefined; - await uploadMcpImage(file, draft.containerPort, trimmedName, envVars); + const result = await uploadMcpImage(file, draft.containerPort, trimmedName, envVars); + if (!result.success) { + throw new Error(result.message || t("mcpTools.add.error.imageUploadFailed")); + } } else if (isContainer) { const mcpConfig = parseContainerMcpConfigJson(draft.containerConfigJson); if (!mcpConfig) { @@ -151,14 +155,7 @@ export function useMcpAddLocal({ onSuccess }: UseMcpAddLocalParams) { return true; } catch (error) { log.error("[useMcpAddLocal] Failed to add service", { error }); - const msg = error instanceof Error ? error.message : ""; - if (/already exists|name conflict|name already used/i.test(msg)) { - message.error(t("mcpTools.add.error.nameExists")); - } else if (/connection|unreachable|ECONNREFUSED|ETIMEDOUT/i.test(msg)) { - message.error(t("mcpTools.add.error.connectionFailed")); - } else { - message.error(msg || t("mcpTools.add.failed")); - } + message.error(getMcpAddErrorMessage(error, t)); return false; } finally { setSubmitting(false); diff --git a/frontend/hooks/mcpTools/useMcpCommunityQuickAdd.ts b/frontend/hooks/mcpTools/useMcpCommunityQuickAdd.ts index a75a7719aa..4593e53c6d 100644 --- a/frontend/hooks/mcpTools/useMcpCommunityQuickAdd.ts +++ b/frontend/hooks/mcpTools/useMcpCommunityQuickAdd.ts @@ -12,6 +12,7 @@ import { parseContainerMcpConfigJson, } from "@/services/mcpToolsService"; import { checkContainerPortAvailable } from "./useContainerPortAvailability"; +import { getMcpAddErrorMessage } from "@/lib/mcpTools"; import { McpSource, McpTransportType } from "@/const/mcpTools"; import type { CommunityMcpCard, CommunityQuickAddDraft } from "@/types/mcpTools"; import { MCP_TOOLS_QUERY_KEYS } from "@/const/mcpTools"; @@ -124,14 +125,7 @@ export function useMcpCommunityQuickAdd({ } function handleAddError(error: unknown) { - const msg = error instanceof Error ? error.message : ""; - if (/already exists|name conflict|name already used/i.test(msg)) { - message.error(t("mcpTools.add.error.nameExists")); - } else if (/connection|unreachable|ECONNREFUSED|ETIMEDOUT/i.test(msg)) { - message.error(t("mcpTools.add.error.connectionFailed")); - } else { - message.error(msg || t("mcpTools.add.failed")); - } + message.error(getMcpAddErrorMessage(error, t)); } const confirm = useCallback(async () => { diff --git a/frontend/hooks/mcpTools/useMcpRegistryQuickAdd.ts b/frontend/hooks/mcpTools/useMcpRegistryQuickAdd.ts index 977d700fe9..cbc1d0cd20 100644 --- a/frontend/hooks/mcpTools/useMcpRegistryQuickAdd.ts +++ b/frontend/hooks/mcpTools/useMcpRegistryQuickAdd.ts @@ -17,6 +17,7 @@ import { buildInitialQuickAddValues, collectPackageEnvValues, findMissingRequiredField, + getMcpAddErrorMessage, hasUnresolvedUrlTemplate, inferContainerRuntimeCommand, normalizeServerKey, @@ -259,14 +260,7 @@ export function useMcpRegistryQuickAdd({ log.error("[useMcpRegistryQuickAdd] Failed to add from registry", { error, }); - const msg = error instanceof Error ? error.message : ""; - if (/already exists|name conflict|name already used/i.test(msg)) { - message.error(t("mcpTools.add.error.nameExists")); - } else if (/connection|unreachable|ECONNREFUSED|ETIMEDOUT/i.test(msg)) { - message.error(t("mcpTools.add.error.connectionFailed")); - } else { - message.error(msg || t("mcpTools.add.failed")); - } + message.error(getMcpAddErrorMessage(error, t)); } finally { setSubmitting(false); } diff --git a/frontend/lib/mcpTools.ts b/frontend/lib/mcpTools.ts index c54352b82c..c3a2bfd2b7 100644 --- a/frontend/lib/mcpTools.ts +++ b/frontend/lib/mcpTools.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import type { McpServer } from "@/types/agentConfig"; import type { CommunityMcpCard, @@ -85,6 +86,130 @@ export const getContainerStatusKey = ( return "mcpTools.containerStatus.unknown"; }; +const stringifyErrorValue = (value: unknown): string => { + if (typeof value === "string") return value.trim(); + if (Array.isArray(value) || (value && typeof value === "object")) { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return value == null ? "" : String(value); +}; + +export const extractMcpErrorMessage = (error: unknown): string => { + const raw = + error instanceof Error ? error.message : stringifyErrorValue(error); + const text = raw.trim(); + if (!text) return ""; + + try { + const parsed = JSON.parse(text) as { + detail?: unknown; + message?: unknown; + error?: unknown; + }; + return ( + stringifyErrorValue(parsed.detail) || + stringifyErrorValue(parsed.message) || + stringifyErrorValue(parsed.error) || + text + ); + } catch { + return text; + } +}; + +const getErrorCode = (error: unknown): string => { + if (!error || typeof error !== "object" || !("code" in error)) return ""; + return String((error as { code?: unknown }).code ?? ""); +}; + +const isTechnicalErrorMessage = (message: string): boolean => { + if (message.length > 180) return true; + return /traceback|exceptiongroup|stack trace|fastmcp|httpx|httpcore|pydantic|baseexception|\[errno|connecterror|readerror|timeouterror/i.test( + message + ); +}; + +const addErrorText = ( + t: TFunction, + key: string, + fallback: string +): string => { + const translated = t(key, { defaultValue: fallback }); + return translated === key ? fallback : translated; +}; + +export const getMcpAddErrorMessage = ( + error: unknown, + t: TFunction +): string => { + const msg = extractMcpErrorMessage(error); + const normalized = msg.toLowerCase(); + const errorCode = getErrorCode(error); + + if (/already exists|name conflict|name already used/.test(normalized)) { + return addErrorText(t, "mcpTools.add.error.nameExists", "An MCP service with this name already exists. Please use a different name."); + } + if ( + /port.*(already|in use|occupied|conflict|unavailable)|address already in use/.test( + normalized + ) + ) { + return addErrorText(t, "mcpTools.add.error.portUnavailable", "The port is unavailable or already in use. Choose another port and try again."); + } + if (/docker|container runtime|daemon/.test(normalized)) { + return addErrorText(t, "mcpTools.add.error.dockerUnavailable", "Docker is unavailable. Make sure Docker is running and try again."); + } + if ( + /unauthorized|forbidden|authentication|authorization|invalid token|bearer|401|403/.test( + normalized + ) + ) { + return addErrorText(t, "mcpTools.add.error.authenticationFailed", "MCP service authentication failed. Check the Bearer token or custom headers."); + } + if (/timeout|timed out|etimedout/.test(normalized)) { + return addErrorText(t, "mcpTools.add.error.connectionTimeout", "Timed out while connecting to the MCP service. Check network connectivity and service responsiveness."); + } + if ( + /econnrefused|connection refused|actively refused|no connection could be made/.test( + normalized + ) + ) { + return addErrorText(t, "mcpTools.add.error.connectionRefused", "The MCP service port refused the connection. Confirm the service is running and the port is correct."); + } + if ( + /does not support mcp|mcp protocol|protocol|invalid sse|404|not found|wrong path|endpoint/.test( + normalized + ) + ) { + return addErrorText(t, "mcpTools.add.error.protocolOrPathInvalid", "The MCP protocol or path does not match. Confirm the URL ends with the correct endpoint, such as /sse or /mcp."); + } + if ( + /enotfound|eai_again|getaddrinfo|dns|network unreachable|ehostunreach|unreachable/.test( + normalized + ) + ) { + return addErrorText(t, "mcpTools.add.error.connectionUnreachable", "Cannot reach the MCP service address. Check the domain, network, or host address."); + } + if ( + /connection|connecterror|disconnected|closed|reset|failed to fetch|networkerror/.test( + normalized + ) || + errorCode === "503" + ) { + return addErrorText(t, "mcpTools.add.error.connectionFailed", "Failed to connect to the MCP service. Verify the service URL, authentication settings, and service status."); + } + + if (msg && !isTechnicalErrorMessage(msg)) { + return msg; + } + + return addErrorText(t, "mcpTools.add.failed", "Failed to add MCP service"); +}; + export const filterServiceCards = ( services: McpServiceItem[], searchValue: string @@ -754,4 +879,3 @@ export const collectPackageEnvValues = ( export const isValidPort = (port: number | undefined): port is number => { return typeof port === "number" && Number.isInteger(port) && port >= MCP_PORT_RANGE.MIN && port <= MCP_PORT_RANGE.MAX; }; - diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index e9e10cbb2b..0b788ee2c3 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -2332,7 +2332,14 @@ "mcpTools.add.error.containerJsonInvalid": "Container JSON config is invalid", "mcpTools.add.error.containerJsonMissingServers": "Container config must include an mcpServers object", "mcpTools.add.error.containerAddFailed": "Failed to add container config", - "mcpTools.add.error.connectionFailed": "MCP server connection failed. Please verify the server URL and try again.", + "mcpTools.add.error.connectionFailed": "Failed to connect to the MCP service. Verify the service URL, authentication settings, and service status.", + "mcpTools.add.error.connectionRefused": "The MCP service port refused the connection. Confirm the service is running and the port is correct.", + "mcpTools.add.error.connectionTimeout": "Timed out while connecting to the MCP service. Check network connectivity and service responsiveness.", + "mcpTools.add.error.connectionUnreachable": "Cannot reach the MCP service address. Check the domain, network, or host address.", + "mcpTools.add.error.authenticationFailed": "MCP service authentication failed. Check the Bearer token or custom headers.", + "mcpTools.add.error.protocolOrPathInvalid": "The MCP protocol or path does not match. Confirm the URL ends with the correct endpoint, such as /sse or /mcp.", + "mcpTools.add.error.portUnavailable": "The port is unavailable or already in use. Choose another port and try again.", + "mcpTools.add.error.dockerUnavailable": "Docker is unavailable. Make sure Docker is running and try again.", "mcpTools.add.error.nameExists": "An MCP service with this name already exists. Please use a different name.", "mcpTools.addModal.title": "Add MCP Service", "mcpTools.addModal.tabLocal": "Local", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 9ab2e7693c..a2a6abf08e 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -2448,7 +2448,14 @@ "mcpTools.add.error.containerJsonInvalid": "容器配置 JSON 格式不正确", "mcpTools.add.error.containerJsonMissingServers": "容器配置必须包含 mcpServers 对象", "mcpTools.add.error.containerAddFailed": "容器配置添加失败", - "mcpTools.add.error.connectionFailed": "MCP 服务器连接失败,请检查服务器地址后重试", + "mcpTools.add.error.connectionFailed": "MCP 服务连接失败,请确认服务地址、鉴权配置及服务状态后重试", + "mcpTools.add.error.connectionRefused": "MCP 服务端口拒绝连接,请确认服务已启动且端口正确", + "mcpTools.add.error.connectionTimeout": "连接 MCP 服务超时,请确认网络连通性和服务响应状态", + "mcpTools.add.error.connectionUnreachable": "无法访问 MCP 服务地址,请检查域名、网络或主机地址", + "mcpTools.add.error.authenticationFailed": "MCP 服务鉴权失败,请检查 Bearer Token 或自定义 Headers", + "mcpTools.add.error.protocolOrPathInvalid": "MCP 协议或路径不匹配,请确认地址以 /sse 或 /mcp 等正确端点结尾", + "mcpTools.add.error.portUnavailable": "端口不可用或已被占用,请更换端口后重试", + "mcpTools.add.error.dockerUnavailable": "Docker 服务不可用,请确认 Docker 已启动后重试", "mcpTools.add.error.nameExists": "已存在同名 MCP 服务,请更换名称", "mcpTools.addModal.title": "添加 MCP 服务", "mcpTools.addModal.tabLocal": "自定义", diff --git a/test/backend/app/test_remote_mcp_app.py b/test/backend/app/test_remote_mcp_app.py index a9dbd4303c..5b675064d3 100644 --- a/test/backend/app/test_remote_mcp_app.py +++ b/test/backend/app/test_remote_mcp_app.py @@ -18,6 +18,10 @@ boto3_module.resource = MagicMock() boto3_module.__spec__ = importlib.machinery.ModuleSpec("boto3", loader=None) sys.modules['boto3'] = boto3_module +elasticsearch_module = types.ModuleType("elasticsearch") +elasticsearch_module.Elasticsearch = MagicMock() +elasticsearch_module.__spec__ = importlib.machinery.ModuleSpec("elasticsearch", loader=None) +sys.modules['elasticsearch'] = elasticsearch_module # Patch storage factory and MinIO config validation to avoid errors during initialization # These patches must be started before any imports that use MinioClient @@ -158,6 +162,17 @@ def test_add_validation_error(self, mock_add, mock_auth): }, headers=AUTH_HEADER) assert resp.status_code == HTTPStatus.BAD_REQUEST + @patch('apps.remote_mcp_app.get_current_user_info') + @patch('apps.remote_mcp_app.add_mcp_service') + def test_add_connection_error_returns_normalized_detail(self, mock_add, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_add.side_effect = MCPConnectionError("MCP connection timeout") + resp = client.post("/mcp/add", json={ + "name": "x", "source": "local", "server_url": "http://srv", + }, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.SERVICE_UNAVAILABLE + assert resp.json()["detail"] == "MCP connection timeout" + @patch('apps.remote_mcp_app.get_current_user_info') @patch('apps.remote_mcp_app.add_mcp_service') def test_add_with_custom_headers(self, mock_add, mock_auth): @@ -226,6 +241,18 @@ def test_add_from_config_name_conflict(self, mock_add, mock_auth): }, headers=AUTH_HEADER) assert resp.status_code == HTTPStatus.CONFLICT + @patch('apps.remote_mcp_app.get_current_user_info') + @patch('apps.remote_mcp_app.add_container_mcp_service') + def test_add_from_config_connection_error_returns_normalized_detail(self, mock_add, mock_auth): + mock_auth.return_value = ("uid", "tid", "en") + mock_add.side_effect = MCPConnectionError("MCP protocol or endpoint invalid") + resp = client.post("/mcp/add-from-config", json={ + "name": "svc", "source": "local", "port": 8080, + "mcp_config": {"mcpServers": {"svc": {"command": "echo"}}}, + }, headers=AUTH_HEADER) + assert resp.status_code == HTTPStatus.SERVICE_UNAVAILABLE + assert resp.json()["detail"] == "MCP protocol or endpoint invalid" + # ============================================================================ # PUT /mcp/update diff --git a/test/backend/services/test_remote_mcp_service.py b/test/backend/services/test_remote_mcp_service.py index 69d21f1362..353c2f78ab 100644 --- a/test/backend/services/test_remote_mcp_service.py +++ b/test/backend/services/test_remote_mcp_service.py @@ -20,6 +20,10 @@ boto3_module.resource = MagicMock() boto3_module.__spec__ = importlib.machinery.ModuleSpec("boto3", loader=None) sys.modules['boto3'] = boto3_module +elasticsearch_module = types.ModuleType("elasticsearch") +elasticsearch_module.Elasticsearch = MagicMock() +elasticsearch_module.__spec__ = importlib.machinery.ModuleSpec("elasticsearch", loader=None) +sys.modules['elasticsearch'] = elasticsearch_module # Pre-mock nexent module hierarchy to prevent deep SDK import chain nexent_mod = types.ModuleType("nexent") nexent_mod.__path__ = [] @@ -133,6 +137,8 @@ upload_and_start_mcp_image, attach_mcp_container_permissions, refresh_mcp_service_tool_count, + _format_mcp_connection_error, + _mcp_protocol_health_check, ) # Patch exception classes to ensure tests use correct exceptions import backend.services.remote_mcp_service as remote_service @@ -167,6 +173,73 @@ def __init__( self.custom_headers = custom_headers +# ============================================================================ +# MCP connection error normalization +# ============================================================================ + +class TestMcpConnectionErrorFormatting(unittest.IsolatedAsyncioTestCase): + """Test user-facing MCP connection error categories.""" + + def test_timeout_error_is_normalized(self): + result = _format_mcp_connection_error(TimeoutError("request timed out after 10s")) + self.assertEqual(result, "MCP connection timeout") + + def test_empty_timeout_error_is_normalized_by_type(self): + result = _format_mcp_connection_error(TimeoutError()) + self.assertEqual(result, "MCP connection timeout") + + def test_chained_timeout_error_is_normalized(self): + error = RuntimeError("Client failed to connect: All connection attempts failed") + error.__cause__ = TimeoutError() + + result = _format_mcp_connection_error(error) + + self.assertEqual(result, "MCP connection timeout") + + def test_refused_error_is_normalized(self): + result = _format_mcp_connection_error(ConnectionError("Connection refused by host")) + self.assertEqual(result, "MCP connection refused") + + def test_auth_error_is_normalized(self): + result = _format_mcp_connection_error(Exception("HTTP 401 Unauthorized")) + self.assertEqual(result, "MCP authentication failed") + + def test_endpoint_error_is_normalized(self): + result = _format_mcp_connection_error(Exception("404 endpoint not found")) + self.assertEqual(result, "MCP endpoint not found") + + def test_protocol_error_is_normalized(self): + result = _format_mcp_connection_error(Exception("server does not support MCP protocol")) + self.assertEqual(result, "MCP protocol or endpoint invalid") + + def test_dns_error_is_normalized(self): + result = _format_mcp_connection_error(Exception("getaddrinfo ENOTFOUND example.invalid")) + self.assertEqual(result, "MCP address unreachable") + + def test_unknown_error_uses_safe_fallback(self): + result = _format_mcp_connection_error(Exception("fastmcp internal stack detail")) + self.assertEqual(result, "MCP connection failed") + + async def test_connection_handshake_timeout_is_normalized(self): + class SlowConnectClient: + async def __aenter__(self): + await asyncio.sleep(0.05) + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def list_tools(self): + return [] + + with patch("backend.services.remote_mcp_service.Client", return_value=SlowConnectClient()), \ + patch("backend.services.remote_mcp_service.MCP_HEALTH_CHECK_TIMEOUT_SECONDS", 0.001): + with self.assertRaises(MCPConnectionError) as context: + await _mcp_protocol_health_check("http://example.com/mcp", {}) + + self.assertEqual(str(context.exception), "MCP connection timeout") + + # ============================================================================ # mcp_server_health - custom_headers tests (lines 50-58) # ============================================================================ @@ -256,6 +329,18 @@ async def test_health_timeout_raises_mcp_connection_error(self, mock_client_cls) with self.assertRaises(MCPConnectionError): await mcp_server_health('https://test-server', custom_headers={"X-Test": "value"}) + @patch('backend.services.remote_mcp_service.Client') + async def test_health_exception_uses_normalized_error_message(self, mock_client_cls): + """Raw SDK errors are converted to safe connection categories.""" + from unittest.mock import AsyncMock + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.list_tools = AsyncMock(side_effect=Exception("HTTP 401 Unauthorized: token rejected")) + mock_client_cls.return_value = mock_client + + with self.assertRaisesRegex(MCPConnectionError, "MCP authentication failed"): + await mcp_server_health('https://test-server', custom_headers={"X-Test": "value"}) + @patch('backend.services.remote_mcp_service.Client') async def test_health_timeout_error_raises_mcp_connection_error(self, mock_client_cls): """Test that TimeoutError raises MCPConnectionError.""" @@ -852,7 +937,7 @@ async def test_health_without_custom_headers(self, mock_get, mock_health, mock_s class TestListMcpServiceToolsByIdCustomHeaders(unittest.IsolatedAsyncioTestCase): """Test list_mcp_service_tools_by_id uses custom_headers from record.""" - @patch('services.tool_configuration_service.get_tool_from_remote_mcp_server') + @patch('services.tool_configuration_service.get_tool_from_remote_mcp_server', new_callable=AsyncMock) @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') async def test_tools_with_custom_headers(self, mock_get, mock_get_tools): """Test list_mcp_service_tools_by_id passes custom_headers to tool retrieval.""" @@ -877,7 +962,7 @@ async def test_tools_with_custom_headers(self, mock_get, mock_get_tools): custom_headers={"X-Tools-Custom": "tools-value"}, ) - @patch('services.tool_configuration_service.get_tool_from_remote_mcp_server') + @patch('services.tool_configuration_service.get_tool_from_remote_mcp_server', new_callable=AsyncMock) @patch('backend.services.remote_mcp_service.get_mcp_record_by_id_and_tenant') async def test_tools_without_custom_headers(self, mock_get, mock_get_tools): """Test list_mcp_service_tools_by_id when custom_headers is None.""" From 44d1caf641613bd96ebb5e809f1870a428ea7ec0 Mon Sep 17 00:00:00 2001 From: Jinyu Z <1012871848@qq.com> Date: Mon, 20 Jul 2026 09:57:52 +0800 Subject: [PATCH 006/126] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20remove?= =?UTF-8?q?=20unused=20AppConfigSection=20and=20refit=20model=20config=20a?= =?UTF-8?q?ction=20buttons=20(#3438)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[locale]/models/ModelConfiguration.tsx | 46 +- .../[locale]/models/components/appConfig.tsx | 566 ------------------ .../models/components/modelConfig.tsx | 120 ++-- 3 files changed, 56 insertions(+), 676 deletions(-) delete mode 100644 frontend/app/[locale]/models/components/appConfig.tsx diff --git a/frontend/app/[locale]/models/ModelConfiguration.tsx b/frontend/app/[locale]/models/ModelConfiguration.tsx index 0fb82db310..f9948aebd6 100644 --- a/frontend/app/[locale]/models/ModelConfiguration.tsx +++ b/frontend/app/[locale]/models/ModelConfiguration.tsx @@ -11,11 +11,7 @@ import { CARD_HEADER, } from "@/const/layoutConstants"; -import { AppConfigSection } from "./components/appConfig"; -import { - ModelConfigSection, - ModelConfigSectionRef, -} from "./components/modelConfig"; +import { ModelConfigSection, ModelConfigSectionRef } from "./components/modelConfig"; const { Title } = Typography; @@ -65,46 +61,12 @@ export default function AppModelConfig({ {isClientSide ? (
- -
-
- {t("setup.config.appSettings")} -
-
-
- -
-
- -
import("antd/es/modal"), { ssr: false }); - -export const AppConfigSection: React.FC = () => { - const { t } = useTranslation(); - const { message } = App.useApp(); - const { appConfig, updateAppConfig, getAppAvatarUrl, saveConfig } = - useConfig(); - - // Add local state management for input values - const [localAppName, setLocalAppName] = useState(appConfig.appName); - const [localAppDescription, setLocalAppDescription] = useState( - appConfig.appDescription - ); - - // Add error state management - const [appNameError, setAppNameError] = useState(false); - - // Add user input state tracking - const isUserTypingAppName = useRef(false); - const isUserTypingDescription = useRef(false); - - // Avatar-related state - const [isAvatarModalOpen, setIsAvatarModalOpen] = useState(false); - const [selectedIconKey, setSelectedIconKey] = useState( - appConfig.iconKey || presetIcons[0].key - ); - const [tempIconKey, setTempIconKey] = useState( - appConfig.iconKey || presetIcons[0].key - ); - const [tempColor, setTempColor] = useState("#2689cb"); - const [avatarType, setAvatarType] = useState< - (typeof ICON_TYPES)[keyof typeof ICON_TYPES] - >(appConfig.iconType); - const [tempAvatarType, setTempAvatarType] = useState< - (typeof ICON_TYPES)[keyof typeof ICON_TYPES] - >(appConfig.iconType); - const [customAvatarUrl, setCustomAvatarUrl] = useState( - appConfig.customIconUrl - ); - const [tempCustomAvatarUrl, setTempCustomAvatarUrl] = useState( - appConfig.customIconUrl - ); - - // Get current avatar URL - const avatarUrl = getAppAvatarUrl(60); - - const fileInputRef = useRef(null); - - const triggerAutoSave = useCallback(() => { - const runSave = async () => { - const ok = await saveConfig(); - if (!ok) { - message.error(t("setup.page.error.saveConfig")); - } - }; - - void runSave(); - }, [saveConfig, message, t]); - - // Add configuration change listener, synchronize local state when config is loaded from backend - useEffect(() => { - const handleConfigChanged = (event: any) => { - const { config } = event.detail; - if (config?.app) { - // Only update state when user is not currently typing - if (!isUserTypingAppName.current) { - setLocalAppName(config.app.appName || ""); - } - if (!isUserTypingDescription.current) { - setLocalAppDescription(config.app.appDescription || ""); - } - setAvatarType(config.app.iconType || ICON_TYPES.PRESET); - setCustomAvatarUrl(config.app.customIconUrl || null); - - // Reset error state - if (config.app.appName && config.app.appName.trim()) { - setAppNameError(false); - } - } - }; - - window.addEventListener("configChanged", handleConfigChanged); - return () => { - window.removeEventListener("configChanged", handleConfigChanged); - }; - }, []); - - // Listen for appConfig changes, synchronize local state - useEffect(() => { - // Only update state when user is not currently typing - if (!isUserTypingAppName.current) { - setLocalAppName(appConfig.appName); - } - if (!isUserTypingDescription.current) { - setLocalAppDescription(appConfig.appDescription); - } - setAvatarType(appConfig.iconType); - setCustomAvatarUrl(appConfig.customIconUrl); - setSelectedIconKey(appConfig.iconKey || presetIcons[0].key); - }, [ - appConfig.appName, - appConfig.appDescription, - appConfig.iconType, - appConfig.customIconUrl, - appConfig.iconKey, - ]); - - // Listen for highlight missing field events - useEffect(() => { - const handleHighlightMissingField = (event: any) => { - const { field } = event.detail; - if (field === "appName") { - setAppNameError(true); - // Scroll to app name input field - const appNameInput = document.querySelector(".app-name-input"); - if (appNameInput) { - appNameInput.scrollIntoView({ behavior: "smooth", block: "center" }); - } - } - }; - - window.addEventListener( - "highlightMissingField", - handleHighlightMissingField - ); - return () => { - window.removeEventListener( - "highlightMissingField", - handleHighlightMissingField - ); - }; - }, []); - - // Handle basic app config changes - const handleAppNameChange = (e: React.ChangeEvent) => { - const newAppName = e.target.value; - isUserTypingAppName.current = true; - setLocalAppName(newAppName); - - // If value is entered, clear error state - if (newAppName.trim()) { - setAppNameError(false); - } - - }; - - const handleAppNameBlur = () => { - updateAppConfig({ appName: localAppName }); - isUserTypingAppName.current = false; - triggerAutoSave(); - }; - - const handleDescriptionChange = ( - e: React.ChangeEvent - ) => { - const newDescription = e.target.value; - isUserTypingDescription.current = true; - setLocalAppDescription(newDescription); - }; - - const handleDescriptionBlur = () => { - updateAppConfig({ appDescription: localAppDescription }); - isUserTypingDescription.current = false; - triggerAutoSave(); - }; - - // Open avatar selection modal - const handleAvatarClick = () => { - setTempIconKey(selectedIconKey); - setTempAvatarType(avatarType); - setTempCustomAvatarUrl(customAvatarUrl); - setIsAvatarModalOpen(true); - }; - - // Handle icon selection - const handleIconSelect = (iconKey: string) => { - setTempIconKey(iconKey); - setTempAvatarType(ICON_TYPES.PRESET); - }; - - // Handle color selection - const handleColorSelect = (color: string) => { - setTempColor(color); - }; - - // Handle custom image upload - const handleFileUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - if (!file.type.startsWith("image/")) { - message.error(t("appConfig.upload.imageOnly")); - return; - } - - if (file.size > 2 * 1024 * 1024) { - message.error(t("appConfig.upload.sizeLimit")); - return; - } - - const reader = new FileReader(); - reader.onload = (event) => { - if (event.target?.result) { - setTempCustomAvatarUrl(event.target.result as string); - setTempAvatarType(ICON_TYPES.CUSTOM); - } - }; - reader.readAsDataURL(file); - } - // Clear the input value to allow re-selecting the same file - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - }; - - // Trigger file selection dialog - const triggerFileUpload = () => { - if (fileInputRef.current) { - // Reset value so selecting the same file triggers onChange - fileInputRef.current.value = ""; - fileInputRef.current.click(); - } - }; - - // Confirm avatar selection - const confirmAvatarSelection = async () => { - try { - setSelectedIconKey(tempIconKey); - setAvatarType(tempAvatarType); - setCustomAvatarUrl( - tempAvatarType === ICON_TYPES.CUSTOM ? tempCustomAvatarUrl : null - ); - setIsAvatarModalOpen(false); - - if (tempAvatarType === ICON_TYPES.PRESET) { - // Generate avatar URI and save - const avatarUri = generateAvatarUri(tempIconKey, tempColor); - - updateAppConfig({ - iconType: ICON_TYPES.PRESET, - iconKey: tempIconKey, - customIconUrl: null, - avatarUri: avatarUri, - }); - } else { - updateAppConfig({ - iconType: ICON_TYPES.CUSTOM, - customIconUrl: tempCustomAvatarUrl || null, - avatarUri: null, - }); - } - - const ok = await saveConfig(); - if (!ok) { - message.error(t("setup.page.error.saveConfig")); - } - } catch (error) { - message.error(t("appConfig.icon.saveError")); - log.error(t("appConfig.icon.saveErrorLog"), error); - } - }; - - // Cancel avatar selection - const cancelAvatarSelection = () => { - setIsAvatarModalOpen(false); - setTempCustomAvatarUrl(customAvatarUrl); - }; - - return ( -
- - - - - -
-
-
- {appConfig.appName} -
-
- -
-
-
-
-
- - {t("appConfig.appName.label")} - -
- -
-
-
- - {t("appConfig.description.label")} - -
-