diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index 08c14d2..a007db4 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -2,7 +2,8 @@ import { convertData } from '../convert/convert.js'; import { MODEL_PROVIDER } from '../utils/common.js'; /** - * Provider model catalogs used by the Web UI. + * 各提供商支持的模型列表 + * 用于前端UI选择不支持的模型 */ export const PROVIDER_MODELS = { 'gemini-cli-oauth': [ @@ -49,7 +50,9 @@ export const PROVIDER_MODELS = { 'vision-model' ], 'openai-iflow': [ + // iFlow 特有模型 'iflow-rome-30ba3b', + // Qwen 模型 'qwen3-coder-plus', 'qwen3-max', 'qwen3-vl-plus', @@ -58,12 +61,16 @@ export const PROVIDER_MODELS = { 'qwen3-235b-a22b-thinking-2507', 'qwen3-235b-a22b-instruct', 'qwen3-235b', + // Kimi 模型 'kimi-k2-0905', 'kimi-k2', + // GLM 模型 'glm-4.6', + // DeepSeek 模型 'deepseek-v3.2', 'deepseek-r1', 'deepseek-v3', + // 手动定义 'glm-4.7', 'glm-5', 'kimi-k2.5', @@ -181,15 +188,16 @@ export function getConfiguredSupportedModels(providerType, providerConfig = {}) } /** - * Gets models supported by a provider type. - * @param {string} providerType - * @returns {Array} + * 获取指定提供商类型支持的模型列表 + * @param {string} providerType - 提供商类型 + * @returns {Array} 模型列表 */ export function getProviderModels(providerType) { if (PROVIDER_MODELS[providerType]) { return PROVIDER_MODELS[providerType]; } + // 尝试前缀匹配 (例如 openai-custom-1 -> openai-custom) for (const key of Object.keys(PROVIDER_MODELS)) { if (providerType.startsWith(key + '-')) { return PROVIDER_MODELS[key]; @@ -199,6 +207,10 @@ export function getProviderModels(providerType) { return []; } +/** + * 获取所有提供商的模型列表 + * @returns {Object} 所有提供商的模型映射 + */ export function getAllProviderModels() { return PROVIDER_MODELS; } diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index cb934a9..f1d6bea 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -176,6 +176,14 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await providerApi.handleDetectProviderModels(req, res, currentConfig, providerPoolManager, providerType, providerUuid); } + // Perform health check for a specific provider node + const singleHealthCheckMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)\/health-check$/); + if (method === 'POST' && singleHealthCheckMatch) { + const providerType = decodeURIComponent(singleHealthCheckMatch[1]); + const providerUuid = singleHealthCheckMatch[2]; + return await providerApi.handleSingleProviderHealthCheck(req, res, currentConfig, providerPoolManager, providerType, providerUuid); + } + // Delete all unhealthy providers for a specific type // NOTE: This must be before the generic /{providerType}/{uuid} route to avoid matching 'delete-unhealthy' as UUID const deleteUnhealthyMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/delete-unhealthy$/); diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 566eb09..7d680ca 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -117,6 +117,86 @@ function getManagedSupportedModels(providerType, providers = []) { ); } +function persistProviderStatusToFile(currentConfig, providerPoolManager) { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + const providerPools = {}; + + for (const providerType in providerPoolManager.providerStatus) { + providerPools[providerType] = providerPoolManager.providerStatus[providerType].map(providerStatus => providerStatus.config); + } + + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + return filePath; +} + +function isAuthHealthCheckError(errorMessage = '') { + return /\b(401|403)\b/.test(errorMessage) || + /\b(Unauthorized|Forbidden|AccessDenied|InvalidToken|ExpiredToken)\b/i.test(errorMessage); +} + +async function runProviderHealthCheck(providerPoolManager, providerType, providerStatus) { + const providerConfig = providerStatus.config; + + try { + const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig); + + if (healthResult.success) { + providerPoolManager.markProviderHealthy(providerType, providerConfig, false, healthResult.modelName); + return { + uuid: providerConfig.uuid, + success: true, + healthy: true, + modelName: healthResult.modelName, + message: 'Healthy' + }; + } + + const errorMessage = healthResult.errorMessage || 'Check failed'; + const isAuthError = isAuthHealthCheckError(errorMessage); + + if (isAuthError) { + providerPoolManager.markProviderUnhealthyImmediately(providerType, providerConfig, errorMessage); + logger.info(`[UI API] Auth error detected for ${providerConfig.uuid}, immediately marked as unhealthy`); + } else { + providerPoolManager.markProviderUnhealthy(providerType, providerConfig, errorMessage); + } + + providerStatus.config.lastHealthCheckTime = new Date().toISOString(); + if (healthResult.modelName) { + providerStatus.config.lastHealthCheckModel = healthResult.modelName; + } + + return { + uuid: providerConfig.uuid, + success: false, + healthy: false, + modelName: healthResult.modelName, + message: errorMessage, + isAuthError + }; + } catch (error) { + const errorMessage = error.message || 'Unknown error'; + const isAuthError = isAuthHealthCheckError(errorMessage); + + if (isAuthError) { + providerPoolManager.markProviderUnhealthyImmediately(providerType, providerConfig, errorMessage); + logger.info(`[UI API] Auth error detected for ${providerConfig.uuid}, immediately marked as unhealthy`); + } else { + providerPoolManager.markProviderUnhealthy(providerType, providerConfig, errorMessage); + } + + providerStatus.config.lastHealthCheckTime = new Date().toISOString(); + + return { + uuid: providerConfig.uuid, + success: false, + healthy: false, + message: errorMessage, + isAuthError + }; + } +} + // 使用 Promise 链式队列,确保文件操作顺序执行 let _fileLockChain = Promise.resolve(); @@ -1144,6 +1224,59 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan * 快速链接配置文件到对应的提供商 * 支持单个文件路径或文件路径数组 */ +export async function handleSingleProviderHealthCheck(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { + try { + if (!providerPoolManager) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pool manager not initialized' } })); + return true; + } + + const providers = providerPoolManager.providerStatus[providerType] || []; + const providerStatus = providers.find(item => item.config?.uuid === providerUuid); + + if (!providerStatus) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider not found' } })); + return true; + } + + logger.info(`[UI API] Starting single health check for provider ${providerUuid} in ${providerType}`); + + const result = await runProviderHealthCheck(providerPoolManager, providerType, providerStatus); + const filePath = persistProviderStatusToFile(currentConfig, providerPoolManager); + + broadcastEvent('config_update', { + action: 'health_check_single', + filePath, + providerType, + providerUuid, + result: { + ...result, + message: sanitizeProviderData({ message: result.message }).message + }, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + providerType, + uuid: providerUuid, + healthy: result.healthy, + modelName: result.modelName || null, + message: result.message, + isAuthError: result.isAuthError || false + })); + return true; + } catch (error) { + logger.error('[UI API] Single health check error:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } +} + export async function handleQuickLinkProvider(req, res, currentConfig, providerPoolManager) { try { const body = await getRequestBody(req); diff --git a/static/app/i18n.js b/static/app/i18n.js index e553116..7b496f5 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -592,6 +592,11 @@ const translations = { 'modal.provider.refreshUnhealthyUuids.failed': '刷新失败', 'modal.provider.kiroAuthHint': '使用 AWS Builder ID 登录方式时,需要 clientIdclientSecret 字段,可在同文件夹下的另一个 JSON 文件中获取', + 'modal.provider.healthCheckCurrentTitle': '对当前节点立即执行一次健康检查', + 'modal.provider.healthCheckSingleSuccess': '健康检查通过', + 'modal.provider.healthCheckSingleSuccessWithModel': '健康检查通过,使用模型: {model}', + 'modal.provider.healthCheckSingleFailed': '健康检查失败: {message}', + // Pagination 'pagination.showing': '显示 {start}-{end} / 共 {total} 条', 'pagination.jumpTo': '跳转到', @@ -1474,6 +1479,10 @@ const translations = { 'modal.provider.refreshUnhealthyUuids.success': 'Refreshed {count} UUID(s)', 'modal.provider.refreshUnhealthyUuids.failed': 'Refresh failed', 'modal.provider.kiroAuthHint': 'When using AWS Builder ID login, clientId and clientSecret fields are required, which can be found in another JSON file in the same folder', + 'modal.provider.healthCheckCurrentTitle': 'Run a health check for this node now', + 'modal.provider.healthCheckSingleSuccess': 'Health check passed', + 'modal.provider.healthCheckSingleSuccessWithModel': 'Health check passed using model: {model}', + 'modal.provider.healthCheckSingleFailed': 'Health check failed: {message}', // Pagination 'pagination.showing': 'Showing {start}-{end} of {total}', diff --git a/static/app/modal.js b/static/app/modal.js index 63491bc..404df5f 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -796,6 +796,9 @@ function renderProviderList(providers) { + @@ -1268,6 +1271,9 @@ function cancelEdit(uuid, event) { + @@ -1813,6 +1819,67 @@ async function performHealthCheck(providerType) { * @param {string} uuid - 提供商UUID * @param {Event} event - 事件对象 */ +async function performSingleHealthCheck(uuid, event) { + event.stopPropagation(); + + const button = event.currentTarget || event.target.closest('button'); + const providerDetail = event.target.closest('.provider-item-detail'); + const providerType = providerDetail?.closest('.provider-modal')?.getAttribute('data-provider-type'); + + if (!providerDetail || !providerType) { + showToast(t('common.error'), t('modal.provider.healthCheckSingleFailed', { message: t('common.error') }), 'error'); + return; + } + + const originalHtml = button ? button.innerHTML : ''; + + try { + if (button) { + button.disabled = true; + button.innerHTML = ` ${t('modal.provider.healthCheck')}`; + } + + showToast(t('common.info'), t('modal.provider.healthCheck') + '...', 'info'); + + const response = await window.apiClient.post( + `/providers/${encodeURIComponent(providerType)}/${uuid}/health-check`, + {} + ); + + if (!response.success) { + showToast(t('common.error'), t('modal.provider.healthCheckSingleFailed', { message: t('common.error') }), 'error'); + return; + } + + const message = response.healthy + ? (response.modelName + ? t('modal.provider.healthCheckSingleSuccessWithModel', { model: response.modelName }) + : t('modal.provider.healthCheckSingleSuccess')) + : t('modal.provider.healthCheckSingleFailed', { message: response.message || t('common.error') }); + + showToast( + response.healthy ? t('common.success') : t('common.warning'), + message, + response.healthy ? 'success' : 'warning' + ); + + await window.apiClient.post('/reload-config'); + await refreshProviderConfig(providerType); + } catch (error) { + console.error('Single provider health check failed:', error); + showToast( + t('common.error'), + t('modal.provider.healthCheckSingleFailed', { message: error.message }), + 'error' + ); + } finally { + if (button && button.isConnected) { + button.innerHTML = originalHtml; + button.disabled = false; + } + } +} + async function refreshProviderUuid(uuid, event) { event.stopPropagation(); @@ -1993,6 +2060,7 @@ export { loadModelsForProviderType, renderNotSupportedModelsSelector, goToProviderPage, + performSingleHealthCheck, refreshProviderUuid }; @@ -2008,6 +2076,7 @@ window.addProvider = addProvider; window.toggleProviderStatus = toggleProviderStatus; window.resetAllProvidersHealth = resetAllProvidersHealth; window.performHealthCheck = performHealthCheck; +window.performSingleHealthCheck = performSingleHealthCheck; window.deleteUnhealthyProviders = deleteUnhealthyProviders; window.refreshUnhealthyUuids = refreshUnhealthyUuids; window.openSupportedModelsPicker = openSupportedModelsPicker;