diff --git a/i18n.js b/i18n.js index d8bc1c1..bfef89e 100644 --- a/i18n.js +++ b/i18n.js @@ -238,6 +238,15 @@ const i18n = { 'ai_providers.openai_delete_confirm': '确定要删除这个OpenAI提供商吗?', 'ai_providers.openai_keys_count': '密钥数量', 'ai_providers.openai_models_count': '模型数量', + 'ai_providers.openai_test_title': '连通性测试', + 'ai_providers.openai_test_hint': '使用当前配置向 /v1/chat/completions 请求,验证是否可用。', + 'ai_providers.openai_test_model_placeholder': '选择或输入要测试的模型', + 'ai_providers.openai_test_action': '发送测试', + 'ai_providers.openai_test_running': '正在发送测试请求...', + 'ai_providers.openai_test_success': '测试成功,模型可用。', + 'ai_providers.openai_test_failed': '测试失败', + 'ai_providers.openai_test_select_placeholder': '从当前模型列表选择', + 'ai_providers.openai_test_select_empty': '当前未配置模型,可直接输入', // 认证文件管理 @@ -619,6 +628,9 @@ const i18n = { 'notification.openai_provider_updated': 'OpenAI提供商更新成功', 'notification.openai_provider_deleted': 'OpenAI提供商删除成功', 'notification.openai_model_name_required': '请填写模型名称', + 'notification.openai_test_url_required': '请先填写有效的 Base URL 以进行测试', + 'notification.openai_test_key_required': '请至少填写一个 API 密钥以进行测试', + 'notification.openai_test_model_required': '请选择或输入要测试的模型', 'notification.data_refreshed': '数据刷新成功', 'notification.connection_required': '请先建立连接', 'notification.refresh_failed': '刷新失败', @@ -893,6 +905,15 @@ const i18n = { 'ai_providers.openai_delete_confirm': 'Are you sure you want to delete this OpenAI provider?', 'ai_providers.openai_keys_count': 'Keys Count', 'ai_providers.openai_models_count': 'Models Count', + 'ai_providers.openai_test_title': 'Connection Test', + 'ai_providers.openai_test_hint': 'Send a /v1/chat/completions request with the current settings to verify availability.', + 'ai_providers.openai_test_model_placeholder': 'Model to test', + 'ai_providers.openai_test_action': 'Run Test', + 'ai_providers.openai_test_running': 'Sending test request...', + 'ai_providers.openai_test_success': 'Test succeeded. The model responded.', + 'ai_providers.openai_test_failed': 'Test failed', + 'ai_providers.openai_test_select_placeholder': 'Choose from current models', + 'ai_providers.openai_test_select_empty': 'No models configured, enter manually', // Auth files management @@ -1273,6 +1294,9 @@ const i18n = { 'notification.openai_provider_updated': 'OpenAI provider updated successfully', 'notification.openai_provider_deleted': 'OpenAI provider deleted successfully', 'notification.openai_model_name_required': 'Model name is required', + 'notification.openai_test_url_required': 'Please provide a valid Base URL before testing', + 'notification.openai_test_key_required': 'Please add at least one API key before testing', + 'notification.openai_test_model_required': 'Please select or enter a model to test', 'notification.data_refreshed': 'Data refreshed successfully', 'notification.connection_required': 'Please establish connection first', 'notification.refresh_failed': 'Refresh failed', diff --git a/src/modules/ai-providers.js b/src/modules/ai-providers.js index 2bb5e01..ab3e5c9 100644 --- a/src/modules/ai-providers.js +++ b/src/modules/ai-providers.js @@ -23,6 +23,19 @@ const buildModelEndpoint = (baseUrl) => { return `${trimmed}/v1/models`; }; +const buildChatCompletionsEndpoint = (baseUrl) => { + if (!baseUrl) return ''; + const trimmed = String(baseUrl).trim().replace(/\/+$/g, ''); + if (!trimmed) return ''; + if (trimmed.endsWith('/chat/completions')) { + return trimmed; + } + if (trimmed.endsWith('/v1')) { + return `${trimmed}/chat/completions`; + } + return `${trimmed}/v1/chat/completions`; +}; + const normalizeExcludedModels = (input) => { const rawList = Array.isArray(input) ? input @@ -1336,6 +1349,9 @@ export function applyOpenAIModelDiscoverySelection() { }); this.populateModelFields(context.modelWrapperId, Array.from(mergedMap.values())); + if (context.mode === 'edit' && typeof this.populateOpenAITestModelOptions === 'function') { + this.populateOpenAITestModelOptions(Array.from(mergedMap.values()), { preserveInput: true }); + } this.closeOpenAIModelDiscovery(); if (addedCount > 0) { @@ -1353,6 +1369,180 @@ export function closeOpenAIModelDiscovery() { this.openAIModelDiscoveryContext = null; } +export function populateOpenAITestModelOptions(models = [], { preserveInput = true } = {}) { + const select = document.getElementById('openai-test-model-select'); + const input = document.getElementById('openai-test-model-input'); + if (!select) return; + + const names = []; + const seen = new Set(); + (Array.isArray(models) ? models : []).forEach(model => { + const name = model?.name ? String(model.name).trim() : ''; + if (!name || seen.has(name)) return; + seen.add(name); + names.push(name); + }); + + if (!names.length) { + select.disabled = true; + select.innerHTML = ``; + if (input && !preserveInput) { + input.value = ''; + } + return; + } + + select.disabled = false; + const placeholder = ``; + const options = names.map(name => ``).join(''); + select.innerHTML = `${placeholder}${options}`; + + if (input) { + if (!preserveInput || !input.value) { + const firstName = names[0]; + if (firstName) { + input.value = firstName; + select.value = firstName; + return; + } + } + + const current = input.value.trim(); + if (current && names.includes(current)) { + select.value = current; + } else { + select.value = ''; + } + } +} + +export function setOpenAITestStatus(message = '', type = 'info') { + const statusEl = document.getElementById('openai-test-status'); + if (!statusEl) return; + statusEl.textContent = message || ''; + statusEl.className = `openai-test-status ${type || ''}`.trim(); +} + +const setOpenAITestButtonState = (state = 'idle') => { + const button = document.getElementById('openai-test-button'); + if (!button) return; + button.disabled = state === 'loading'; + button.classList.remove('openai-test-btn-success', 'openai-test-btn-error'); + + switch (state) { + case 'loading': + button.innerHTML = ``; + break; + case 'success': + button.classList.add('openai-test-btn-success'); + button.innerHTML = ``; + break; + case 'error': + button.classList.add('openai-test-btn-error'); + button.innerHTML = ``; + break; + default: + button.innerHTML = ` ${i18n.t('ai_providers.openai_test_action')}`; + break; + } +}; + +export async function testOpenAIProviderConnection() { + const baseUrlInput = document.getElementById('edit-provider-url'); + const baseUrl = baseUrlInput ? baseUrlInput.value.trim() : ''; + if (!baseUrl) { + const message = i18n.t('notification.openai_test_url_required'); + this.setOpenAITestStatus(message, 'error'); + this.showNotification(message, 'error'); + return; + } + + const endpoint = buildChatCompletionsEndpoint(baseUrl); + if (!endpoint) { + const message = i18n.t('notification.openai_test_url_required'); + this.setOpenAITestStatus(message, 'error'); + this.showNotification(message, 'error'); + return; + } + + const apiKeyEntries = this.collectApiKeyEntryInputs('edit-openai-keys-wrapper'); + const firstKeyEntry = Array.isArray(apiKeyEntries) ? apiKeyEntries.find(entry => entry && entry['api-key']) : null; + if (!firstKeyEntry) { + const message = i18n.t('notification.openai_test_key_required'); + this.setOpenAITestStatus(message, 'error'); + this.showNotification(message, 'error'); + return; + } + + const models = this.collectModelInputs('edit-provider-models-wrapper'); + this.populateOpenAITestModelOptions(models); + + const modelInput = document.getElementById('openai-test-model-input'); + let modelName = modelInput ? modelInput.value.trim() : ''; + if (!modelName) { + const firstModel = Array.isArray(models) ? models.find(model => model && model.name) : null; + if (firstModel && firstModel.name) { + modelName = firstModel.name; + if (modelInput) { + modelInput.value = firstModel.name; + } + } + } + + if (!modelName) { + const message = i18n.t('notification.openai_test_model_required'); + this.setOpenAITestStatus(message, 'error'); + this.showNotification(message, 'error'); + return; + } + + const customHeaders = this.collectHeaderInputs('edit-openai-headers-wrapper') || {}; + const headers = { + 'Content-Type': 'application/json', + ...customHeaders + }; + if (!headers.Authorization && !headers.authorization) { + headers.Authorization = `Bearer ${firstKeyEntry['api-key']}`; + } + + this.setOpenAITestStatus('', 'info'); + setOpenAITestButtonState('loading'); + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify({ + model: modelName, + messages: [{ role: 'user', content: 'Hi' }], + stream: false, + max_tokens: 5 + }) + }); + + const rawText = await response.text(); + + if (!response.ok) { + let errorMessage = `${response.status} ${response.statusText}`; + try { + const parsed = rawText ? JSON.parse(rawText) : null; + errorMessage = parsed?.error?.message || parsed?.message || errorMessage; + } catch (error) { + if (rawText) { + errorMessage = rawText; + } + } + throw new Error(errorMessage); + } + + this.setOpenAITestStatus('', 'info'); + setOpenAITestButtonState('success'); + } catch (error) { + this.setOpenAITestStatus(`${i18n.t('ai_providers.openai_test_failed')}: ${error.message}`, 'error'); + setOpenAITestButtonState('error'); + } +} + export function showAddOpenAIProviderModal() { const modal = document.getElementById('modal'); const modalBody = document.getElementById('modal-body'); @@ -1490,6 +1680,18 @@ export function editOpenAIProvider(index, provider) { +
${i18n.t('ai_providers.openai_test_hint')}
+