diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index b80d4236..8bdfa142 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -402,17 +402,12 @@ pub async fn test_ai_config_connection( result.response_time_ms + image_result.response_time_ms; if !image_result.success { - let image_error = image_result - .error_details - .unwrap_or_else(|| "Unknown image input test error".to_string()); let merged = bitfun_core::util::types::ConnectionTestResult { success: false, response_time_ms, model_response: image_result.model_response.or(result.model_response), - error_details: Some(format!( - "Basic connection passed, but multimodal image input test failed: {}", - image_error - )), + message_code: image_result.message_code, + error_details: image_result.error_details, }; info!( "AI config connection test completed: model={}, success={}, response_time={}ms", @@ -425,7 +420,8 @@ pub async fn test_ai_config_connection( success: true, response_time_ms, model_response: image_result.model_response.or(result.model_response), - error_details: None, + message_code: result.message_code, + error_details: result.error_details, }; info!( "AI config connection test completed: model={}, success={}, response_time={}ms", diff --git a/src/crates/core/src/infrastructure/ai/client.rs b/src/crates/core/src/infrastructure/ai/client.rs index 3409fcb3..b284fc01 100644 --- a/src/crates/core/src/infrastructure/ai/client.rs +++ b/src/crates/core/src/infrastructure/ai/client.rs @@ -156,33 +156,6 @@ impl AIClient { ) } - fn build_test_connection_extra_body(&self) -> Option { - let provider = self.config.format.to_ascii_lowercase(); - if !matches!( - provider.as_str(), - "openai" | "response" | "responses" | "nvidia" | "openrouter" - ) { - return self.config.custom_request_body.clone(); - } - - let mut extra_body = self - .config - .custom_request_body - .clone() - .unwrap_or_else(|| serde_json::json!({})); - - if let Some(extra_obj) = extra_body.as_object_mut() { - extra_obj - .entry("temperature".to_string()) - .or_insert_with(|| serde_json::json!(0)); - extra_obj - .entry("tool_choice".to_string()) - .or_insert_with(|| serde_json::json!("required")); - } - - Some(extra_body) - } - fn is_gemini_api_format(api_format: &str) -> bool { matches!( api_format.to_ascii_lowercase().as_str(), @@ -1942,8 +1915,8 @@ impl AIClient { pub async fn test_connection(&self) -> Result { let start_time = std::time::Instant::now(); - // Force a tool call to avoid false negatives: some models may answer directly when - // `tool_choice=auto`, even if they support tool calls. + // Reuse the normal chat request path so the test matches real conversations, even when + // a provider rejects stricter tool_choice settings such as "required". let test_messages = vec![Message::user( "Call the get_weather tool for city=Beijing. Do not answer with plain text." .to_string(), @@ -1961,14 +1934,7 @@ impl AIClient { }), }]); - let extra_body = self.build_test_connection_extra_body(); - - let result = if extra_body.is_some() { - self.send_message_with_extra_body(test_messages, tools, extra_body) - .await - } else { - self.send_message(test_messages, tools).await - }; + let result = self.send_message(test_messages, tools).await; match result { Ok(response) => { @@ -1978,16 +1944,16 @@ impl AIClient { success: true, response_time_ms, model_response: Some(response.text), + message_code: None, error_details: None, }) } else { Ok(ConnectionTestResult { - success: false, + success: true, response_time_ms, model_response: Some(response.text), - error_details: Some( - "Model did not return tool calls (tool_choice=required).".to_string(), - ), + message_code: Some(ConnectionTestMessageCode::ToolCallsNotDetected), + error_details: None, }) } } @@ -1999,6 +1965,7 @@ impl AIClient { success: false, response_time_ms, model_response: None, + message_code: None, error_details: Some(error_msg), }) } @@ -2059,6 +2026,7 @@ impl AIClient { success: true, response_time_ms: start_time.elapsed().as_millis() as u64, model_response: Some(response.text), + message_code: None, error_details: None, }) } else { @@ -2071,6 +2039,7 @@ impl AIClient { success: false, response_time_ms: start_time.elapsed().as_millis() as u64, model_response: Some(response.text), + message_code: Some(ConnectionTestMessageCode::ImageInputCheckFailed), error_details: Some(detail), }) } @@ -2082,6 +2051,7 @@ impl AIClient { success: false, response_time_ms: start_time.elapsed().as_millis() as u64, model_response: None, + message_code: None, error_details: Some(error_msg), }) } @@ -2130,44 +2100,6 @@ mod tests { }) } - #[test] - fn build_test_connection_extra_body_merges_custom_body_defaults() { - let client = make_test_client( - "responses", - Some(json!({ - "metadata": { - "source": "test" - } - })), - ); - - let extra_body = client - .build_test_connection_extra_body() - .expect("extra body"); - - assert_eq!(extra_body["metadata"]["source"], "test"); - assert_eq!(extra_body["temperature"], 0); - assert_eq!(extra_body["tool_choice"], "required"); - } - - #[test] - fn build_test_connection_extra_body_preserves_existing_tool_choice() { - let client = make_test_client( - "response", - Some(json!({ - "tool_choice": "auto", - "temperature": 0.3 - })), - ); - - let extra_body = client - .build_test_connection_extra_body() - .expect("extra body"); - - assert_eq!(extra_body["tool_choice"], "auto"); - assert_eq!(extra_body["temperature"], 0.3); - } - #[test] fn resolves_openai_models_url_from_completion_endpoint() { let client = AIClient::new(AIConfig { diff --git a/src/crates/core/src/util/types/ai.rs b/src/crates/core/src/util/types/ai.rs index 3735f04a..72a76d9a 100644 --- a/src/crates/core/src/util/types/ai.rs +++ b/src/crates/core/src/util/types/ai.rs @@ -34,6 +34,14 @@ pub struct GeminiUsage { pub cached_content_token_count: Option, } +/// Structured message codes for localized connection test messaging. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionTestMessageCode { + ToolCallsNotDetected, + ImageInputCheckFailed, +} + /// AI connection test result #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConnectionTestResult { @@ -44,7 +52,10 @@ pub struct ConnectionTestResult { /// Model response content (if successful) #[serde(skip_serializing_if = "Option::is_none")] pub model_response: Option, - /// Error details (if failed) + /// Structured message code for localized frontend messaging + #[serde(skip_serializing_if = "Option::is_none")] + pub message_code: Option, + /// Raw error or diagnostic details #[serde(skip_serializing_if = "Option::is_none")] pub error_details: Option, } diff --git a/src/web-ui/src/features/onboarding/components/steps/ModelConfigStep.tsx b/src/web-ui/src/features/onboarding/components/steps/ModelConfigStep.tsx index a87628c5..46ba6d5b 100644 --- a/src/web-ui/src/features/onboarding/components/steps/ModelConfigStep.tsx +++ b/src/web-ui/src/features/onboarding/components/steps/ModelConfigStep.tsx @@ -11,6 +11,7 @@ import { systemAPI } from '@/infrastructure/api'; import { Select, Checkbox, Button, IconButton } from '@/component-library'; import { PROVIDER_TEMPLATES } from '@/infrastructure/config/services/modelConfigs'; import { createLogger } from '@/shared/utils/logger'; +import { translateConnectionTestMessage } from '@/shared/utils/aiConnectionTestMessages'; const log = createLogger('ModelConfigStep'); @@ -39,6 +40,7 @@ export const ModelConfigStep: React.FC = ({ onSkipForNow } ); const [testStatus, setTestStatus] = useState('idle'); const [testError, setTestError] = useState(''); + const [testNotice, setTestNotice] = useState(''); const [remoteModelOptions, setRemoteModelOptions] = useState([]); const [isFetchingRemoteModels, setIsFetchingRemoteModels] = useState(false); const [remoteModelsError, setRemoteModelsError] = useState(''); @@ -250,6 +252,7 @@ export const ModelConfigStep: React.FC = ({ onSkipForNow } setSelectedProviderId(newProviderId); setTestStatus('idle'); setTestError(''); + setTestNotice(''); if (newProviderId === 'custom') { setBaseUrl(''); @@ -274,6 +277,7 @@ export const ModelConfigStep: React.FC = ({ onSkipForNow } setModelName(value); setTestStatus('idle'); setTestError(''); + setTestNotice(''); }, []); // Open help URL @@ -309,6 +313,7 @@ export const ModelConfigStep: React.FC = ({ onSkipForNow } setTestStatus('testing'); setTestError(''); + setTestNotice(''); try { const effectiveBaseUrl = baseUrl || (currentTemplate?.baseUrl || ''); @@ -321,23 +326,31 @@ export const ModelConfigStep: React.FC = ({ onSkipForNow } model_name: effectiveModelName, provider: format }); + const localizedMessage = translateConnectionTestMessage(result.message_code, tAiModel); if (result.success) { setTestStatus('success'); + setTestNotice(localizedMessage || result.error_details || ''); log.info('Connection test passed', { provider: selectedProviderId, modelName: effectiveModelName }); } else { setTestStatus('error'); - const errorMsg = result.error_details - ? `${t('model.testFailed')}\n${result.error_details}` + setTestNotice(''); + const detailLines = [ + localizedMessage, + result.error_details ? `${tAiModel('messages.errorDetails')}: ${result.error_details}` : undefined + ].filter((line): line is string => Boolean(line)); + const errorMsg = detailLines.length > 0 + ? `${t('model.testFailed')}\n${detailLines.join('\n')}` : t('model.testFailed'); setTestError(errorMsg); } } catch (error) { log.error('Connection test failed', error); setTestStatus('error'); + setTestNotice(''); const rawMsg = error instanceof Error ? error.message : String(error); // Tauri command errors often have "Connection test failed: " prefix, extract the actual cause const cleanMsg = rawMsg.replace(/^Connection test failed:\s*/i, ''); @@ -511,6 +524,7 @@ export const ModelConfigStep: React.FC = ({ onSkipForNow } setApiKey(e.target.value); setTestStatus('idle'); setTestError(''); + setTestNotice(''); }} /> {currentTemplate.helpUrl && ( @@ -542,6 +556,7 @@ export const ModelConfigStep: React.FC = ({ onSkipForNow } } setTestStatus('idle'); setTestError(''); + setTestNotice(''); }} placeholder={t('model.baseUrl.placeholder')} options={currentTemplate.baseUrlOptions.map(opt => ({ @@ -561,6 +576,7 @@ export const ModelConfigStep: React.FC = ({ onSkipForNow } setBaseUrl(e.target.value); setTestStatus('idle'); setTestError(''); + setTestNotice(''); }} onFocus={(e) => e.target.select()} /> @@ -587,6 +603,7 @@ export const ModelConfigStep: React.FC = ({ onSkipForNow } setBaseUrl(e.target.value); setTestStatus('idle'); setTestError(''); + setTestNotice(''); }} /> @@ -602,6 +619,7 @@ export const ModelConfigStep: React.FC = ({ onSkipForNow } setModelName(value as string); setTestStatus('idle'); setTestError(''); + setTestNotice(''); }} placeholder={t('model.modelName.placeholder')} options={availableModelOptions} @@ -644,6 +662,7 @@ export const ModelConfigStep: React.FC = ({ onSkipForNow } setApiKey(e.target.value); setTestStatus('idle'); setTestError(''); + setTestNotice(''); }} /> @@ -830,6 +849,13 @@ export const ModelConfigStep: React.FC = ({ onSkipForNow } {testError} )} + + {testStatus === 'success' && testNotice && ( +
+ + {testNotice} +
+ )} )} diff --git a/src/web-ui/src/infrastructure/api/service-api/AIApi.ts b/src/web-ui/src/infrastructure/api/service-api/AIApi.ts index a25722d8..158408eb 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AIApi.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AIApi.ts @@ -3,6 +3,7 @@ import { api } from './ApiClient'; import { createTauriCommandError } from '../errors/TauriCommandError'; import type { SendMessageRequest } from './tauri-commands'; +import type { ConnectionTestMessageCode } from '@/shared/utils/aiConnectionTestMessages'; export interface CreateAISessionRequest { session_id?: string; @@ -19,6 +20,7 @@ export interface ConnectionTestResult { success: boolean; response_time_ms: number; model_response?: string; + message_code?: ConnectionTestMessageCode; error_details?: string; } diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx index f29c1bae..5f67bf94 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx @@ -16,6 +16,7 @@ import { ConfigPageHeader, ConfigPageLayout, ConfigPageContent, ConfigPageSectio import DefaultModelConfig from './DefaultModelConfig'; import TokenStatsModal from './TokenStatsModal'; import { createLogger } from '@/shared/utils/logger'; +import { translateConnectionTestMessage } from '@/shared/utils/aiConnectionTestMessages'; import './AIModelConfig.scss'; const log = createLogger('AIModelConfig'); @@ -752,9 +753,16 @@ const AIModelConfig: React.FC = () => { const result = await aiApi.testAIConfigConnection(config); const baseMessage = result.success ? t('messages.testSuccess') : t('messages.testFailed'); let message = baseMessage + (result.response_time_ms ? ` (${result.response_time_ms}ms)` : ''); + const localizedMessage = translateConnectionTestMessage(result.message_code, t); - if (!result.success && result.error_details) { - message += `\n${t('messages.errorDetails')}: ${result.error_details}`; + if (localizedMessage) { + message += `\n${localizedMessage}`; + } + + if (result.error_details) { + message += result.success + ? `\n${result.error_details}` + : `\n${t('messages.errorDetails')}: ${result.error_details}`; } setTestResults(prev => ({ @@ -818,8 +826,13 @@ const AIModelConfig: React.FC = () => { const baseMessage = result.success ? t('messages.testSuccess') : t('messages.testFailed'); let message = baseMessage + (result.response_time_ms ? ` (${result.response_time_ms}ms)` : ''); + const localizedMessage = translateConnectionTestMessage(result.message_code, t); - if (!result.success && result.error_details) { + if (localizedMessage) { + message += `\n${localizedMessage}`; + } + + if (result.error_details) { message += `\n${t('messages.errorDetails')}: ${result.error_details}`; } diff --git a/src/web-ui/src/locales/en-US/settings/ai-model.json b/src/web-ui/src/locales/en-US/settings/ai-model.json index 06201f55..441318db 100644 --- a/src/web-ui/src/locales/en-US/settings/ai-model.json +++ b/src/web-ui/src/locales/en-US/settings/ai-model.json @@ -243,6 +243,10 @@ "testSuccess": "Test successful", "testFailed": "Test failed", "errorDetails": "Error details", + "connectionTestMessages": { + "toolCallsNotDetected": "This test did not detect any tool calls, so tool calling could not be verified this time.", + "imageInputCheckFailed": "The connection succeeded, but the image input check failed." + }, "autoSetPrimary": "Automatically set as primary model", "defaultDescription": "{{name}} model configuration", "cloneName": "{{name}} - Copy", diff --git a/src/web-ui/src/locales/zh-CN/settings/ai-model.json b/src/web-ui/src/locales/zh-CN/settings/ai-model.json index d8caf209..bd1fb3d3 100644 --- a/src/web-ui/src/locales/zh-CN/settings/ai-model.json +++ b/src/web-ui/src/locales/zh-CN/settings/ai-model.json @@ -243,6 +243,10 @@ "testSuccess": "测试成功", "testFailed": "测试失败", "errorDetails": "详细错误", + "connectionTestMessages": { + "toolCallsNotDetected": "本次测试未检测到工具调用,因此暂时无法验证工具调用能力。", + "imageInputCheckFailed": "连接已成功,但图片输入测试未通过。" + }, "autoSetPrimary": "已自动设为主力模型", "defaultDescription": "{{name}} 模型配置", "cloneName": "{{name}} - 副本", diff --git a/src/web-ui/src/shared/utils/aiConnectionTestMessages.ts b/src/web-ui/src/shared/utils/aiConnectionTestMessages.ts new file mode 100644 index 00000000..5bc243dc --- /dev/null +++ b/src/web-ui/src/shared/utils/aiConnectionTestMessages.ts @@ -0,0 +1,22 @@ +type TranslateFn = (key: string) => string; + +export type ConnectionTestMessageCode = + | 'tool_calls_not_detected' + | 'image_input_check_failed'; + +const MESSAGE_KEY_BY_CODE: Record = { + tool_calls_not_detected: 'messages.connectionTestMessages.toolCallsNotDetected', + image_input_check_failed: 'messages.connectionTestMessages.imageInputCheckFailed', +}; + +export function translateConnectionTestMessage( + messageCode: ConnectionTestMessageCode | undefined, + t: TranslateFn +): string | undefined { + if (!messageCode) { + return undefined; + } + + const translationKey = MESSAGE_KEY_BY_CODE[messageCode]; + return translationKey ? t(translationKey) : undefined; +} diff --git a/src/web-ui/src/shared/utils/configConverter.ts b/src/web-ui/src/shared/utils/configConverter.ts index c672a7f4..dff64b7f 100644 --- a/src/web-ui/src/shared/utils/configConverter.ts +++ b/src/web-ui/src/shared/utils/configConverter.ts @@ -1,6 +1,7 @@ import { ModelConfig } from '../types'; import { aiApi } from '@/infrastructure/api'; import { createLogger } from '@/shared/utils/logger'; +import type { ConnectionTestMessageCode } from './aiConnectionTestMessages'; const log = createLogger('ConfigConverter'); @@ -93,6 +94,7 @@ export interface ConnectionTestResult { success: boolean; response_time_ms: number; model_response?: string; + message_code?: ConnectionTestMessageCode; error_details?: string; }