From 8987e911f71613e7261c8894237162dc8385f4e7 Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Thu, 14 May 2026 09:58:24 +0800 Subject: [PATCH 01/25] =?UTF-8?q?=E8=A7=A3=E5=86=B3=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/agents/create_agent_info.py | 9 +- backend/consts/model.py | 3 + backend/database/db_models.py | 2 + backend/database/model_management_db.py | 55 +++++++++ .../conversation_management_service.py | 5 +- backend/services/file_management_service.py | 2 + backend/services/model_health_service.py | 13 ++- backend/services/model_management_service.py | 28 ++++- backend/services/model_provider_service.py | 7 +- backend/utils/llm_utils.py | 3 + ..._add_timeout_seconds_to_model_record_t.sql | 10 ++ .../components/model/ModelAddDialog.tsx | 106 +++++++++++++++++- .../components/model/ModelDeleteDialog.tsx | 57 +++++++++- .../components/model/ModelEditDialog.tsx | 47 +++++++- frontend/public/locales/en/common.json | 1 + frontend/public/locales/zh/common.json | 1 + frontend/services/modelService.ts | 14 +++ frontend/types/modelConfig.ts | 1 + sdk/nexent/core/agents/agent_model.py | 4 + sdk/nexent/core/agents/nexent_agent.py | 1 + sdk/nexent/core/models/openai_llm.py | 37 ++++-- 21 files changed, 371 insertions(+), 35 deletions(-) create mode 100644 docker/sql/v2.0.5_0507_add_timeout_seconds_to_model_record_t.sql diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index 5a11b550b..90509c8f5 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -247,7 +247,8 @@ async def create_model_config_list(tenant_id): ), url=record["base_url"], ssl_verify=record.get("ssl_verify", True), - model_factory=record.get("model_factory"))) + model_factory=record.get("model_factory"), + timeout_seconds=record.get("timeout_seconds"))) # fit for old version, main_model and sub_model use default model main_model_config = tenant_config_manager.get_model_config( key=MODEL_CONFIG_MAPPING["llm"], tenant_id=tenant_id) @@ -258,7 +259,8 @@ async def create_model_config_list(tenant_id): "model_name") else "", url=main_model_config.get("base_url", ""), ssl_verify=main_model_config.get("ssl_verify", True), - model_factory=main_model_config.get("model_factory"))) + model_factory=main_model_config.get("model_factory"), + timeout_seconds=main_model_config.get("timeout_seconds"))) model_list.append( ModelConfig(cite_name="sub_model", api_key=main_model_config.get("api_key", ""), @@ -266,7 +268,8 @@ async def create_model_config_list(tenant_id): "model_name") else "", url=main_model_config.get("base_url", ""), ssl_verify=main_model_config.get("ssl_verify", True), - model_factory=main_model_config.get("model_factory"))) + model_factory=main_model_config.get("model_factory"), + timeout_seconds=main_model_config.get("timeout_seconds"))) return model_list diff --git a/backend/consts/model.py b/backend/consts/model.py index bcaffcae7..bc32515fe 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -121,6 +121,7 @@ class ModelRequest(BaseModel): # STT specific fields model_appid: Optional[str] = None access_token: Optional[str] = None + timeout_seconds: Optional[int] = None class ProviderModelRequest(BaseModel): @@ -756,6 +757,7 @@ class ManageTenantModelCreateRequest(BaseModel): # STT specific fields model_appid: Optional[str] = Field(None, description="Application ID for STT models (e.g., Volcano Engine)") 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") class ManageTenantModelUpdateRequest(BaseModel): @@ -776,6 +778,7 @@ class ManageTenantModelUpdateRequest(BaseModel): # STT specific fields model_appid: Optional[str] = Field(None, description="Application ID for STT models") access_token: Optional[str] = Field(None, description="Access token for STT models") + timeout_seconds: Optional[int] = Field(None, description="Request timeout in seconds") class ManageTenantModelDeleteRequest(BaseModel): diff --git a/backend/database/db_models.py b/backend/database/db_models.py index baa8e903e..94f5be80b 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -182,6 +182,8 @@ class ModelRecord(TableBase): String(100), doc="Application ID for model authentication (used by some STT/TTS providers like Volcano Engine)") access_token = Column( String(100), doc="Access token for model authentication (used by some STT/TTS providers like Volcano Engine)") + timeout_seconds = Column( + Integer, doc="Request timeout in seconds for this model. Default is 120 seconds.") class ModelMonitoringRecord(SimpleTableBase): diff --git a/backend/database/model_management_db.py b/backend/database/model_management_db.py index cb1c6c69f..7838315b8 100644 --- a/backend/database/model_management_db.py +++ b/backend/database/model_management_db.py @@ -1,3 +1,4 @@ +import logging from typing import Any, Dict, List, Optional from sqlalchemy import and_, desc, func, insert, select, update @@ -7,6 +8,8 @@ from .db_models import ModelRecord from .utils import add_creation_tracking, add_update_tracking +logger = logging.getLogger("database.model_management_db") + def create_model_record(model_data: Dict[str, Any], user_id: str, tenant_id: str) -> bool: """ @@ -84,6 +87,58 @@ def update_model_record( return result.rowcount > 0 +def update_model_record_by_model_name( + model_name: str, + update_data: Dict[str, Any], + user_id: Optional[str] = None, + tenant_id: Optional[str] = None, + model_repo: Optional[str] = None +) -> bool: + """ + Update a model record by model_name and tenant_id. + + Args: + model_name: Model name (display name, not the primary key) + update_data: Dictionary containing update data + user_id: Reserved parameter for filling updated_by field + tenant_id: Tenant ID for filtering + model_repo: Optional model repo for more precise matching + + Returns: + bool: Whether the operation was successful + """ + import logging + db_logger = logging.getLogger("database.client") + + with get_db_session() as session: + # Data cleaning + cleaned_data = db_client.clean_string_values(update_data) + + # Add update timestamp + cleaned_data["update_time"] = func.current_timestamp() + if user_id: + cleaned_data = add_update_tracking(cleaned_data, user_id) + + db_logger.info(f"update_model_record_by_model_name: model_name={model_name}, model_repo={model_repo}, tenant_id={tenant_id}, cleaned_data={cleaned_data}") + + # Build conditions list + conditions = [ + ModelRecord.model_name == model_name, + ModelRecord.tenant_id == tenant_id + ] + if model_repo: + conditions.append(ModelRecord.model_repo == model_repo) + + # Build the update statement + stmt = update(ModelRecord).where(*conditions).values(cleaned_data) + + # Execute the update statement + result = session.execute(stmt) + db_logger.info(f"update_model_record_by_model_name: rowcount={result.rowcount}") + + return result.rowcount > 0 + + def delete_model_record(model_id: int, user_id: str, tenant_id: str) -> bool: """ Delete a model record (soft delete) and update the update timestamp diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index d5d4a85a4..c3571fcf3 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -248,6 +248,8 @@ def call_llm_for_title(question: str, tenant_id: str, language: str = LANGUAGE[" display_name = model_config.get("display_name", "") if model_config else "" set_monitoring_operation("title_generation", display_name=display_name or None) + timeout_seconds = model_config.get("timeout_seconds") if model_config else None + # Create OpenAIModel instance llm = OpenAIModel( model_id=get_model_name_from_config(model_config) if model_config.get("model_name") else "", @@ -256,7 +258,8 @@ def call_llm_for_title(question: str, tenant_id: str, language: str = LANGUAGE[" temperature=0.7, top_p=0.95, model_factory=model_config.get("model_factory", None), - ssl_verify=model_config.get("ssl_verify", True) + ssl_verify=model_config.get("ssl_verify", True), + timeout_seconds=timeout_seconds, ) # Build messages - use new template variable 'question' instead of 'content' diff --git a/backend/services/file_management_service.py b/backend/services/file_management_service.py index b5cd048bf..7dad75a0a 100644 --- a/backend/services/file_management_service.py +++ b/backend/services/file_management_service.py @@ -352,6 +352,7 @@ def get_llm_model(tenant_id: str): # Get the tenant config main_model_config = tenant_config_manager.get_model_config( key=MODEL_CONFIG_MAPPING["llm"], tenant_id=tenant_id) + timeout_seconds = main_model_config.get("timeout_seconds") if main_model_config else None long_text_to_text_model = OpenAILongContextModel( observer=MessageObserver(), model_id=get_model_name_from_config(main_model_config), @@ -359,6 +360,7 @@ def get_llm_model(tenant_id: str): api_key=main_model_config.get("api_key"), max_context_tokens=main_model_config.get("max_tokens"), ssl_verify=main_model_config.get("ssl_verify", True), + timeout_seconds=timeout_seconds, ) return long_text_to_text_model diff --git a/backend/services/model_health_service.py b/backend/services/model_health_service.py index a20b2a6ca..b6dac2d04 100644 --- a/backend/services/model_health_service.py +++ b/backend/services/model_health_service.py @@ -71,6 +71,7 @@ async def _perform_connectivity_check( model_appid: Optional[str] = None, access_token: Optional[str] = None, display_name: Optional[str] = None, + timeout_seconds: Optional[float] = None, ) -> bool: """ Perform specific model connectivity check @@ -80,6 +81,8 @@ async def _perform_connectivity_check( model_base_url: Model base URL model_api_key: API key ssl_verify: Whether to verify SSL certificates (default: True) + display_name: Optional display name for monitoring + timeout_seconds: Optional request timeout in seconds Returns: bool: Connectivity check result """ @@ -115,7 +118,8 @@ async def _perform_connectivity_check( model_id=model_name, api_base=model_base_url, api_key=model_api_key, - ssl_verify=ssl_verify + ssl_verify=ssl_verify, + timeout_seconds=timeout_seconds, ).check_connectivity() elif model_type == "rerank": rerank_model = OpenAICompatibleRerank( @@ -192,6 +196,7 @@ async def check_model_connectivity(display_name: str, tenant_id: str) -> dict: model_factory = model.get("model_factory") model_appid = model.get("model_appid") access_token = model.get("access_token") + timeout_seconds = model.get("timeout_seconds") try: set_monitoring_context(tenant_id=tenant_id) @@ -199,6 +204,8 @@ async def check_model_connectivity(display_name: str, tenant_id: str) -> dict: connectivity = await _perform_connectivity_check( model_name, model_type, model_base_url, model_api_key, ssl_verify, model_factory, model_appid, access_token,display_name=display_name, + display_name=display_name, + timeout_seconds=timeout_seconds, ) except Exception as e: update_data = { @@ -245,16 +252,20 @@ async def verify_model_config_connectivity(model_config: dict): model_factory = model_config.get("model_factory") model_appid = model_config.get("model_appid") access_token = model_config.get("access_token") + # Get timeout from model config if present + timeout_seconds = model_config.get("timeout_seconds") try: connectivity = await _perform_connectivity_check( model_name, model_type, model_base_url, model_api_key, ssl_verify, model_factory, model_appid, access_token + timeout_seconds=timeout_seconds, ) if not connectivity and ssl_verify: connectivity = await _perform_connectivity_check( model_name, model_type, model_base_url, model_api_key, False, model_factory, model_appid, access_token + timeout_seconds=timeout_seconds, ) if not connectivity: error_msg = f"Failed to connect to model '{model_name}' at {model_base_url}. Please verify the URL, API key, and network connection." diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index d012803be..64675d047 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -13,6 +13,7 @@ get_model_records, get_models_by_tenant_factory_type, update_model_record, + update_model_record_by_model_name, ) from services.model_provider_service import ( prepare_model_dict, @@ -276,12 +277,31 @@ async def update_single_model_for_tenant( async def batch_update_models_for_tenant(user_id: str, tenant_id: str, model_list: List[Dict[str, Any]]): - """Batch update models for a tenant.""" + """Batch update models for a tenant by model_id or model_name.""" try: for model in model_list: - update_model_record(model["model_id"], model, user_id, tenant_id) - - logging.debug("Batch update models successfully") + # Build update data excluding id fields + update_data = {k: v for k, v in model.items() if k not in ["model_id", "model_name"]} + + model_id_or_name = model.get("model_id") or model.get("model_name") + + # Check if model_id is a numeric string (primary key) + if model_id_or_name and model_id_or_name.isdigit(): + # Use model_id (primary key) for update + logging.info(f"[DEBUG] Updating model by id: model_id={model_id_or_name}, tenant_id={tenant_id}, update_data={update_data}") + update_model_record(int(model_id_or_name), update_data, user_id, tenant_id) + else: + # Parse "model_repo/model_name" format from frontend's model_id field + if "/" in model_id_or_name: + model_repo, model_name = model_id_or_name.split("/", 1) + else: + model_repo = None + model_name = model_id_or_name + + logging.info(f"[DEBUG] Updating model by name: model_name={model_name}, model_repo={model_repo}, tenant_id={tenant_id}, update_data={update_data}") + update_model_record_by_model_name(model_name, update_data, user_id, tenant_id, model_repo) + + logging.info("[DEBUG] Batch update models successfully") except Exception as e: logging.error(f"Failed to batch update models: {str(e)}") raise Exception(f"Failed to batch update models: {str(e)}") diff --git a/backend/services/model_provider_service.py b/backend/services/model_provider_service.py index dbff17082..6fc729a39 100644 --- a/backend/services/model_provider_service.py +++ b/backend/services/model_provider_service.py @@ -100,11 +100,13 @@ async def prepare_model_dict(provider: str, model: dict, model_url: str, model_a # Build the canonical representation using the existing Pydantic schema for # consistency of validation and default handling. # For embedding/multi_embedding models, max_tokens will be set via connectivity check later, - # so use 0 as placeholder if not provided + # so use 0 as placeholder if not provided. + # Set default timeout_seconds to 120 for LLM models (embedding models don't need it). model_type = model["model_type"] is_embedding_type = model_type in ["embedding", "multi_embedding"] max_tokens_value = model.get( "max_tokens", 0) if not is_embedding_type else 0 + timeout_seconds_value = 120 if not is_embedding_type else None model_obj = ModelRequest( model_factory=provider, @@ -115,7 +117,8 @@ async def prepare_model_dict(provider: str, model: dict, model_url: str, model_a display_name=model_display_name, expected_chunk_size=expected_chunk_size, maximum_chunk_size=maximum_chunk_size, - chunk_batch=chunk_batch + chunk_batch=chunk_batch, + timeout_seconds=timeout_seconds_value ) model_dict = model_obj.model_dump() diff --git a/backend/utils/llm_utils.py b/backend/utils/llm_utils.py index e99b9f384..53c23aa7b 100644 --- a/backend/utils/llm_utils.py +++ b/backend/utils/llm_utils.py @@ -73,6 +73,8 @@ def call_llm_for_system_prompt( set_monitoring_operation("system_prompt_generation", display_name=display_name or None) + timeout_seconds = llm_model_config.get("timeout_seconds") if llm_model_config else None + llm = OpenAIModel( model_id=get_model_name_from_config(llm_model_config) if llm_model_config else "", api_base=llm_model_config.get("base_url", "") if llm_model_config else "", @@ -82,6 +84,7 @@ def call_llm_for_system_prompt( model_factory=llm_model_config.get("model_factory") if llm_model_config else None, ssl_verify=llm_model_config.get("ssl_verify", True) if llm_model_config else True, display_name=display_name or None, + timeout_seconds=timeout_seconds, ) messages = [ {"role": MESSAGE_ROLE["SYSTEM"], "content": system_prompt}, diff --git a/docker/sql/v2.0.5_0507_add_timeout_seconds_to_model_record_t.sql b/docker/sql/v2.0.5_0507_add_timeout_seconds_to_model_record_t.sql new file mode 100644 index 000000000..6c0ef24db --- /dev/null +++ b/docker/sql/v2.0.5_0507_add_timeout_seconds_to_model_record_t.sql @@ -0,0 +1,10 @@ +-- Migration: Add timeout_seconds column to model_record_t table +-- Date: 2026-05-07 +-- Description: Add timeout_seconds field to control request timeout per model + +-- Add timeout_seconds column to model_record_t table +ALTER TABLE nexent.model_record_t +ADD COLUMN IF NOT EXISTS timeout_seconds INTEGER DEFAULT 120; + +-- Add comment to the column +COMMENT ON COLUMN nexent.model_record_t.timeout_seconds IS 'Request timeout in seconds for this model. Default is 120 seconds.'; diff --git a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx index 11391c133..eee1ab277 100644 --- a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx @@ -50,6 +50,7 @@ const DEFAULT_FORM_STATE = { url: "", apiKey: "", maxTokens: "4096", + timeoutSeconds: "120", isMultimodal: false, isBatchImport: false, provider: "modelengine", @@ -252,6 +253,7 @@ export const ModelAddDialog = ({ const [selectedModelForSettings, setSelectedModelForSettings] = useState(null); const [modelMaxTokens, setModelMaxTokens] = useState("4096"); + const [modelTimeoutSeconds, setModelTimeoutSeconds] = useState("120"); // Use the silicon model list hook const siliconHook = useSiliconModelList({ @@ -639,23 +641,49 @@ export const ModelAddDialog = ({ const handleSettingsClick = (model: any) => { setSelectedModelForSettings(model); setModelMaxTokens(model.max_tokens?.toString() || "4096"); + setModelTimeoutSeconds(model.timeout_seconds?.toString() || "120"); setSettingsModalVisible(true); }; // Handle settings save - const handleSettingsSave = () => { - if (selectedModelForSettings) { - // Update the model in the list with new max_tokens + const handleSettingsSave = async () => { + if (!selectedModelForSettings) return; + + try { + // Use model_name as the identifier (API returns model_name field, id is combined format) + const modelName = selectedModelForSettings.model_name || selectedModelForSettings.id; + + // Call API to update model settings + await modelService.updateBatchModel( + [ + { + model_id: modelName, + apiKey: selectedModelForSettings.api_key || "", + maxTokens: parseInt(modelMaxTokens) || 4096, + timeoutSeconds: parseInt(modelTimeoutSeconds) || 120, + }, + ], + selectedModelForSettings.model_factory + ); + + // Update the model in the list with new max_tokens and timeout_seconds setModelList((prev) => prev.map((model) => model.id === selectedModelForSettings.id - ? { ...model, max_tokens: parseInt(modelMaxTokens) || 4096 } + ? { + ...model, + max_tokens: parseInt(modelMaxTokens) || 4096, + timeout_seconds: parseInt(modelTimeoutSeconds) || 120, + } : model ) ); + } catch (error) { + console.error("Failed to update model settings:", error); + } finally { + setSettingsModalVisible(false); + setSelectedModelForSettings(null); } - setSettingsModalVisible(false); - setSelectedModelForSettings(null); }; // Handle adding a model @@ -698,6 +726,7 @@ export const ModelAddDialog = ({ apiKey: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey, maxTokens: maxTokensValue, displayName: form.displayName || form.name, +<<<<<<< HEAD }; // Add STT specific fields @@ -717,6 +746,21 @@ export const ModelAddDialog = ({ } await modelService.createManageTenantModel(modelParams); +======= + expectedChunkSize: isEmbeddingModel + ? form.chunkSizeRange[0] + : undefined, + maximumChunkSize: isEmbeddingModel + ? form.chunkSizeRange[1] + : undefined, + chunkingBatchSize: isEmbeddingModel + ? parseInt(form.chunkingBatchSize) || 10 + : undefined, + timeoutSeconds: !isEmbeddingModel && !isRerankModel + ? parseInt(form.timeoutSeconds) || 120 + : undefined, + }); +>>>>>>> a64daaea1 (Feat: support user to configurate model timeout) } else { const modelParams: any = { name: form.name, @@ -725,6 +769,7 @@ export const ModelAddDialog = ({ apiKey: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey, maxTokens: maxTokensValue, displayName: form.displayName || form.name, +<<<<<<< HEAD }; // Add STT specific fields @@ -744,6 +789,23 @@ export const ModelAddDialog = ({ } await modelService.addCustomModel(modelParams); +======= + // Send chunk size range for embedding models + ...(isEmbeddingModel + ? { + expectedChunkSize: form.chunkSizeRange[0], + maximumChunkSize: form.chunkSizeRange[1], + chunkingBatchSize: parseInt(form.chunkingBatchSize) || 10, + } + : {}), + // Send timeout for non-embedding models + ...(!isEmbeddingModel && !isRerankModel + ? { + timeoutSeconds: parseInt(form.timeoutSeconds) || 120, + } + : {}), + }); +>>>>>>> a64daaea1 (Feat: support user to configurate model timeout) } // Create the model configuration object @@ -1190,6 +1252,26 @@ export const ModelAddDialog = ({ )} + {/* Timeout Seconds */} + {!isEmbeddingModel && !isRerankModel && !form.isBatchImport && ( +
+ + handleFormChange("timeoutSeconds", e.target.value)} + /> +
+ )} + {/* Connectivity verification area */} {!form.isBatchImport && (
@@ -1713,6 +1795,18 @@ export const ModelAddDialog = ({ placeholder={t("model.dialog.placeholder.maxTokens")} />
+
+ + setModelTimeoutSeconds(e.target.value)} + placeholder="120" + /> +
diff --git a/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx b/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx index ad3cf0391..f58ca242e 100644 --- a/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx @@ -57,6 +57,7 @@ export const ModelDeleteDialog = ({ const [selectedModelForSettings, setSelectedModelForSettings] = useState(null); const [modelMaxTokens, setModelMaxTokens] = useState("4096"); + const [modelTimeoutSeconds, setModelTimeoutSeconds] = useState("120"); const [providerModelSearchTerm, setProviderModelSearchTerm] = useState(""); // Embedding model chunk config modal state @@ -589,9 +590,11 @@ export const ModelDeleteDialog = ({ const handleProviderConfigSave = async ({ apiKey, maxTokens, + timeoutSeconds, }: { apiKey: string; maxTokens: number; + timeoutSeconds?: number; }) => { setMaxTokens(maxTokens); if ( @@ -624,6 +627,7 @@ export const ModelDeleteDialog = ({ model_id: String(m.id), apiKey: apiKey || m.apiKey, maxTokens: maxTokens || m.maxTokens, + ...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}), })); await modelService.updateBatchModel( @@ -653,23 +657,52 @@ export const ModelDeleteDialog = ({ const handleSettingsClick = (model: any) => { setSelectedModelForSettings(model); setModelMaxTokens(model.max_tokens?.toString() || "4096"); + setModelTimeoutSeconds(model.timeout_seconds?.toString() || "120"); setSettingsModalVisible(true); }; // Handle settings save - const handleSettingsSave = () => { - if (selectedModelForSettings) { - // Update the model in the list with new max_tokens + const handleSettingsSave = async () => { + if (!selectedModelForSettings) return; + + try { + // Use model_name as the identifier (API returns model_name field, id is combined format) + const modelName = selectedModelForSettings.model_name || selectedModelForSettings.id; + + // Call API to update model settings + await modelService.updateBatchModel( + [ + { + model_id: modelName, + apiKey: selectedModelForSettings.api_key || "", + maxTokens: parseInt(modelMaxTokens) || 4096, + timeoutSeconds: parseInt(modelTimeoutSeconds) || 120, + }, + ], + selectedModelForSettings.model_factory + ); + + // Update the model in the list with new max_tokens and timeout_seconds setProviderModels((prev) => prev.map((model) => model.id === selectedModelForSettings.id - ? { ...model, max_tokens: parseInt(modelMaxTokens) || 4096 } + ? { + ...model, + max_tokens: parseInt(modelMaxTokens) || 4096, + timeout_seconds: parseInt(modelTimeoutSeconds) || 120, + } : model ) ); + + message.success(t("model.message.updateSuccess") || "Update successful"); + } catch (error) { + console.error("Failed to update model settings:", error); + message.error(t("model.message.updateFailed") || "Failed to update settings"); + } finally { + setSettingsModalVisible(false); + setSelectedModelForSettings(null); } - setSettingsModalVisible(false); - setSelectedModelForSettings(null); }; // Handle embedding model click to open config modal @@ -1542,6 +1575,18 @@ export const ModelDeleteDialog = ({ placeholder={t("model.dialog.placeholder.maxTokens")} /> +
+ + setModelTimeoutSeconds(e.target.value)} + placeholder="120" + /> +
diff --git a/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx b/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx index 3114c5535..a784258df 100644 --- a/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx @@ -39,6 +39,7 @@ export const ModelEditDialog = ({ url: "", apiKey: "", maxTokens: "4096", + timeoutSeconds: "120", vectorDimension: "1024", chunkSizeRange: [ DEFAULT_EXPECTED_CHUNK_SIZE, @@ -65,6 +66,7 @@ export const ModelEditDialog = ({ url: model.apiUrl || "", apiKey: model.apiKey || "", maxTokens: model.maxTokens?.toString() || "4096", + timeoutSeconds: model.timeoutSeconds?.toString() || "120", vectorDimension: model.maxTokens?.toString() || "1024", chunkSizeRange: [ model.expectedChunkSize || DEFAULT_EXPECTED_CHUNK_SIZE, @@ -78,7 +80,7 @@ export const ModelEditDialog = ({ const handleFormChange = (field: string, value: string) => { setForm((prev) => ({ ...prev, [field]: value })); // If the key configuration item changes, clear the verification status - if (["url", "apiKey", "maxTokens", "vectorDimension"].includes(field)) { + if (["url", "apiKey", "maxTokens", "timeoutSeconds", "vectorDimension"].includes(field)) { setConnectivityStatus({ status: null, message: "" }); } }; @@ -176,6 +178,7 @@ export const ModelEditDialog = ({ expectedChunkSize: isEmbeddingModel ? form.chunkSizeRange[0] : undefined, maximumChunkSize: isEmbeddingModel ? form.chunkSizeRange[1] : undefined, chunkingBatchSize: isEmbeddingModel ? parseInt(form.chunkingBatchSize) || 10 : undefined, + timeoutSeconds: !isEmbeddingModel && !isRerankModel ? parseInt(form.timeoutSeconds) || 120 : undefined, }); } else { await modelService.updateSingleModel({ @@ -196,6 +199,12 @@ export const ModelEditDialog = ({ chunkingBatchSize: parseInt(form.chunkingBatchSize) || 10, } : {}), + // Send timeout for non-embedding models + ...(!isEmbeddingModel && !isRerankModel + ? { + timeoutSeconds: parseInt(form.timeoutSeconds) || 120, + } + : {}), }); } @@ -303,6 +312,12 @@ export const ModelEditDialog = ({ value={form.maxTokens} onChange={(e) => handleFormChange("maxTokens", e.target.value)} /> + handleFormChange("timeoutSeconds", e.target.value)} + /> )} @@ -408,15 +423,17 @@ interface ProviderConfigEditDialogProps { isOpen: boolean initialApiKey?: string initialMaxTokens?: string + initialTimeoutSeconds?: string modelType?: ModelType onClose: () => void - onSave: (config: { apiKey: string; maxTokens: number }) => Promise | void + onSave: (config: { apiKey: string; maxTokens: number; timeoutSeconds?: number }) => Promise | void } export const ProviderConfigEditDialog = ({ isOpen, initialApiKey = '', initialMaxTokens = '4096', + initialTimeoutSeconds = '120', modelType, onClose, onSave, @@ -424,12 +441,14 @@ export const ProviderConfigEditDialog = ({ const { t } = useTranslation() const [apiKey, setApiKey] = useState(initialApiKey) const [maxTokens, setMaxTokens] = useState(initialMaxTokens) + const [timeoutSeconds, setTimeoutSeconds] = useState(initialTimeoutSeconds) const [saving, setSaving] = useState(false) useEffect(() => { setApiKey(initialApiKey) setMaxTokens(initialMaxTokens) - }, [initialApiKey, initialMaxTokens]) + setTimeoutSeconds(initialTimeoutSeconds) + }, [initialApiKey, initialMaxTokens, initialTimeoutSeconds]) const valid = () => { const parsed = parseInt(maxTokens) @@ -440,7 +459,13 @@ export const ProviderConfigEditDialog = ({ if (!valid()) return try { setSaving(true) - await onSave({ apiKey: apiKey.trim() === '' ? 'sk-no-api-key' : apiKey, maxTokens: parseInt(maxTokens) }) + const isEmbeddingModel = modelType === MODEL_TYPES.EMBEDDING || modelType === MODEL_TYPES.MULTI_EMBEDDING + const isRerankModel = modelType === MODEL_TYPES.RERANK + await onSave({ + apiKey: apiKey.trim() === '' ? 'sk-no-api-key' : apiKey, + maxTokens: parseInt(maxTokens), + ...(!isEmbeddingModel && !isRerankModel ? { timeoutSeconds: parseInt(timeoutSeconds) || 120 } : {}), + }) onClose() } finally { setSaving(false) @@ -448,6 +473,7 @@ export const ProviderConfigEditDialog = ({ } const isEmbeddingModel = modelType === MODEL_TYPES.EMBEDDING || modelType === MODEL_TYPES.MULTI_EMBEDDING + const isRerankModel = modelType === MODEL_TYPES.RERANK return ( setMaxTokens(e.target.value)} /> )} + {!isEmbeddingModel && !isRerankModel && ( +
+ + setTimeoutSeconds(e.target.value)} + /> +
+ )}
)} + {/* Concurrency Limit */} + {!isEmbeddingModel && !isRerankModel && !form.isBatchImport && ( +
+ + handleFormChange("concurrencyLimit", e.target.value)} + /> +
+ {t("model.dialog.hint.concurrencyLimit")} +
+
+ )} + {/* Connectivity verification area */} {!form.isBatchImport && (
@@ -1428,7 +1411,7 @@ export const ModelAddDialog = ({ size="small" onClick={(e) => { e.stopPropagation(); // Prevent switch toggle - handleSettingsClick(model); + handleSingleModelSettingsClick(model); }} /> @@ -1773,42 +1756,52 @@ export const ModelAddDialog = ({
- {/* Settings Modal */} - setSettingsModalVisible(false)} - onOk={handleSettingsSave} - cancelText={t("common.cancel")} - okText={t("common.confirm")} - destroyOnHidden - > -
-
- - setModelMaxTokens(e.target.value)} - placeholder={t("model.dialog.placeholder.maxTokens")} - /> -
-
- - setModelTimeoutSeconds(e.target.value)} - placeholder="120" - /> -
-
-
+ {/* Single Model Settings Modal */} + { + setIsSingleModelSettingsOpen(false); + setSelectedSingleModel(null); + }} + initialMaxTokens={selectedSingleModel?.max_tokens?.toString() || "4096"} + initialTimeoutSeconds={selectedSingleModel?.timeout_seconds?.toString() || "120"} + modelType={form.type} + showApiKeyField={false} + onSave={async (config) => { + if (!selectedSingleModel) return; + try { + const modelName = selectedSingleModel.model_name || selectedSingleModel.id; + await modelService.updateBatchModel( + [ + { + model_id: modelName, + apiKey: config.apiKey, + maxTokens: config.maxTokens, + timeoutSeconds: config.timeoutSeconds, + concurrencyLimit: config.concurrencyLimit, + }, + ], + selectedSingleModel.model_factory + ); + + // Update the model in the list + setModelList((prev) => + prev.map((model) => + model.id === selectedSingleModel.id + ? { + ...model, + api_key: config.apiKey, + max_tokens: config.maxTokens, + timeout_seconds: config.timeoutSeconds, + } + : model + ) + ); + } catch (error) { + console.error("Failed to update model settings:", error); + } + }} + />
); }; diff --git a/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx b/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx index f58ca242e..0074a9bb5 100644 --- a/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx @@ -52,12 +52,9 @@ export const ModelDeleteDialog = ({ const [isConfirmLoading, setIsConfirmLoading] = useState(false); const [maxTokens, setMaxTokens] = useState(0); - // Settings modal state - const [settingsModalVisible, setSettingsModalVisible] = useState(false); - const [selectedModelForSettings, setSelectedModelForSettings] = - useState(null); - const [modelMaxTokens, setModelMaxTokens] = useState("4096"); - const [modelTimeoutSeconds, setModelTimeoutSeconds] = useState("120"); + // Single model settings modal state + const [isSingleModelSettingsOpen, setIsSingleModelSettingsOpen] = useState(false); + const [selectedSingleModel, setSelectedSingleModel] = useState(null); const [providerModelSearchTerm, setProviderModelSearchTerm] = useState(""); // Embedding model chunk config modal state @@ -591,10 +588,12 @@ export const ModelDeleteDialog = ({ apiKey, maxTokens, timeoutSeconds, + concurrencyLimit, }: { apiKey: string; maxTokens: number; timeoutSeconds?: number; + concurrencyLimit?: number; }) => { setMaxTokens(maxTokens); if ( @@ -628,6 +627,7 @@ export const ModelDeleteDialog = ({ apiKey: apiKey || m.apiKey, maxTokens: maxTokens || m.maxTokens, ...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}), + ...(concurrencyLimit !== undefined ? { concurrencyLimit } : {}), })); await modelService.updateBatchModel( @@ -643,6 +643,8 @@ export const ModelDeleteDialog = ({ prev.map((model) => ({ ...model, max_tokens: maxTokens || model.max_tokens || 4096, + timeout_seconds: timeoutSeconds || model.timeout_seconds, + concurrency_limit: concurrencyLimit !== undefined ? concurrencyLimit : model.concurrency_limit, })) ); } catch (e) { @@ -653,58 +655,6 @@ export const ModelDeleteDialog = ({ setIsProviderConfigOpen(false); }; - // Handle settings button click - const handleSettingsClick = (model: any) => { - setSelectedModelForSettings(model); - setModelMaxTokens(model.max_tokens?.toString() || "4096"); - setModelTimeoutSeconds(model.timeout_seconds?.toString() || "120"); - setSettingsModalVisible(true); - }; - - // Handle settings save - const handleSettingsSave = async () => { - if (!selectedModelForSettings) return; - - try { - // Use model_name as the identifier (API returns model_name field, id is combined format) - const modelName = selectedModelForSettings.model_name || selectedModelForSettings.id; - - // Call API to update model settings - await modelService.updateBatchModel( - [ - { - model_id: modelName, - apiKey: selectedModelForSettings.api_key || "", - maxTokens: parseInt(modelMaxTokens) || 4096, - timeoutSeconds: parseInt(modelTimeoutSeconds) || 120, - }, - ], - selectedModelForSettings.model_factory - ); - - // Update the model in the list with new max_tokens and timeout_seconds - setProviderModels((prev) => - prev.map((model) => - model.id === selectedModelForSettings.id - ? { - ...model, - max_tokens: parseInt(modelMaxTokens) || 4096, - timeout_seconds: parseInt(modelTimeoutSeconds) || 120, - } - : model - ) - ); - - message.success(t("model.message.updateSuccess") || "Update successful"); - } catch (error) { - console.error("Failed to update model settings:", error); - message.error(t("model.message.updateFailed") || "Failed to update settings"); - } finally { - setSettingsModalVisible(false); - setSelectedModelForSettings(null); - } - }; - // Handle embedding model click to open config modal const handleEmbeddingModelClick = (model: ModelOption | any) => { const isEmbeddingModel = @@ -762,6 +712,12 @@ export const ModelDeleteDialog = ({ } }; + // Handle single model settings button click + const handleSingleModelSettingsClick = (model: any) => { + setSelectedSingleModel(model); + setIsSingleModelSettingsOpen(true); + }; + // Handle embedding config save const handleEmbeddingConfigSave = async () => { if (!selectedEmbeddingModel) return; @@ -1363,7 +1319,7 @@ export const ModelDeleteDialog = ({ size="small" onClick={(e) => { e.stopPropagation(); // Prevent switch toggle - handleSettingsClick(providerModel); + handleSingleModelSettingsClick(providerModel); }} /> @@ -1549,46 +1505,75 @@ export const ModelDeleteDialog = ({ m.source === (selectedSource || MODEL_SOURCES.SILICON) )?.maxTokens || 4096 ).toString()} + initialTimeoutSeconds={( + models.find( + (m) => + m.type === deletingModelType && + m.source === (selectedSource || MODEL_SOURCES.SILICON) + )?.timeoutSeconds?.toString() || "120" + )} + initialConcurrencyLimit={( + models.find( + (m) => + m.type === deletingModelType && + m.source === (selectedSource || MODEL_SOURCES.SILICON) + )?.concurrencyLimit?.toString() || "" + )} modelType={deletingModelType || undefined} onSave={handleProviderConfigSave} /> - {/* Settings Modal */} - setSettingsModalVisible(false)} - onOk={handleSettingsSave} - cancelText={t("common.button.cancel")} - okText={t("common.button.save")} - destroyOnHidden - > -
-
- - setModelMaxTokens(e.target.value)} - placeholder={t("model.dialog.placeholder.maxTokens")} - /> -
-
- - setModelTimeoutSeconds(e.target.value)} - placeholder="120" - /> -
-
-
+ {/* Single Model Settings Modal */} + { + setIsSingleModelSettingsOpen(false); + setSelectedSingleModel(null); + }} + initialMaxTokens={selectedSingleModel?.max_tokens?.toString() || "4096"} + initialTimeoutSeconds={selectedSingleModel?.timeout_seconds?.toString() || "120"} + initialConcurrencyLimit={selectedSingleModel?.concurrency_limit?.toString() || ""} + modelType={deletingModelType || undefined} + showApiKeyField={false} + onSave={async (config) => { + if (!selectedSingleModel) return; + try { + const modelName = selectedSingleModel.model_name || selectedSingleModel.id; + await modelService.updateBatchModel( + [ + { + model_id: modelName, + apiKey: config.apiKey, + maxTokens: config.maxTokens, + timeoutSeconds: config.timeoutSeconds, + concurrencyLimit: config.concurrencyLimit, + }, + ], + selectedSingleModel.model_factory + ); + + // Update the model in the list + setProviderModels((prev) => + prev.map((model) => + model.id === selectedSingleModel.id + ? { + ...model, + api_key: config.apiKey, + max_tokens: config.maxTokens, + timeout_seconds: config.timeoutSeconds, + concurrency_limit: config.concurrencyLimit, + } + : model + ) + ); + + message.success(t("model.message.updateSuccess") || "Update successful"); + } catch (error) { + console.error("Failed to update model settings:", error); + message.error(t("model.message.updateFailed") || "Failed to update settings"); + } + }} + /> {/* Embedding Model Config Modal */} handleFormChange("maxTokens", e.target.value)} /> + + )} + + {/* Timeout Seconds */} + {!isEmbeddingModel && !isRerankModel && ( +
+ )} + {/* Concurrency Limit */} + {!isEmbeddingModel && !isRerankModel && ( +
+ + handleFormChange("concurrencyLimit", e.target.value)} + placeholder={t("model.dialog.placeholder.concurrencyLimit")} + /> +
+ {t("model.dialog.hint.concurrencyLimit")} +
+
+ )} + {/* Chunk Size Range for embedding models */} {isEmbeddingModel && (
@@ -424,9 +456,11 @@ interface ProviderConfigEditDialogProps { initialApiKey?: string initialMaxTokens?: string initialTimeoutSeconds?: string + initialConcurrencyLimit?: string modelType?: ModelType + showApiKeyField?: boolean // Whether to show API Key field (default: true) onClose: () => void - onSave: (config: { apiKey: string; maxTokens: number; timeoutSeconds?: number }) => Promise | void + onSave: (config: { apiKey: string; maxTokens: number; timeoutSeconds?: number; concurrencyLimit?: number }) => Promise | void } export const ProviderConfigEditDialog = ({ @@ -434,7 +468,9 @@ export const ProviderConfigEditDialog = ({ initialApiKey = '', initialMaxTokens = '4096', initialTimeoutSeconds = '120', + initialConcurrencyLimit = '', modelType, + showApiKeyField = true, onClose, onSave, }: ProviderConfigEditDialogProps) => { @@ -442,13 +478,15 @@ export const ProviderConfigEditDialog = ({ const [apiKey, setApiKey] = useState(initialApiKey) const [maxTokens, setMaxTokens] = useState(initialMaxTokens) const [timeoutSeconds, setTimeoutSeconds] = useState(initialTimeoutSeconds) + const [concurrencyLimit, setConcurrencyLimit] = useState(initialConcurrencyLimit) const [saving, setSaving] = useState(false) useEffect(() => { setApiKey(initialApiKey) setMaxTokens(initialMaxTokens) setTimeoutSeconds(initialTimeoutSeconds) - }, [initialApiKey, initialMaxTokens, initialTimeoutSeconds]) + setConcurrencyLimit(initialConcurrencyLimit) + }, [initialApiKey, initialMaxTokens, initialTimeoutSeconds, initialConcurrencyLimit]) const valid = () => { const parsed = parseInt(maxTokens) @@ -462,9 +500,10 @@ export const ProviderConfigEditDialog = ({ const isEmbeddingModel = modelType === MODEL_TYPES.EMBEDDING || modelType === MODEL_TYPES.MULTI_EMBEDDING const isRerankModel = modelType === MODEL_TYPES.RERANK await onSave({ - apiKey: apiKey.trim() === '' ? 'sk-no-api-key' : apiKey, + apiKey: showApiKeyField ? (apiKey.trim() === '' ? 'sk-no-api-key' : apiKey) : '', maxTokens: parseInt(maxTokens), ...(!isEmbeddingModel && !isRerankModel ? { timeoutSeconds: parseInt(timeoutSeconds) || 120 } : {}), + ...(!isEmbeddingModel && !isRerankModel ? { concurrencyLimit: concurrencyLimit ? parseInt(concurrencyLimit) : undefined } : {}), }) onClose() } finally { @@ -484,12 +523,14 @@ export const ProviderConfigEditDialog = ({ destroyOnHidden >
-
- - setApiKey(e.target.value)} visibilityToggle={false} /> -
+ {showApiKeyField && ( +
+ + setApiKey(e.target.value)} visibilityToggle={false} /> +
+ )} {!isEmbeddingModel && (
)} + {!isEmbeddingModel && !isRerankModel && ( +
+ + setConcurrencyLimit(e.target.value)} + placeholder={t("model.dialog.placeholder.concurrencyLimit")} + /> +
+ {t("model.dialog.hint.concurrencyLimit")} +
+
+ )}
- {/* Single Model Settings Modal */} - { - setIsSingleModelSettingsOpen(false); - setSelectedSingleModel(null); - }} - initialMaxTokens={selectedSingleModel?.max_tokens?.toString() || "4096"} - initialTimeoutSeconds={selectedSingleModel?.timeout_seconds?.toString() || "120"} - modelType={form.type} - showApiKeyField={false} - onSave={async (config) => { - if (!selectedSingleModel) return; - try { - const modelName = selectedSingleModel.model_name || selectedSingleModel.id; - await modelService.updateBatchModel( - [ - { - model_id: modelName, - apiKey: config.apiKey, - maxTokens: config.maxTokens, - timeoutSeconds: config.timeoutSeconds, - concurrencyLimit: config.concurrencyLimit, - }, - ], - selectedSingleModel.model_factory - ); - - // Update the model in the list - setModelList((prev) => - prev.map((model) => - model.id === selectedSingleModel.id - ? { - ...model, - api_key: config.apiKey, - max_tokens: config.maxTokens, - timeout_seconds: config.timeoutSeconds, - } - : model - ) - ); - } catch (error) { - console.error("Failed to update model settings:", error); - } - }} - /> + {/* Settings Modal */} + setSettingsModalVisible(false)} + onOk={handleSettingsSave} + cancelText={t("common.cancel")} + okText={t("common.confirm")} + destroyOnHidden + > +
+
+ + setModelMaxTokens(e.target.value)} + placeholder={t("model.dialog.placeholder.maxTokens")} + /> +
+
+
); -}; +}; \ No newline at end of file From 59810bbf7ada8e829caedd49c494a7b28436e9ab Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Sat, 9 May 2026 17:18:23 +0800 Subject: [PATCH 12/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/[locale]/models/components/model/ModelAddDialog.tsx | 1 + frontend/services/modelService.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx index 471963439..94a869301 100644 --- a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx @@ -829,6 +829,7 @@ export const ModelAddDialog = ({ }; const isEmbeddingModel = form.type === MODEL_TYPES.EMBEDDING; + const isRerankModel = form.type === MODEL_TYPES.RERANK; const isSTTModel = form.type === MODEL_TYPES.STT; return ( diff --git a/frontend/services/modelService.ts b/frontend/services/modelService.ts index 3538b34f4..07796d2c4 100644 --- a/frontend/services/modelService.ts +++ b/frontend/services/modelService.ts @@ -124,6 +124,7 @@ export const modelService = { maximum_chunk_size: model.maximumChunkSize, chunk_batch: model.chunkingBatchSize, timeout_seconds: model.timeoutSeconds, + concurrency_limit: model.concurrencyLimit, }; // Add STT specific fields @@ -721,6 +722,7 @@ export const modelService = { api_key: params.apiKey, max_tokens: params.maxTokens || 4096, display_name: params.displayName || params.name, + model_factory: params.modelFactory || "OpenAI-API-Compatible", expected_chunk_size: params.expectedChunkSize, maximum_chunk_size: params.maximumChunkSize, chunk_batch: params.chunkingBatchSize, From 055b5a294ce16f8cdf42c58d0df46284802cb020 Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Mon, 11 May 2026 14:25:51 +0800 Subject: [PATCH 13/25] =?UTF-8?q?=E4=B8=BAnexent-config=E6=8C=82=E8=BD=BD?= =?UTF-8?q?=E8=AF=81=E4=B9=A6=EF=BC=8C=E4=BB=A4=E5=AE=B9=E5=99=A8=E5=86=85?= =?UTF-8?q?=E7=9A=84=20Python=20=E5=BA=94=E7=94=A8=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E6=9C=BA=E7=9A=84=20CA=20=E8=AF=81=E4=B9=A6?= =?UTF-8?q?=E6=9D=A5=E9=AA=8C=E8=AF=81=E5=A4=96=E9=83=A8=20SMTP=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E7=9A=84=20SSL=20=E8=AF=81=E4=B9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/docker-compose.prod.yml | 2 ++ docker/docker-compose.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 934fe8b2f..3cc7ac59a 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -78,6 +78,8 @@ services: - ${ROOT_DIR}/openssh-server/ssh-keys:/opt/ssh-keys:ro - ${ROOT_DIR}/scripts/sync_user_supabase2pg.py:/opt/sync_user_supabase2pg.py:ro - /var/run/docker.sock:/var/run/docker.sock:ro # Docker socket for MCP container management + # CA certificates for external service SSL verification (e.g., SMTP) + - /etc/ssl/certs:/etc/ssl/certs:ro environment: <<: [*minio-vars, *es-vars] skip_proxy: "true" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 89088f2c3..4056683dc 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -89,6 +89,8 @@ services: - ${ROOT_DIR}/openssh-server/ssh-keys:/opt/ssh-keys:ro - ${ROOT_DIR}/scripts/sync_user_supabase2pg.py:/opt/sync_user_supabase2pg.py:ro - /var/run/docker.sock:/var/run/docker.sock:ro # Docker socket for MCP container management + # CA certificates for external service SSL verification (e.g., SMTP) + - /etc/ssl/certs:/etc/ssl/certs:ro environment: <<: [*minio-vars, *es-vars] skip_proxy: "true" From 4a8ccb2af515d17a2b77f49793ab57e64b6daa21 Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Mon, 11 May 2026 14:36:38 +0800 Subject: [PATCH 14/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/model_health_service.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/services/model_health_service.py b/backend/services/model_health_service.py index e22f6c642..73adacc00 100644 --- a/backend/services/model_health_service.py +++ b/backend/services/model_health_service.py @@ -97,23 +97,23 @@ async def _perform_connectivity_check( # Test connectivity based on different model types if model_type == "embedding": - connectivity = len(await OpenAICompatibleEmbedding( + embedding = OpenAICompatibleEmbedding( model_name=model_name, base_url=model_base_url, api_key=model_api_key, embedding_dim=0, ssl_verify=ssl_verify, - timeout_seconds=timeout_seconds, - ).dimension_check()) > 0 + ) + connectivity = len(await embedding.dimension_check(timeout=timeout_seconds if timeout_seconds else 5.0)) > 0 elif model_type == "multi_embedding": - connectivity = len(await JinaEmbedding( + embedding = JinaEmbedding( model_name=model_name, base_url=model_base_url, api_key=model_api_key, embedding_dim=0, ssl_verify=ssl_verify, - timeout_seconds=timeout_seconds, - ).dimension_check()) > 0 + ) + connectivity = len(await embedding.dimension_check(timeout=timeout_seconds if timeout_seconds else 5.0)) > 0 elif model_type == "llm": observer = MessageObserver() set_monitoring_operation("connectivity_check", From ff787b07ea3351c3e840cde718768066b963d467 Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Mon, 11 May 2026 15:13:59 +0800 Subject: [PATCH 15/25] =?UTF-8?q?=E5=8C=BA=E5=88=86send=20email=E9=92=88?= =?UTF-8?q?=E5=AF=B9=E6=98=AF=E5=90=A6=E8=B7=B3=E8=BF=87=E8=AF=81=E4=B9=A6?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E7=9A=84=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/nexent/core/tools/send_email_tool.py | 49 ++++++-- test/sdk/core/tools/test_send_email_tool.py | 122 +++++++++++++------- 2 files changed, 120 insertions(+), 51 deletions(-) diff --git a/sdk/nexent/core/tools/send_email_tool.py b/sdk/nexent/core/tools/send_email_tool.py index 2451020ea..097ad838c 100644 --- a/sdk/nexent/core/tools/send_email_tool.py +++ b/sdk/nexent/core/tools/send_email_tool.py @@ -65,8 +65,8 @@ class SendEmailTool(Tool): "description_zh": "SMTP 服务器密码" }, "use_ssl": { - "description": "Use SSL", - "description_zh": "使用 SSL" + "description": "Use SSL/TLS encryption (set to False for plain text)", + "description_zh": "使用 SSL/TLS 加密(设为 False 使用明文)" }, "sender_name": { "description": "Sender name", @@ -80,13 +80,13 @@ class SendEmailTool(Tool): output_type = "string" category = ToolCategory.EMAIL.value - def __init__(self, smtp_server: str=Field(description="SMTP Server Address"), - smtp_port: int=Field(description="SMTP server port"), - username: str=Field(description="SMTP server username"), - password: str=Field(description="SMTP server password"), - use_ssl: bool=Field(description="Use SSL", default=True), - sender_name: Optional[str] = Field(description="Sender name", default=None), - timeout: int = Field(description="Timeout", default=30)): + def __init__(self, smtp_server: str = "", + smtp_port: int = 587, + username: str = "", + password: str = "", + use_ssl: bool = True, + sender_name: Optional[str] = None, + timeout: int = 30): super().__init__() self.smtp_server = smtp_server self.smtp_port = smtp_port @@ -96,6 +96,18 @@ def __init__(self, smtp_server: str=Field(description="SMTP Server Address"), self.sender_name = sender_name self.timeout = timeout + def _create_ssl_context(self, skip_verify: bool = False) -> ssl.SSLContext: + """Create SSL context with optional verification disabled for self-signed certs.""" + context = ssl.create_default_context() + if skip_verify: + logger.warning("SSL verification disabled - use only for internal/local SMTP servers") + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + else: + context.check_hostname = True + context.verify_mode = ssl.CERT_REQUIRED + return context + def forward(self, to: str, subject: str, content: str, cc: str = "", bcc: str = "") -> str: try: logger.info("Creating email message...") @@ -119,13 +131,26 @@ def forward(self, to: str, subject: str, content: str, cc: str = "", bcc: str = if self.smtp_port == 465: # Port 465 uses implicit SSL logger.info("Using implicit SSL connection (port 465)...") - context = ssl.create_default_context() + context = self._create_ssl_context(skip_verify=False) server = smtplib.SMTP_SSL(self.smtp_server, self.smtp_port, context=context, timeout=self.timeout) - else: + elif self.use_ssl: # Port 587 (and others) use STARTTLS logger.info("Using STARTTLS connection...") server = smtplib.SMTP(self.smtp_server, self.smtp_port, timeout=self.timeout) - server.starttls(context=ssl.create_default_context()) + server.starttls(context=self._create_ssl_context(skip_verify=False)) + else: + # Port 25 - plain connection (may have self-signed certs) + logger.info("Using plain text connection (port 25)...") + server = smtplib.SMTP(self.smtp_server, self.smtp_port, timeout=self.timeout) + # Some servers force TLS handshake even on plain connections + # Skip cert verification for port 25 to handle self-signed certs + try: + server.starttls(context=self._create_ssl_context(skip_verify=True)) + logger.info("Server upgraded to TLS connection") + except smtplib.SMTPNotSupportedError: + logger.info("Server does not support STARTTLS, using plain connection") + except Exception as tls_err: + logger.warning(f"TLS upgrade failed: {tls_err}, continuing with plain connection") logger.info("Logging in...") # Login diff --git a/test/sdk/core/tools/test_send_email_tool.py b/test/sdk/core/tools/test_send_email_tool.py index 1287a4f53..88b279eb2 100644 --- a/test/sdk/core/tools/test_send_email_tool.py +++ b/test/sdk/core/tools/test_send_email_tool.py @@ -60,6 +60,17 @@ def test_init_with_custom_values(self): assert tool.sender_name == "Custom Sender" assert tool.timeout == 60 + def test_init_use_ssl_default(self): + """Test that use_ssl defaults to True""" + tool = SendEmailTool( + smtp_server="smtp.example.com", + smtp_port=587, + username="user@example.com", + password="password123" + ) + assert tool.use_ssl is True + assert tool.timeout == 30 + def test_tool_attributes(self, send_email_tool): """Test tool class attributes""" assert send_email_tool.name == "send_email" @@ -91,9 +102,9 @@ def test_tool_inputs_schema(self, send_email_tool): assert inputs["bcc"]["type"] == "string" assert inputs["bcc"]["nullable"] is True - @patch('smtplib.SMTP_SSL') + @patch('smtplib.SMTP') @patch('ssl.create_default_context') - def test_forward_success_basic_email(self, mock_ssl_context, mock_smtp_ssl, send_email_tool): + def test_forward_success_basic_email(self, mock_ssl_context, mock_smtp, send_email_tool): """Test successful basic email sending""" # Mock SSL context mock_context = Mock() @@ -101,7 +112,7 @@ def test_forward_success_basic_email(self, mock_ssl_context, mock_smtp_ssl, send # Mock SMTP server mock_server = Mock() - mock_smtp_ssl.return_value = mock_server + mock_smtp.return_value = mock_server result = send_email_tool.forward( to="recipient@example.com", @@ -119,17 +130,16 @@ def test_forward_success_basic_email(self, mock_ssl_context, mock_smtp_ssl, send assert result_data["subject"] == "Test Subject" # Verify SMTP operations - mock_smtp_ssl.assert_called_once_with( - "smtp.test.com", 587, context=mock_context, timeout=30 - ) + mock_smtp.assert_called_once_with("smtp.test.com", 587, timeout=30) + mock_server.starttls.assert_called_once_with(context=mock_context) mock_server.login.assert_called_once_with( "test@test.com", "test_password") mock_server.send_message.assert_called_once() mock_server.quit.assert_called_once() - @patch('smtplib.SMTP_SSL') + @patch('smtplib.SMTP') @patch('ssl.create_default_context') - def test_forward_success_with_cc_and_bcc(self, mock_ssl_context, mock_smtp_ssl, send_email_tool): + def test_forward_success_with_cc_and_bcc(self, mock_ssl_context, mock_smtp, send_email_tool): """Test successful email sending with CC and BCC""" # Mock SSL context mock_context = Mock() @@ -137,7 +147,7 @@ def test_forward_success_with_cc_and_bcc(self, mock_ssl_context, mock_smtp_ssl, # Mock SMTP server mock_server = Mock() - mock_smtp_ssl.return_value = mock_server + mock_smtp.return_value = mock_server result = send_email_tool.forward( to="recipient@example.com", @@ -164,9 +174,9 @@ def test_forward_success_with_cc_and_bcc(self, mock_ssl_context, mock_smtp_ssl, assert call_args['Cc'] == "cc1@example.com,cc2@example.com" assert call_args['Bcc'] == "bcc@example.com" - @patch('smtplib.SMTP_SSL') + @patch('smtplib.SMTP') @patch('ssl.create_default_context') - def test_forward_success_multiple_recipients(self, mock_ssl_context, mock_smtp_ssl, send_email_tool): + def test_forward_success_multiple_recipients(self, mock_ssl_context, mock_smtp, send_email_tool): """Test successful email sending with multiple recipients""" # Mock SSL context mock_context = Mock() @@ -174,7 +184,7 @@ def test_forward_success_multiple_recipients(self, mock_ssl_context, mock_smtp_s # Mock SMTP server mock_server = Mock() - mock_smtp_ssl.return_value = mock_server + mock_smtp.return_value = mock_server result = send_email_tool.forward( to="recipient1@example.com,recipient2@example.com", @@ -191,9 +201,9 @@ def test_forward_success_multiple_recipients(self, mock_ssl_context, mock_smtp_s assert result_data["status"] == "success" assert result_data["to"] == "recipient1@example.com,recipient2@example.com" - @patch('smtplib.SMTP_SSL') + @patch('smtplib.SMTP') @patch('ssl.create_default_context') - def test_forward_smtp_send_error(self, mock_ssl_context, mock_smtp_ssl, send_email_tool): + def test_forward_smtp_send_error(self, mock_ssl_context, mock_smtp, send_email_tool): """Test email sending with SMTP send error""" # Mock SSL context mock_context = Mock() @@ -204,7 +214,7 @@ def test_forward_smtp_send_error(self, mock_ssl_context, mock_smtp_ssl, send_ema mock_server.send_message.side_effect = smtplib.SMTPRecipientsRefused( "Recipients refused" ) - mock_smtp_ssl.return_value = mock_server + mock_smtp.return_value = mock_server result = send_email_tool.forward( to="recipient@example.com", @@ -219,9 +229,9 @@ def test_forward_smtp_send_error(self, mock_ssl_context, mock_smtp_ssl, send_ema assert result_data["status"] == "error" assert "Failed to send email" in result_data["message"] - @patch('smtplib.SMTP_SSL') + @patch('smtplib.SMTP') @patch('ssl.create_default_context') - def test_forward_unexpected_exception(self, mock_ssl_context, mock_smtp_ssl, send_email_tool): + def test_forward_unexpected_exception(self, mock_ssl_context, mock_smtp, send_email_tool): """Test email sending with unexpected exception""" # Mock SSL context mock_context = Mock() @@ -230,7 +240,7 @@ def test_forward_unexpected_exception(self, mock_ssl_context, mock_smtp_ssl, sen # Mock SMTP server with unexpected error mock_server = Mock() mock_server.login.side_effect = RuntimeError("Unexpected error") - mock_smtp_ssl.return_value = mock_server + mock_smtp.return_value = mock_server result = send_email_tool.forward( to="recipient@example.com", @@ -246,9 +256,9 @@ def test_forward_unexpected_exception(self, mock_ssl_context, mock_smtp_ssl, sen assert "An unexpected error occurred" in result_data["message"] assert "Unexpected error" in result_data["message"] - @patch('smtplib.SMTP_SSL') + @patch('smtplib.SMTP') @patch('ssl.create_default_context') - def test_forward_empty_cc_and_bcc(self, mock_ssl_context, mock_smtp_ssl, send_email_tool): + def test_forward_empty_cc_and_bcc(self, mock_ssl_context, mock_smtp, send_email_tool): """Test email sending with empty CC and BCC""" # Mock SSL context mock_context = Mock() @@ -256,7 +266,7 @@ def test_forward_empty_cc_and_bcc(self, mock_ssl_context, mock_smtp_ssl, send_em # Mock SMTP server mock_server = Mock() - mock_smtp_ssl.return_value = mock_server + mock_smtp.return_value = mock_server result = send_email_tool.forward( to="recipient@example.com", @@ -277,9 +287,9 @@ def test_forward_empty_cc_and_bcc(self, mock_ssl_context, mock_smtp_ssl, send_em assert 'Cc' not in call_args assert 'Bcc' not in call_args - @patch('smtplib.SMTP_SSL') + @patch('smtplib.SMTP') @patch('ssl.create_default_context') - def test_forward_html_content_attachment(self, mock_ssl_context, mock_smtp_ssl, send_email_tool): + def test_forward_html_content_attachment(self, mock_ssl_context, mock_smtp, send_email_tool): """Test that HTML content is properly attached to email""" # Mock SSL context mock_context = Mock() @@ -287,7 +297,7 @@ def test_forward_html_content_attachment(self, mock_ssl_context, mock_smtp_ssl, # Mock SMTP server mock_server = Mock() - mock_smtp_ssl.return_value = mock_server + mock_smtp.return_value = mock_server html_content = "

Test Header

This is bold text.

" @@ -314,17 +324,19 @@ def test_forward_html_content_attachment(self, mock_ssl_context, mock_smtp_ssl, assert attachments[0].get_content_type() == "text/html" assert attachments[0].get_payload() == html_content - @patch('smtplib.SMTP_SSL') + @patch('smtplib.SMTP') @patch('ssl.create_default_context') - def test_forward_ssl_context_configuration(self, mock_ssl_context, mock_smtp_ssl, send_email_tool): - """Test SSL context is properly configured""" + def test_forward_ssl_context_configuration(self, mock_ssl_context, mock_smtp, send_email_tool): + """Test SSL context is properly configured for STARTTLS""" # Mock SSL context mock_context = Mock() + mock_context.check_hostname = True + mock_context.verify_mode = ssl.CERT_REQUIRED mock_ssl_context.return_value = mock_context # Mock SMTP server mock_server = Mock() - mock_smtp_ssl.return_value = mock_server + mock_smtp.return_value = mock_server send_email_tool.forward( to="recipient@example.com", @@ -332,16 +344,48 @@ def test_forward_ssl_context_configuration(self, mock_ssl_context, mock_smtp_ssl content="

Test content

" ) - # Verify SSL context configuration + # Verify SSL context is created (default settings preserved) mock_ssl_context.assert_called_once() - assert mock_context.check_hostname is True - assert mock_context.verify_mode == ssl.CERT_REQUIRED - # Verify SMTP_SSL is called with context - mock_smtp_ssl.assert_called_once_with( - "smtp.test.com", 587, context=mock_context, timeout=30 + # Verify STARTTLS is called with context + mock_server.starttls.assert_called_once_with(context=mock_context) + + @patch('smtplib.SMTP') + @patch('ssl.create_default_context') + def test_forward_port_25_skips_ssl_verification(self, mock_ssl_context, mock_smtp): + """Test that port 25 skips SSL certificate verification for self-signed certs""" + # Create tool with port 25 + tool = SendEmailTool( + smtp_server="smtp.local.com", + smtp_port=25, + username="user@example.com", + password="password123", + use_ssl=False + ) + + # Mock SSL context + mock_context = Mock() + mock_context.check_hostname = False + mock_context.verify_mode = ssl.CERT_NONE + mock_ssl_context.return_value = mock_context + + # Mock SMTP server + mock_server = Mock() + mock_smtp.return_value = mock_server + + result = tool.forward( + to="recipient@example.com", + subject="Test Subject", + content="

Test content

" ) + # Parse result + result_data = json.loads(result) + assert result_data["status"] == "success" + + # Verify STARTTLS is called with context for self-signed certs + mock_server.starttls.assert_called_once_with(context=mock_context) + @patch('smtplib.SMTP_SSL') @patch('ssl.create_default_context') def test_forward_timeout_configuration(self, mock_ssl_context, mock_smtp_ssl): @@ -374,9 +418,9 @@ def test_forward_timeout_configuration(self, mock_ssl_context, mock_smtp_ssl): "smtp.example.com", 465, context=mock_context, timeout=60 ) - @patch('smtplib.SMTP_SSL') + @patch('smtplib.SMTP') @patch('ssl.create_default_context') - def test_forward_server_quit_called_on_success(self, mock_ssl_context, mock_smtp_ssl, send_email_tool): + def test_forward_server_quit_called_on_success(self, mock_ssl_context, mock_smtp, send_email_tool): """Test that server.quit() is called on successful send""" # Mock SSL context mock_context = Mock() @@ -384,7 +428,7 @@ def test_forward_server_quit_called_on_success(self, mock_ssl_context, mock_smtp # Mock SMTP server mock_server = Mock() - mock_smtp_ssl.return_value = mock_server + mock_smtp.return_value = mock_server send_email_tool.forward( to="recipient@example.com", @@ -397,7 +441,7 @@ def test_forward_server_quit_called_on_success(self, mock_ssl_context, mock_smtp def test_forward_empty_parameters(self, send_email_tool): """Test forward method with empty parameters""" - with patch('smtplib.SMTP_SSL') as mock_smtp_ssl, \ + with patch('smtplib.SMTP') as mock_smtp, \ patch('ssl.create_default_context') as mock_ssl_context: # Mock SSL context @@ -406,7 +450,7 @@ def test_forward_empty_parameters(self, send_email_tool): # Mock SMTP server mock_server = Mock() - mock_smtp_ssl.return_value = mock_server + mock_smtp.return_value = mock_server result = send_email_tool.forward( to="", From ef58bda35b91d966943899c5dc9fbec6c0152fc9 Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Mon, 11 May 2026 17:08:37 +0800 Subject: [PATCH 16/25] =?UTF-8?q?=E5=8C=BA=E5=88=86sender=5Femail=E5=92=8C?= =?UTF-8?q?=E5=92=8Csender=5Fname?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/tool_configuration_service.py | 26 +++++++++--- backend/utils/tool_utils.py | 3 +- sdk/nexent/core/tools/send_email_tool.py | 34 ++++++++++++---- test/sdk/core/tools/test_send_email_tool.py | 40 ++++++++++++++++++- 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/backend/services/tool_configuration_service.py b/backend/services/tool_configuration_service.py index 5e5229ff6..0f779cb98 100644 --- a/backend/services/tool_configuration_service.py +++ b/backend/services/tool_configuration_service.py @@ -130,11 +130,15 @@ def get_local_tools() -> List[ToolInfo]: if hasattr(param.default, 'exclude') and param.default.exclude: continue + # Check if default is a Pydantic FieldInfo (has .default attribute) + is_pydantic_field = hasattr(param.default, 'default') + # Get description in both languages - param_description = param.default.description if hasattr(param.default, 'description') else "" + param_description = param.default.description if is_pydantic_field else "" # First try to get from param.default.description_zh (FieldInfo) - param_description_zh = param.default.description_zh if hasattr(param.default, 'description_zh') else None + # Note: Pydantic Field doesn't have description_zh attribute, so use getattr with default + param_description_zh = getattr(param.default, 'description_zh', None) if is_pydantic_field else None # Fallback to init_param_descriptions if not found if param_description_zh is None and param_name in init_param_descriptions: @@ -146,11 +150,21 @@ def get_local_tools() -> List[ToolInfo]: "description": param_description, "description_zh": param_description_zh } - if param.default.default is PydanticUndefined: - param_info["optional"] = False + + # Handle both Pydantic FieldInfo and simple defaults + if is_pydantic_field: + if param.default.default is PydanticUndefined: + param_info["optional"] = False + else: + param_info["default"] = param.default.default + param_info["optional"] = True else: - param_info["default"] = param.default.default - param_info["optional"] = True + # Simple default value (not a FieldInfo) + if param.default == inspect.Parameter.empty: + param_info["optional"] = False + else: + param_info["default"] = param.default + param_info["optional"] = True init_params_list.append(param_info) diff --git a/backend/utils/tool_utils.py b/backend/utils/tool_utils.py index f06f36bc3..f1d9147e3 100644 --- a/backend/utils/tool_utils.py +++ b/backend/utils/tool_utils.py @@ -46,7 +46,8 @@ def get_local_tools_description_zh() -> Dict[str, Dict]: if hasattr(param.default, 'exclude') and param.default.exclude: continue - param_description_zh = param.default.description_zh if hasattr(param.default, 'description_zh') else None + # Note: Pydantic Field doesn't have description_zh attribute + param_description_zh = getattr(param.default, 'description_zh', None) if hasattr(param.default, 'description_zh') else None if param_description_zh is None and param_name in init_param_descriptions: param_description_zh = init_param_descriptions[param_name].get('description_zh') diff --git a/sdk/nexent/core/tools/send_email_tool.py b/sdk/nexent/core/tools/send_email_tool.py index 097ad838c..42453e16b 100644 --- a/sdk/nexent/core/tools/send_email_tool.py +++ b/sdk/nexent/core/tools/send_email_tool.py @@ -44,6 +44,12 @@ class SendEmailTool(Tool): "description": "BCC email address, multiple BCCs separated by commas, optional", "description_zh": "密送邮箱地址,多个密送用逗号分隔,可选", "nullable": True + }, + "sender_email": { + "type": "string", + "description": "Actual sender email address (From address), optional - defaults to username", + "description_zh": "实际发件人邮箱地址(From字段),可选,默认为username", + "nullable": True } } @@ -68,6 +74,10 @@ class SendEmailTool(Tool): "description": "Use SSL/TLS encryption (set to False for plain text)", "description_zh": "使用 SSL/TLS 加密(设为 False 使用明文)" }, + "sender_email": { + "description": "Actual sender email address (From address), defaults to username", + "description_zh": "实际发件人邮箱地址,默认为 username" + }, "sender_name": { "description": "Sender name", "description_zh": "发件人名称" @@ -81,10 +91,11 @@ class SendEmailTool(Tool): category = ToolCategory.EMAIL.value def __init__(self, smtp_server: str = "", - smtp_port: int = 587, - username: str = "", - password: str = "", + smtp_port: int = 587, + username: str = "", + password: str = "", use_ssl: bool = True, + sender_email: Optional[str] = None, sender_name: Optional[str] = None, timeout: int = 30): super().__init__() @@ -93,6 +104,7 @@ def __init__(self, smtp_server: str = "", self.username = username self.password = password self.use_ssl = use_ssl + self.sender_email = sender_email or username self.sender_name = sender_name self.timeout = timeout @@ -108,12 +120,18 @@ def _create_ssl_context(self, skip_verify: bool = False) -> ssl.SSLContext: context.verify_mode = ssl.CERT_REQUIRED return context - def forward(self, to: str, subject: str, content: str, cc: str = "", bcc: str = "") -> str: + def forward(self, to: str, subject: str, content: str, cc: str = "", bcc: str = "", + sender_email: Optional[str] = None) -> str: try: logger.info("Creating email message...") - # Create email object msg = MIMEMultipart() - msg['From'] = f"{self.sender_name} <{self.username}>" if self.sender_name else self.username + + sender = sender_email or self.sender_email + if self.sender_name: + msg['From'] = f"{self.sender_name} <{sender}>" + else: + msg['From'] = sender + msg['To'] = to msg['Subject'] = subject @@ -131,13 +149,13 @@ def forward(self, to: str, subject: str, content: str, cc: str = "", bcc: str = if self.smtp_port == 465: # Port 465 uses implicit SSL logger.info("Using implicit SSL connection (port 465)...") - context = self._create_ssl_context(skip_verify=False) + context = self._create_ssl_context(skip_verify=True) server = smtplib.SMTP_SSL(self.smtp_server, self.smtp_port, context=context, timeout=self.timeout) elif self.use_ssl: # Port 587 (and others) use STARTTLS logger.info("Using STARTTLS connection...") server = smtplib.SMTP(self.smtp_server, self.smtp_port, timeout=self.timeout) - server.starttls(context=self._create_ssl_context(skip_verify=False)) + server.starttls(context=self._create_ssl_context(skip_verify=True)) else: # Port 25 - plain connection (may have self-signed certs) logger.info("Using plain text connection (port 25)...") diff --git a/test/sdk/core/tools/test_send_email_tool.py b/test/sdk/core/tools/test_send_email_tool.py index 88b279eb2..d3bc9f946 100644 --- a/test/sdk/core/tools/test_send_email_tool.py +++ b/test/sdk/core/tools/test_send_email_tool.py @@ -19,6 +19,7 @@ def send_email_tool(): username="test@test.com", password="test_password", use_ssl=True, + sender_email="actual@test.com", sender_name="Test Sender", timeout=30 ) @@ -102,6 +103,10 @@ def test_tool_inputs_schema(self, send_email_tool): assert inputs["bcc"]["type"] == "string" assert inputs["bcc"]["nullable"] is True + assert "sender_email" in inputs + assert inputs["sender_email"]["type"] == "string" + assert inputs["sender_email"]["nullable"] is True + @patch('smtplib.SMTP') @patch('ssl.create_default_context') def test_forward_success_basic_email(self, mock_ssl_context, mock_smtp, send_email_tool): @@ -168,7 +173,7 @@ def test_forward_success_with_cc_and_bcc(self, mock_ssl_context, mock_smtp, send call_args = mock_server.send_message.call_args[0][0] # Verify email headers - assert call_args['From'] == "Test Sender " + assert call_args['From'] == "Test Sender " assert call_args['To'] == "recipient@example.com" assert call_args['Subject'] == "Test Subject" assert call_args['Cc'] == "cc1@example.com,cc2@example.com" @@ -466,6 +471,39 @@ def test_forward_empty_parameters(self, send_email_tool): assert result_data["to"] == "" assert result_data["subject"] == "" + @patch('smtplib.SMTP') + @patch('ssl.create_default_context') + def test_forward_sender_email_override(self, mock_ssl_context, mock_smtp): + """Test that sender_email parameter in forward overrides instance sender_email""" + tool = SendEmailTool( + smtp_server="smtp.test.com", + smtp_port=587, + username="auth@test.com", + password="password", + use_ssl=True, + sender_email="instance@test.com", + sender_name="Instance Sender" + ) + + mock_context = Mock() + mock_ssl_context.return_value = mock_context + + mock_server = Mock() + mock_smtp.return_value = mock_server + + result = tool.forward( + to="recipient@example.com", + subject="Test Subject", + content="

Test content

", + sender_email="override@test.com" + ) + + result_data = json.loads(result) + assert result_data["status"] == "success" + + call_args = mock_server.send_message.call_args[0][0] + assert call_args['From'] == "Instance Sender " + if __name__ == '__main__': pytest.main([__file__]) From 777502aba656c2704fdb2c6acff4e37b1f61308a Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Mon, 11 May 2026 20:28:51 +0800 Subject: [PATCH 17/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E6=98=8A=E5=A4=A9=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/haotian_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/services/haotian_service.py b/backend/services/haotian_service.py index a49079ec7..97c5db564 100644 --- a/backend/services/haotian_service.py +++ b/backend/services/haotian_service.py @@ -77,7 +77,7 @@ async def fetch_haotian_knowledge_sets_impl( ) headers = {"Authorization": external_authorization} - async with httpx.AsyncClient(timeout=timeout_s, follow_redirects=True) as client: + async with httpx.AsyncClient(timeout=timeout_s, follow_redirects=True, trust_env=False) as client: resp = await client.get(list_url, headers=headers) if resp.status_code >= 400: raise RuntimeError( From 9917c8f1cf1055e2fb2171103f1413634a2b4950 Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Tue, 12 May 2026 16:18:30 +0800 Subject: [PATCH 18/25] Create a session with trust_env=False to ignore proxy environment variables --- sdk/nexent/core/models/embedding_model.py | 12 ++++++++++-- sdk/nexent/core/models/openai_llm.py | 16 +++++++--------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/sdk/nexent/core/models/embedding_model.py b/sdk/nexent/core/models/embedding_model.py index 092877941..a7379efcb 100644 --- a/sdk/nexent/core/models/embedding_model.py +++ b/sdk/nexent/core/models/embedding_model.py @@ -171,6 +171,10 @@ def __init__( self.model = model_name self.embedding_dim = embedding_dim self.ssl_verify = ssl_verify + + # Create a session with trust_env=False to ignore proxy environment variables + self.session = requests.Session() + self.session.trust_env = False self.headers = {"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}"} @@ -189,7 +193,7 @@ def _make_request(self, data: Dict[str, Any], timeout: Optional[float] = None) - Returns: Dict[str, Any]: API response """ - response = requests.post(self.api_url, headers=self.headers, json=data, timeout=timeout, verify=self.ssl_verify) + response = self.session.post(self.api_url, headers=self.headers, json=data, timeout=timeout, verify=self.ssl_verify) response.raise_for_status() return response.json() @@ -332,6 +336,10 @@ def __init__(self, model_name: str, base_url: str, api_key: str, embedding_dim: self.headers = {"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}"} + # Create a session with trust_env=False to ignore proxy environment variables + self.session = requests.Session() + self.session.trust_env = False + def _prepare_input(self, inputs: Union[str, List[str]]) -> Dict[str, Any]: """Prepare the input data for the API request.""" if isinstance(inputs, str): @@ -349,7 +357,7 @@ def _make_request(self, data: Dict[str, Any], timeout: Optional[float] = None) - Returns: Dict[str, Any]: API response """ - response = requests.post(self.api_url, headers=self.headers, json=data, timeout=timeout, verify=self.ssl_verify) + response = self.session.post(self.api_url, headers=self.headers, json=data, timeout=timeout, verify=self.ssl_verify) response.raise_for_status() return response.json() diff --git a/sdk/nexent/core/models/openai_llm.py b/sdk/nexent/core/models/openai_llm.py index 4c41e0021..02c4f74bd 100644 --- a/sdk/nexent/core/models/openai_llm.py +++ b/sdk/nexent/core/models/openai_llm.py @@ -56,15 +56,13 @@ def __init__(self, observer: MessageObserver = MessageObserver, temperature=0.2, if concurrency_limit is not None and concurrency_limit > 0: self._semaphore = asyncio.Semaphore(concurrency_limit) - # Create http_client based on ssl_verify parameter and timeout_seconds - if not ssl_verify or timeout_seconds is not None: - import httpx - # Build timeout configuration - timeout = httpx.Timeout(timeout_seconds) if timeout_seconds is not None else httpx.Timeout(120.0) - http_client = httpx.Client(verify=ssl_verify, timeout=timeout) - client_kwargs = kwargs.get('client_kwargs', {}) - client_kwargs['http_client'] = http_client - kwargs['client_kwargs'] = client_kwargs + # Create http_client with trust_env=False to ignore proxy env vars + import httpx + timeout = httpx.Timeout(timeout_seconds) if timeout_seconds is not None else httpx.Timeout(120.0) + http_client = httpx.Client(verify=ssl_verify, timeout=timeout, trust_env=False) + client_kwargs = kwargs.get('client_kwargs', {}) + client_kwargs['http_client'] = http_client + kwargs['client_kwargs'] = client_kwargs super().__init__(*args, **kwargs) From 4b922a98a9d784ff94ed1ebbff04679ee07bffc9 Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Tue, 12 May 2026 19:26:57 +0800 Subject: [PATCH 19/25] =?UTF-8?q?=E8=AE=BE=E7=BD=AEgenerate=5Ftitle?= =?UTF-8?q?=E4=B8=BA=E9=9D=9E=E6=B5=81=E5=BC=8F=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/conversation_management_service.py | 4 ++-- sdk/nexent/core/models/openai_llm.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index c3571fcf3..f03c32512 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -275,8 +275,8 @@ def call_llm_for_title(question: str, tenant_id: str, language: str = LANGUAGE[" if model_config.get("model_factory", "").lower() == "modelengine": messages = [{"role": msg["role"], "content": str(msg.get("content", ""))} for msg in messages] - # Call the model - response = llm.generate(messages) + # Call the model with stream=False to get a single response + response = llm.generate(messages, stream=False) if not response or not response.content or not response.content.strip(): return DEFAULT_EN_TITLE if language == LANGUAGE["EN"] else DEFAULT_ZH_TITLE return remove_think_blocks(response.content.strip()) diff --git a/sdk/nexent/core/models/openai_llm.py b/sdk/nexent/core/models/openai_llm.py index 02c4f74bd..918e362a3 100644 --- a/sdk/nexent/core/models/openai_llm.py +++ b/sdk/nexent/core/models/openai_llm.py @@ -142,7 +142,7 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List completion_kwargs["stream_options"] = {"include_usage": True} current_request = self.client.chat.completions.create( - stream=True, **completion_kwargs) + stream=kwargs.get("stream", True), **completion_kwargs) # Validate response type: ensure we got a proper iterator, not error strings or dicts # Some APIs return error strings like "error: rate limit" or JSON dicts on failure From 09258eef2e1464c355f1611e7422e20fb7424b4b Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Tue, 12 May 2026 20:01:49 +0800 Subject: [PATCH 20/25] =?UTF-8?q?Revert=20"=E8=AE=BE=E7=BD=AEgenerate=5Fti?= =?UTF-8?q?tle=E4=B8=BA=E9=9D=9E=E6=B5=81=E5=BC=8F=E6=8E=A5=E5=8F=A3"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit d1cffeb589b3ea2cb735d42d4d1ab7f61e125b39. --- backend/services/conversation_management_service.py | 4 ++-- sdk/nexent/core/models/openai_llm.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index f03c32512..c3571fcf3 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -275,8 +275,8 @@ def call_llm_for_title(question: str, tenant_id: str, language: str = LANGUAGE[" if model_config.get("model_factory", "").lower() == "modelengine": messages = [{"role": msg["role"], "content": str(msg.get("content", ""))} for msg in messages] - # Call the model with stream=False to get a single response - response = llm.generate(messages, stream=False) + # Call the model + response = llm.generate(messages) if not response or not response.content or not response.content.strip(): return DEFAULT_EN_TITLE if language == LANGUAGE["EN"] else DEFAULT_ZH_TITLE return remove_think_blocks(response.content.strip()) diff --git a/sdk/nexent/core/models/openai_llm.py b/sdk/nexent/core/models/openai_llm.py index 918e362a3..02c4f74bd 100644 --- a/sdk/nexent/core/models/openai_llm.py +++ b/sdk/nexent/core/models/openai_llm.py @@ -142,7 +142,7 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List completion_kwargs["stream_options"] = {"include_usage": True} current_request = self.client.chat.completions.create( - stream=kwargs.get("stream", True), **completion_kwargs) + stream=True, **completion_kwargs) # Validate response type: ensure we got a proper iterator, not error strings or dicts # Some APIs return error strings like "error: rate limit" or JSON dicts on failure From 59fed2fe7c4584cdd5d3ca2f013d112918a23e4f Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Tue, 12 May 2026 20:25:32 +0800 Subject: [PATCH 21/25] =?UTF-8?q?"=E8=AE=BE=E7=BD=AEgenerate=5Ftitle?= =?UTF-8?q?=E4=B8=BA=E9=9D=9E=E6=B5=81=E5=BC=8F=E6=8E=A5=E5=8F=A3"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/conversation_management_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index c3571fcf3..302ec63a8 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -260,6 +260,7 @@ def call_llm_for_title(question: str, tenant_id: str, language: str = LANGUAGE[" model_factory=model_config.get("model_factory", None), ssl_verify=model_config.get("ssl_verify", True), timeout_seconds=timeout_seconds, + stream=False, ) # Build messages - use new template variable 'question' instead of 'content' From 10a89e8a9ac0138d0a78671296edefe107bd63f0 Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Wed, 13 May 2026 14:19:10 +0800 Subject: [PATCH 22/25] =?UTF-8?q?=E8=AE=BE=E7=BD=AEauthorization=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E4=B9=9F=E4=B8=BA=E5=AF=86=E7=A0=81=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agentConfig/tool/ToolConfigModal.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx index 53c6d3f03..39c3bbce2 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx @@ -1474,10 +1474,21 @@ export default function ToolConfigModal({ case TOOL_PARAM_TYPES.ARRAY: case TOOL_PARAM_TYPES.OBJECT: default: - // Check if parameter name contains "password" for secure input - const isPasswordType = param.name.toLowerCase().includes("password"); + // Check if parameter name indicates a secure/sensitive field + const sensitivePatterns = [ + "password", + "authorization", + "api_key", + "apikey", + "api-key", + "secret", + "token", + ]; + const isSecureField = sensitivePatterns.some((pattern) => + param.name.toLowerCase().includes(pattern) + ); - if (isPasswordType) { + if (isSecureField) { return ( Date: Wed, 13 May 2026 18:18:25 +0800 Subject: [PATCH 23/25] =?UTF-8?q?=E5=A6=82=E6=9E=9C=E6=98=AF=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E7=9F=A5=E8=AF=86=E5=BA=93=EF=BC=8C=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=BB=98=E8=AE=A4id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/haotian_service.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/services/haotian_service.py b/backend/services/haotian_service.py index 97c5db564..e7f762244 100644 --- a/backend/services/haotian_service.py +++ b/backend/services/haotian_service.py @@ -11,6 +11,8 @@ logger = logging.getLogger("haotian_service") +_DEFAULT_KNOWLEDGE_BASE_ID = "abcdefg" + def _normalize_list_payload(raw: Dict[str, Any]) -> Dict[str, Any]: """ @@ -24,7 +26,7 @@ def _normalize_list_payload(raw: Dict[str, Any]) -> Dict[str, Any]: ] } - This function also filters out knowledge sets with name == "Public". + When dify_dataset_id is "null", it is replaced with the default ID. """ knowledge_sets = raw.get("knowledge_sets", []) if not isinstance(knowledge_sets, list): @@ -35,7 +37,7 @@ def _normalize_list_payload(raw: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(ks, dict): continue set_name = str(ks.get("name", "") or "").strip() - if not set_name or set_name == "Public": + if not set_name: continue bases = ks.get("knowledge_bases", []) @@ -48,15 +50,18 @@ def _normalize_list_payload(raw: Dict[str, Any]) -> Dict[str, Any]: continue dataset_id = str(kb.get("dify_dataset_id", "") or "").strip() kb_name = str(kb.get("name", "") or "").strip() - if not dataset_id or not kb_name: + if not kb_name: continue + if dataset_id == "null" or not dataset_id: + dataset_id = _DEFAULT_KNOWLEDGE_BASE_ID normalized_bases.append( {"dify_dataset_id": dataset_id, "name": kb_name} ) - normalized_sets.append( - {"name": set_name, "knowledge_bases": normalized_bases} - ) + if normalized_bases: + normalized_sets.append( + {"name": set_name, "knowledge_bases": normalized_bases} + ) return {"knowledge_sets": normalized_sets} From 0eb02b09236c36420f488f11c951c8af6d6deef5 Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Thu, 14 May 2026 14:58:37 +0800 Subject: [PATCH 24/25] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E6=95=B0=E9=87=8F=E7=9A=84=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/services/modelService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/services/modelService.ts b/frontend/services/modelService.ts index 07796d2c4..58e9b9887 100644 --- a/frontend/services/modelService.ts +++ b/frontend/services/modelService.ts @@ -727,6 +727,7 @@ export const modelService = { maximum_chunk_size: params.maximumChunkSize, chunk_batch: params.chunkingBatchSize, timeout_seconds: params.timeoutSeconds, + concurrency_limit: params.concurrencyLimit, }; // Add STT specific fields From 00e574977bb4173a70200b4e73fd4fa4fde941c0 Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Fri, 15 May 2026 19:09:33 +0800 Subject: [PATCH 25/25] Bugfix: Resolve frontend cache issue when only one model is available --- backend/utils/llm_utils.py | 9 +++++++ .../agentInfo/AgentGenerateDetail.tsx | 27 ++++++++++++++++--- frontend/types/agentConfig.ts | 2 +- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/backend/utils/llm_utils.py b/backend/utils/llm_utils.py index 53c23aa7b..7d6b0dc17 100644 --- a/backend/utils/llm_utils.py +++ b/backend/utils/llm_utils.py @@ -103,6 +103,15 @@ def call_llm_for_system_prompt( reasoning_content_seen = False content_tokens_seen = 0 for chunk in current_request: + # Safety check: skip non-standard chunks that lack expected attributes + if not hasattr(chunk, 'choices'): + if hasattr(chunk, '__str__'): + logger.warning(f"Received non-standard chunk (no 'choices'): {str(chunk)[:200]}") + continue + + if not chunk.choices: + continue + delta = chunk.choices[0].delta reasoning_content = getattr(delta, "reasoning_content", None) new_token = delta.content diff --git a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx index c7c238a83..ad49c0516 100644 --- a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx +++ b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx @@ -278,6 +278,21 @@ export default function AgentGenerateDetail({ delete initialAgentInfo.group_ids; } + // Check if the agent's model is still available + const agentModelAvailable = availableLlmModels.some( + (m) => m.name === editedAgent.model || m.displayName === editedAgent.model + ); + let effectiveMainAgentModel = initialAgentInfo.mainAgentModel; + let effectiveMainAgentModelId = editedAgent.model_id || 0; + + if (!agentModelAvailable && defaultLlmModel) { + // Agent's original model is no longer available, switch to default model + effectiveMainAgentModel = defaultLlmModel.displayName || ""; + effectiveMainAgentModelId = defaultLlmModel.id || 0; + // Update the initialAgentInfo with the new model + initialAgentInfo.mainAgentModel = effectiveMainAgentModel; + } + const initialBusinessInfo = { businessDescription: editedAgent.business_description || "", businessLogicModelName: @@ -291,12 +306,18 @@ export default function AgentGenerateDetail({ setBusinessInfo(initialBusinessInfo); form.setFieldsValue(initialAgentInfo); - // Sync model to store if not already set (e.g., in create mode with default model) + // Sync model to store (use default model if original is unavailable) if (isCreatingMode && defaultLlmModel) { updateProfileInfo({ model: defaultLlmModel.displayName || "", model_id: defaultLlmModel.id || 0, }); + } else if (!agentModelAvailable && defaultLlmModel) { + // Update model in store when original model is no longer available + updateProfileInfo({ + model: effectiveMainAgentModel, + model_id: effectiveMainAgentModelId, + }); } // Sync max_step to store in create mode (default to 5) if (isCreatingMode && !editedAgent.max_step) { @@ -310,7 +331,7 @@ export default function AgentGenerateDetail({ }); } - }, [currentAgentId, defaultLlmModel?.id, isCreatingMode, forceRefreshKey]); + }, [currentAgentId, defaultLlmModel, isCreatingMode, forceRefreshKey, availableLlmModels]); // Default to selecting all groups when creating a new agent. // Only applies when groups are loaded and no group is selected yet. @@ -609,7 +630,7 @@ export default function AgentGenerateDetail({ { agent_id: effectiveAgentId, task_description: businessInfo.businessDescription, - model_id: businessInfo.businessLogicModelId.toString(), + model_id: businessInfo.businessLogicModelId, sub_agent_ids: editedAgent.sub_agent_id_list, tool_ids: Array.isArray(editedAgent.tools) ? editedAgent.tools.map((tool: any) => diff --git a/frontend/types/agentConfig.ts b/frontend/types/agentConfig.ts index e6d36daaf..c0fd007fc 100644 --- a/frontend/types/agentConfig.ts +++ b/frontend/types/agentConfig.ts @@ -407,7 +407,7 @@ export interface McpContainer { export interface GeneratePromptParams { agent_id: number; task_description: string; - model_id: string; + model_id: number; tool_ids?: number[]; // Optional: tool IDs selected in frontend (takes precedence over database query) sub_agent_ids?: number[]; // Optional: sub-agent IDs selected in frontend (takes precedence over database query) /**