From 75f2676236637145581ee5298abe3f33799a784b Mon Sep 17 00:00:00 2001 From: HenryZ-0302 <211446046+HenryZ-0302@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:38:01 -0700 Subject: [PATCH] feat: add managed model list picker for custom providers --- src/providers/provider-models.js | 105 ++++-- src/providers/provider-pool-manager.js | 21 +- src/services/ui-manager.js | 12 +- src/ui-modules/provider-api.js | 142 +++++++- static/app/i18n.js | 24 ++ static/app/modal.js | 429 ++++++++++++++++++++++-- static/components/section-providers.css | 168 +++++++++- tests/provider-models.unit.test.js | 33 ++ 8 files changed, 871 insertions(+), 63 deletions(-) create mode 100644 tests/provider-models.unit.test.js diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index 8c815a0..08c14d2 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -1,8 +1,9 @@ -/** - * 各提供商支持的模型列表 - * 用于前端UI选择不支持的模型 - */ +import { convertData } from '../convert/convert.js'; +import { MODEL_PROVIDER } from '../utils/common.js'; +/** + * Provider model catalogs used by the Web UI. + */ export const PROVIDER_MODELS = { 'gemini-cli-oauth': [ 'gemini-2.5-flash', @@ -48,9 +49,7 @@ export const PROVIDER_MODELS = { 'vision-model' ], 'openai-iflow': [ - // iFlow 特有模型 'iflow-rome-30ba3b', - // Qwen 模型 'qwen3-coder-plus', 'qwen3-max', 'qwen3-vl-plus', @@ -59,16 +58,12 @@ 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', @@ -110,30 +105,100 @@ export const PROVIDER_MODELS = { ] }; +export const MANAGED_MODEL_LIST_PROVIDERS = [ + MODEL_PROVIDER.OPENAI_CUSTOM, + MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES +]; + +export function getManagedModelListProviderType(providerType) { + return MANAGED_MODEL_LIST_PROVIDERS.find(baseType => + providerType === baseType || providerType.startsWith(baseType + '-') + ) || null; +} + +export function usesManagedModelList(providerType) { + return getManagedModelListProviderType(providerType) !== null; +} + +export function normalizeModelIds(models = []) { + return [...new Set( + (Array.isArray(models) ? models : []) + .filter(model => typeof model === 'string') + .map(model => model.trim()) + .filter(Boolean) + )].sort((a, b) => a.localeCompare(b)); +} + +function extractModelIdsFromListShape(modelList) { + if (!modelList) { + return []; + } + + if (Array.isArray(modelList)) { + return modelList.map(item => { + if (typeof item === 'string') return item; + return item?.id || item?.name || item?.model || null; + }).filter(Boolean); + } + + if (Array.isArray(modelList.data)) { + return modelList.data.map(item => item?.id || item?.name || item?.model || null).filter(Boolean); + } + + if (Array.isArray(modelList.models)) { + return modelList.models.map(item => { + if (typeof item === 'string') return item; + return item?.id || item?.name || item?.model || null; + }).filter(Boolean); + } + + return []; +} + +export function extractModelIdsFromNativeList(modelList, providerType) { + let convertedModelList = modelList; + + try { + convertedModelList = convertData(modelList, 'modelList', providerType, MODEL_PROVIDER.OPENAI_CUSTOM); + } catch { + convertedModelList = modelList; + } + + const convertedIds = normalizeModelIds(extractModelIdsFromListShape(convertedModelList)); + if (convertedIds.length > 0) { + return convertedIds; + } + + return normalizeModelIds(extractModelIdsFromListShape(modelList)); +} + +export function getConfiguredSupportedModels(providerType, providerConfig = {}) { + if (!usesManagedModelList(providerType)) { + return []; + } + + return normalizeModelIds(providerConfig?.supportedModels); +} + /** - * 获取指定提供商类型支持的模型列表 - * @param {string} providerType - 提供商类型 - * @returns {Array} 模型列表 + * Gets models supported by a provider type. + * @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]; } } - + return []; } -/** - * 获取所有提供商的模型列表 - * @returns {Object} 所有提供商的模型映射 - */ export function getAllProviderModels() { return PROVIDER_MODELS; } diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index ab87f0a..25609cd 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -2,9 +2,13 @@ import * as fs from 'fs'; import { getServiceAdapter, getRegisteredProviders } from './adapter.js'; import logger from '../utils/logger.js'; import { MODEL_PROVIDER, getProtocolPrefix } from '../utils/common.js'; -import { getProviderModels } from './provider-models.js'; -import { broadcastEvent } from '../ui-modules/event-broadcast.js'; import { convertData } from '../convert/convert.js'; +import { + getConfiguredSupportedModels, + getProviderModels, + normalizeModelIds +} from './provider-models.js'; +import { broadcastEvent } from '../ui-modules/event-broadcast.js'; import { ENDPOINT_TYPE } from '../utils/common.js'; /** @@ -885,6 +889,10 @@ export class ProviderPoolManager { // 如果指定了模型,则排除不支持该模型的提供商 if (requestedModel) { const modelFilteredProviders = availableAndHealthyProviders.filter(p => { + const supportedModels = getConfiguredSupportedModels(providerType, p.config); + if (supportedModels.length > 0) { + return supportedModels.includes(requestedModel); + } // 如果提供商没有配置 notSupportedModels,则认为它支持所有模型 if (!p.config.notSupportedModels || !Array.isArray(p.config.notSupportedModels)) { return true; @@ -1282,6 +1290,15 @@ export class ProviderPoolManager { for (const providerType of allProviderTypes) { if (this.providerStatus[providerType]) { let models = getProviderModels(providerType); + const configuredSupportedModels = normalizeModelIds( + this.providerStatus[providerType].flatMap(providerStatus => + getConfiguredSupportedModels(providerType, providerStatus.config) + ) + ); + + if (configuredSupportedModels.length > 0) { + models = configuredSupportedModels; + } // 如果硬编码的模型列表为空,或者该类型的提供商在号池中没有配置节点,尝试从服务获取 if (models.length === 0) { diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index cd71bb9..cb934a9 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -144,7 +144,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo const providerModelsMatch = pathParam.match(/^\/api\/provider-models\/([^\/]+)$/); if (method === 'GET' && providerModelsMatch) { const providerType = decodeURIComponent(providerModelsMatch[1]); - return await providerApi.handleGetProviderTypeModels(req, res, providerType); + return await providerApi.handleGetProviderTypeModels(req, res, currentConfig, providerPoolManager, providerType); } // Add new provider configuration @@ -168,6 +168,14 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await providerApi.handleHealthCheck(req, res, currentConfig, providerPoolManager, providerType); } + // Detect available models for a specific provider node + const detectModelsMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)\/detect-models$/); + if (method === 'POST' && detectModelsMatch) { + const providerType = decodeURIComponent(detectModelsMatch[1]); + const providerUuid = detectModelsMatch[2]; + return await providerApi.handleDetectProviderModels(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$/); @@ -348,4 +356,4 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo } return false; -} \ No newline at end of file +} diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index d6692ad..566eb09 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -1,10 +1,16 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import logger from '../utils/logger.js'; import { getRequestBody } from '../utils/common.js'; -import { getAllProviderModels, getProviderModels } from '../providers/provider-models.js'; +import { + extractModelIdsFromNativeList, + getConfiguredSupportedModels, + getProviderModels, + normalizeModelIds, + usesManagedModelList +} from '../providers/provider-models.js'; import { generateUUID, createProviderConfig, formatSystemPath, detectProviderFromPath, addToUsedPaths, isPathUsed, pathsEqual } from '../utils/provider-utils.js'; import { broadcastEvent } from './event-broadcast.js'; -import { getRegisteredProviders } from '../providers/adapter.js'; +import { getRegisteredProviders, getServiceAdapter, serviceInstances } from '../providers/adapter.js'; // 文件级互斥锁:防止并发读写导致数据丢失 // 安全净化:移除用户输入字段中的危险内容(script、事件处理器、javascript:协议等), @@ -87,6 +93,30 @@ function filterMaskedData(data) { return result; } +function getProviderPoolsFilePath(currentConfig) { + return currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; +} + +function loadProviderPools(currentConfig, providerPoolManager) { + const filePath = getProviderPoolsFilePath(currentConfig); + + if (providerPoolManager?.providerPools) { + return providerPoolManager.providerPools; + } + + if (!existsSync(filePath)) { + return {}; + } + + return JSON.parse(readFileSync(filePath, 'utf-8')); +} + +function getManagedSupportedModels(providerType, providers = []) { + return normalizeModelIds( + providers.flatMap(provider => getConfiguredSupportedModels(providerType, provider)) + ); +} + // 使用 Promise 链式队列,确保文件操作顺序执行 let _fileLockChain = Promise.resolve(); @@ -228,26 +258,27 @@ export async function handleGetSupportedProviders(req, res, currentConfig, provi */ export async function handleGetProviderModels(req, res, currentConfig, providerPoolManager) { const registeredProviders = getRegisteredProviders(); - let poolTypes = []; + let providerPools = {}; // 获取所有存在的类型(基础 + 动态) - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; try { - if (providerPoolManager && providerPoolManager.providerPools) { - poolTypes = Object.keys(providerPoolManager.providerPools); - } else if (existsSync(filePath)) { - const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); - poolTypes = Object.keys(poolsData); - } + providerPools = loadProviderPools(currentConfig, providerPoolManager); } catch (error) { logger.warn('[UI API] Failed to load provider pools for models:', error.message); } + const poolTypes = Object.keys(providerPools); const allTypes = [...new Set([...registeredProviders, ...poolTypes])]; const allModels = {}; allTypes.forEach(type => { - const models = getProviderModels(type); + let models = getProviderModels(type); + if (usesManagedModelList(type)) { + const managedModels = getManagedSupportedModels(type, providerPools[type] || []); + if (managedModels.length > 0) { + models = managedModels; + } + } if (models && models.length > 0) { allModels[type] = models; } @@ -261,8 +292,19 @@ export async function handleGetProviderModels(req, res, currentConfig, providerP /** * 获取特定提供商类型的可用模型 */ -export async function handleGetProviderTypeModels(req, res, providerType) { - const models = getProviderModels(providerType); +export async function handleGetProviderTypeModels(req, res, currentConfig, providerPoolManager, providerType) { + let models = getProviderModels(providerType); + if (usesManagedModelList(providerType)) { + try { + const providerPools = loadProviderPools(currentConfig, providerPoolManager); + const managedModels = getManagedSupportedModels(providerType, providerPools[providerType] || []); + if (managedModels.length > 0) { + models = managedModels; + } + } catch (error) { + logger.warn('[UI API] Failed to load managed provider models:', error.message); + } + } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ providerType, @@ -271,6 +313,70 @@ export async function handleGetProviderTypeModels(req, res, providerType) { return true; } +/** + * Detect available models for a specific provider node. + */ +export async function handleDetectProviderModels(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { + try { + if (!usesManagedModelList(providerType)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: `Model detection is not supported for provider type: ${providerType}` } })); + return true; + } + + const providerPools = loadProviderPools(currentConfig, providerPoolManager); + const providers = providerPools[providerType] || []; + const existingProvider = providers.find(provider => provider.uuid === providerUuid); + + if (!existingProvider) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider not found' } })); + return true; + } + + const body = await getRequestBody(req); + const draftConfig = filterMaskedData(body?.providerConfig || {}); + const detectionUuid = `${providerUuid}-detect-models`; + const instanceKey = `${providerType}${detectionUuid}`; + const tempConfig = { + ...currentConfig, + ...existingProvider, + ...draftConfig, + MODEL_PROVIDER: providerType, + uuid: detectionUuid + }; + + let models = []; + try { + delete serviceInstances[instanceKey]; + const serviceAdapter = getServiceAdapter(tempConfig); + if (typeof serviceAdapter.listModels !== 'function') { + throw new Error(`Provider ${providerType} does not support model detection`); + } + + const nativeModels = await serviceAdapter.listModels(); + models = extractModelIdsFromNativeList(nativeModels, providerType); + } finally { + delete serviceInstances[instanceKey]; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + providerType, + uuid: providerUuid, + count: models.length, + models, + selectedModels: getConfiguredSupportedModels(providerType, existingProvider) + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } +} + /** * 添加新的提供商配置 */ @@ -324,6 +430,10 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager) // 过滤掉脱敏字段 const filteredConfig = filterMaskedData(providerConfig); + if (usesManagedModelList(providerType)) { + filteredConfig.supportedModels = normalizeModelIds(filteredConfig.supportedModels); + filteredConfig.notSupportedModels = []; + } providerPools[providerType].push(filteredConfig); // Save to file @@ -419,6 +529,10 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage // 过滤掉传入配置中的脱敏占位符,避免覆盖真实数据 const filteredConfig = filterMaskedData(providerConfig); + if (usesManagedModelList(providerType)) { + filteredConfig.supportedModels = normalizeModelIds(filteredConfig.supportedModels); + filteredConfig.notSupportedModels = []; + } const updatedProvider = { ...existingProvider, @@ -1252,4 +1366,4 @@ export async function handleRefreshProviderUuid(req, res, currentConfig, provide res.end(JSON.stringify({ error: { message: error.message } })); return true; } -} \ No newline at end of file +} diff --git a/static/app/i18n.js b/static/app/i18n.js index e51e1ad..e553116 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -508,6 +508,17 @@ const translations = { 'modal.provider.loadingModels': '加载模型列表...', 'modal.provider.unsupportedModels': '不支持的模型', 'modal.provider.unsupportedModelsHelp': '选择此提供商不支持的模型,系统会自动排除这些模型', + 'modal.provider.supportedModels': '模型列表', + 'modal.provider.supportedModelsHelp': '仅保留你想让这个节点使用的模型,健康检查模型也可以直接从这里挑选', + 'modal.provider.supportedModelsEmpty': '还没有选择模型。进入编辑后可点击“检测可用模型”获取候选列表。', + 'modal.provider.detectModels': '检测可用模型', + 'modal.provider.detectModelsFailed': '检测可用模型失败', + 'modal.provider.detectModelsNoResults': '没有检测到可用模型', + 'modal.provider.modelPickerTitle': '{type} 可用模型', + 'modal.provider.modelPickerSearchPlaceholder': '搜索模型名称', + 'modal.provider.modelPickerSelectAll': '全选当前结果', + 'modal.provider.modelPickerClearAll': '清空已选', + 'modal.provider.modelPickerSelected': '已选择 {count} 个模型', 'modal.provider.addTitle': '添加新提供商配置', 'modal.provider.customName': '自定义名称', 'modal.provider.checkModelName': '检查模型名称', @@ -817,6 +828,7 @@ const translations = { // Common 'common.confirm': '确定', 'common.cancel': '取消', + 'common.close': '关闭', 'common.success': '成功', 'common.enabled': '已启用', 'common.disabled': '已禁用', @@ -1379,6 +1391,17 @@ const translations = { 'modal.provider.loadingModels': 'Loading models...', 'modal.provider.unsupportedModels': 'Unsupported Models', 'modal.provider.unsupportedModelsHelp': 'Select models not supported by this provider; they will be excluded automatically', + 'modal.provider.supportedModels': 'Model List', + 'modal.provider.supportedModelsHelp': 'Keep only the models you want this node to use, including a convenient health-check model choice', + 'modal.provider.supportedModelsEmpty': 'No models selected yet. Enter edit mode and click "Detect Available Models" to load candidates.', + 'modal.provider.detectModels': 'Detect Available Models', + 'modal.provider.detectModelsFailed': 'Failed to detect available models', + 'modal.provider.detectModelsNoResults': 'No available models were detected', + 'modal.provider.modelPickerTitle': '{type} Available Models', + 'modal.provider.modelPickerSearchPlaceholder': 'Search model names', + 'modal.provider.modelPickerSelectAll': 'Select visible results', + 'modal.provider.modelPickerClearAll': 'Clear selected', + 'modal.provider.modelPickerSelected': '{count} models selected', 'modal.provider.addTitle': 'Add New Provider Config', 'modal.provider.customName': 'Custom Name', 'modal.provider.checkModelName': 'Check Model Name', @@ -1689,6 +1712,7 @@ const translations = { 'common.togglePassword': 'Show/Hide Password', 'common.confirm': 'Confirm', 'common.cancel': 'Cancel', + 'common.close': 'Close', 'common.success': 'Success', 'common.enabled': 'Enabled', 'common.disabled': 'Disabled', diff --git a/static/app/modal.js b/static/app/modal.js index 16132eb..63491bc 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -1,16 +1,383 @@ // 模态框管理模块 -import { showToast, getFieldLabel, getProviderTypeFields } from './utils.js'; +import { escapeHtml, showToast, getFieldLabel, getProviderTypeFields } from './utils.js'; import { handleProviderPasswordToggle } from './event-handlers.js'; import { t } from './i18n.js'; +const MANAGED_MODEL_LIST_PROVIDERS = new Set(['openai-custom', 'openaiResponses-custom']); + // 分页配置 const PROVIDERS_PER_PAGE = 5; let currentPage = 1; let currentProviders = []; let currentProviderType = ''; + +function usesManagedModelList(providerType = '') { + return Array.from(MANAGED_MODEL_LIST_PROVIDERS).some(baseType => + providerType === baseType || providerType.startsWith(`${baseType}-`) + ); +} + +function normalizeModelList(models = []) { + return [...new Set( + (Array.isArray(models) ? models : []) + .filter(model => typeof model === 'string') + .map(model => model.trim()) + .filter(Boolean) + )].sort((a, b) => a.localeCompare(b)); +} + +function serializeModelsData(models = []) { + return encodeURIComponent(JSON.stringify(normalizeModelList(models))); +} + +function parseModelsData(rawValue = '') { + if (!rawValue) { + return []; + } + + try { + return normalizeModelList(JSON.parse(decodeURIComponent(rawValue))); + } catch (error) { + console.warn('Failed to parse models data:', error); + return []; + } +} + +function renderSupportedModelsValue(models = []) { + const selectedModels = normalizeModelList(models); + if (selectedModels.length === 0) { + return `
${escapeHtml(t('modal.provider.supportedModelsEmpty'))}
`; + } + + return ` +
+ ${selectedModels.map(model => ` + ${escapeHtml(model)} + `).join('')} +
+ `; +} + +function getSupportedModelsContainer(uuid) { + return document.querySelector(`.supported-models-container[data-uuid="${uuid}"]`); +} + +function setSupportedModelsSelection(uuid, models, options = {}) { + const container = getSupportedModelsContainer(uuid); + if (!container) return; + + const normalizedModels = normalizeModelList(models); + const encodedModels = serializeModelsData(normalizedModels); + container.dataset.selectedModels = encodedModels; + + if (options.updateOriginal) { + container.dataset.originalModels = encodedModels; + } + + const valueContainer = container.querySelector('.supported-models-values'); + if (valueContainer) { + valueContainer.innerHTML = renderSupportedModelsValue(normalizedModels); + } + + const summary = container.querySelector('.supported-models-summary'); + if (summary) { + summary.textContent = t('modal.provider.modelPickerSelected', { count: normalizedModels.length }); + } +} + +function resetSupportedModelsSelection(uuid) { + const container = getSupportedModelsContainer(uuid); + if (!container) return; + setSupportedModelsSelection(uuid, parseModelsData(container.dataset.originalModels || '')); +} + +function renderSupportedModelsSection(provider) { + const selectedModels = normalizeModelList(provider.supportedModels || []); + const encodedModels = serializeModelsData(selectedModels); + + return ` +
+ +
+
+ ${escapeHtml(t('modal.provider.modelPickerSelected', { count: selectedModels.length }))} + +
+
+ ${renderSupportedModelsValue(selectedModels)} +
+
+
+ `; +} + +function collectDraftProviderConfig(providerDetail, providerType, uuid) { + const configInputs = providerDetail.querySelectorAll('input[data-config-key]'); + const configSelects = providerDetail.querySelectorAll('select[data-config-key]'); + const providerConfig = {}; + + configInputs.forEach(input => { + const key = input.dataset.configKey; + let value = input.value; + if (key === 'concurrencyLimit' || key === 'queueLimit') { + value = parseInt(value || '0', 10); + } + providerConfig[key] = value; + }); + + configSelects.forEach(select => { + const key = select.dataset.configKey; + providerConfig[key] = select.value === 'true'; + }); + + if (usesManagedModelList(providerType)) { + const supportedModels = parseModelsData(getSupportedModelsContainer(uuid)?.dataset.selectedModels || ''); + providerConfig.supportedModels = supportedModels; + providerConfig.notSupportedModels = []; + } else { + const modelCheckboxes = providerDetail.querySelectorAll(`.model-checkbox[data-uuid="${uuid}"]:checked`); + providerConfig.notSupportedModels = Array.from(modelCheckboxes).map(checkbox => checkbox.value); + } + + return providerConfig; +} let cachedModels = []; // 缓存模型列表 +function closeSupportedModelsPicker(overlay) { + if (!overlay) return; + + if (overlay.escapeHandler) { + document.removeEventListener('keydown', overlay.escapeHandler); + } + + overlay.remove(); +} + +function showSupportedModelsPickerModal(providerType, uuid, detectedModels, currentSelectedModels = []) { + const existingOverlay = document.querySelector('.provider-model-picker-overlay'); + if (existingOverlay) { + closeSupportedModelsPicker(existingOverlay); + } + + const allModels = normalizeModelList([...detectedModels, ...currentSelectedModels]); + const selectedModels = new Set(normalizeModelList(currentSelectedModels)); + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay provider-model-picker-overlay'; + overlay.style.display = 'flex'; + overlay.innerHTML = ` + + `; + + const searchInput = overlay.querySelector('.provider-model-picker-search'); + const listContainer = overlay.querySelector('.provider-model-picker-list'); + const summary = overlay.querySelector('.provider-model-picker-summary'); + const selectAllInput = overlay.querySelector('.provider-model-picker-select-all-input'); + const clearButton = overlay.querySelector('.provider-model-picker-clear'); + const cancelButton = overlay.querySelector('.provider-model-picker-cancel'); + const confirmButton = overlay.querySelector('.provider-model-picker-confirm'); + const closeButton = overlay.querySelector('.modal-close'); + + const getVisibleModels = () => { + const keyword = searchInput.value.trim().toLowerCase(); + if (!keyword) { + return allModels; + } + + return allModels.filter(model => model.toLowerCase().includes(keyword)); + }; + + const updateSelectAllState = () => { + const visibleModels = getVisibleModels(); + if (visibleModels.length === 0) { + selectAllInput.checked = false; + selectAllInput.indeterminate = false; + selectAllInput.disabled = true; + return; + } + + selectAllInput.disabled = false; + const checkedCount = visibleModels.filter(model => selectedModels.has(model)).length; + selectAllInput.checked = checkedCount === visibleModels.length; + selectAllInput.indeterminate = checkedCount > 0 && checkedCount < visibleModels.length; + }; + + const updateSummary = () => { + summary.textContent = t('modal.provider.modelPickerSelected', { count: selectedModels.size }); + }; + + const renderList = () => { + const visibleModels = getVisibleModels(); + + if (visibleModels.length === 0) { + listContainer.innerHTML = ` +
+ ${escapeHtml(allModels.length === 0 ? t('modal.provider.detectModelsNoResults') : t('modal.provider.supportedModelsEmpty'))} +
+ `; + updateSelectAllState(); + updateSummary(); + return; + } + + listContainer.innerHTML = visibleModels.map(model => ` + + `).join(''); + + listContainer.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { + checkbox.addEventListener('change', () => { + if (checkbox.checked) { + selectedModels.add(checkbox.value); + } else { + selectedModels.delete(checkbox.value); + } + updateSelectAllState(); + updateSummary(); + }); + }); + + updateSelectAllState(); + updateSummary(); + }; + + const handleClose = () => closeSupportedModelsPicker(overlay); + + overlay.escapeHandler = event => { + if (event.key === 'Escape') { + handleClose(); + } + }; + + document.addEventListener('keydown', overlay.escapeHandler); + overlay.addEventListener('click', event => { + if (event.target === overlay) { + handleClose(); + } + }); + + searchInput.addEventListener('input', renderList); + selectAllInput.addEventListener('change', () => { + const visibleModels = getVisibleModels(); + visibleModels.forEach(model => { + if (selectAllInput.checked) { + selectedModels.add(model); + } else { + selectedModels.delete(model); + } + }); + renderList(); + }); + clearButton.addEventListener('click', () => { + selectedModels.clear(); + renderList(); + }); + cancelButton.addEventListener('click', handleClose); + closeButton.addEventListener('click', handleClose); + confirmButton.addEventListener('click', () => { + setSupportedModelsSelection(uuid, Array.from(selectedModels)); + handleClose(); + }); + + document.body.appendChild(overlay); + renderList(); + searchInput.focus(); +} + +async function openSupportedModelsPicker(providerType, uuid, event) { + event.stopPropagation(); + + if (!usesManagedModelList(providerType)) { + return; + } + + const providerDetail = event.target.closest('.provider-item-detail'); + if (!providerDetail) { + return; + } + + const detectButton = providerDetail.querySelector('.detect-models-btn'); + const originalHtml = detectButton?.innerHTML; + const draftProviderConfig = collectDraftProviderConfig(providerDetail, providerType, uuid); + + try { + if (detectButton) { + detectButton.disabled = true; + detectButton.innerHTML = ` ${escapeHtml(t('common.loading'))}`; + } + + const response = await window.apiClient.post( + `/providers/${encodeURIComponent(providerType)}/${uuid}/detect-models`, + { providerConfig: draftProviderConfig } + ); + + showSupportedModelsPickerModal( + providerType, + uuid, + response.models || [], + draftProviderConfig.supportedModels || response.selectedModels || [] + ); + } catch (error) { + console.error('Failed to detect provider models:', error); + showToast(t('common.error'), t('modal.provider.detectModelsFailed') + ': ' + error.message, 'error'); + } finally { + if (detectButton) { + detectButton.innerHTML = originalHtml; + detectButton.disabled = !providerDetail.classList.contains('editing'); + } + } +} + /** * 显示提供商管理模态框 * @param {Object} data - 提供商数据 @@ -202,11 +569,11 @@ function goToProviderPage(page) { const pageProviders = currentProviders.slice(startIndex, endIndex); // 如果已缓存模型列表,直接使用 - if (cachedModels.length > 0) { + if (!usesManagedModelList(currentProviderType) && cachedModels.length > 0) { pageProviders.forEach(provider => { renderNotSupportedModelsSelector(provider.uuid, cachedModels, provider.notSupportedModels || []); }); - } else { + } else if (!usesManagedModelList(currentProviderType)) { loadModelsForProviderType(currentProviderType, pageProviders); } } @@ -232,6 +599,10 @@ function renderProviderListPaginated(providers, page) { */ async function loadModelsForProviderType(providerType, providers) { try { + if (usesManagedModelList(providerType)) { + return; + } + // 如果已有缓存,直接使用 if (cachedModels.length > 0) { providers.forEach(provider => { @@ -647,6 +1018,13 @@ function renderProviderConfig(provider) { } // 添加 notSupportedModels 配置区域 + if (usesManagedModelList(currentProviderType)) { + html += '
'; + html += renderSupportedModelsSection(provider); + html += '
'; + return html; + } + html += '
'; html += `
@@ -683,7 +1061,7 @@ function getFieldOrder(provider) { const excludedFields = [ 'isHealthy', 'lastUsed', 'usageCount', 'errorCount', 'lastErrorTime', 'uuid', 'isDisabled', 'lastHealthCheckTime', 'lastHealthCheckModel', 'lastErrorMessage', - 'notSupportedModels', 'refreshCount', 'needsRefresh', '_lastSelectionSeq' + 'notSupportedModels', 'supportedModels', 'refreshCount', 'needsRefresh', '_lastSelectionSeq' ]; // 尝试从当前模态框上下文中获取提供商类型 @@ -791,6 +1169,11 @@ function editProvider(uuid, event) { modelCheckboxes.forEach(checkbox => { checkbox.disabled = false; }); + + const detectModelsButton = providerDetail.querySelector('.detect-models-btn'); + if (detectModelsButton) { + detectModelsButton.disabled = false; + } // 添加编辑状态类 providerDetail.classList.add('editing'); @@ -838,6 +1221,20 @@ function cancelEdit(uuid, event) { modelCheckboxes.forEach(checkbox => { checkbox.disabled = true; }); + + const detectModelsButton = providerDetail.querySelector('.detect-models-btn'); + if (detectModelsButton) { + detectModelsButton.disabled = true; + } + + if (usesManagedModelList(currentProviderType)) { + resetSupportedModelsSelection(uuid); + } else { + const currentProviderData = currentProviders.find(provider => provider.uuid === uuid); + if (currentProviderData) { + renderNotSupportedModelsSelector(uuid, cachedModels, currentProviderData.notSupportedModels || []); + } + } // 移除编辑状态类 providerDetail.classList.remove('editing'); @@ -891,29 +1288,11 @@ async function saveProvider(uuid, event) { const providerDetail = event.target.closest('.provider-item-detail'); const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type'); - const configInputs = providerDetail.querySelectorAll('input[data-config-key]'); - const configSelects = providerDetail.querySelectorAll('select[data-config-key]'); - const providerConfig = {}; + const providerConfig = collectDraftProviderConfig(providerDetail, providerType, uuid); - configInputs.forEach(input => { - const key = input.dataset.configKey; - let value = input.value; - if (key === 'concurrencyLimit' || key === 'queueLimit') { - value = parseInt(value || '0'); - } - providerConfig[key] = value; - }); - configSelects.forEach(select => { - const key = select.dataset.configKey; - const value = select.value === 'true'; - providerConfig[key] = value; - }); // 收集不支持的模型列表 - const modelCheckboxes = providerDetail.querySelectorAll(`.model-checkbox[data-uuid="${uuid}"]:checked`); - const notSupportedModels = Array.from(modelCheckboxes).map(checkbox => checkbox.value); - providerConfig.notSupportedModels = notSupportedModels; try { await window.apiClient.put(`/providers/${encodeURIComponent(providerType)}/${uuid}`, { providerConfig }); @@ -1610,6 +1989,7 @@ export { performHealthCheck, deleteUnhealthyProviders, refreshUnhealthyUuids, + openSupportedModelsPicker, loadModelsForProviderType, renderNotSupportedModelsSelector, goToProviderPage, @@ -1630,5 +2010,6 @@ window.resetAllProvidersHealth = resetAllProvidersHealth; window.performHealthCheck = performHealthCheck; window.deleteUnhealthyProviders = deleteUnhealthyProviders; window.refreshUnhealthyUuids = refreshUnhealthyUuids; +window.openSupportedModelsPicker = openSupportedModelsPicker; window.goToProviderPage = goToProviderPage; -window.refreshProviderUuid = refreshProviderUuid; \ No newline at end of file +window.refreshProviderUuid = refreshProviderUuid; diff --git a/static/components/section-providers.css b/static/components/section-providers.css index f30523b..8349743 100644 --- a/static/components/section-providers.css +++ b/static/components/section-providers.css @@ -1121,6 +1121,172 @@ font-weight: 500; } +.supported-models-section { + grid-column: 1 / -1; + margin-top: 16px; +} + +.supported-models-section label { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--neutral-700); + margin-bottom: 12px; +} + +.supported-models-section .help-text { + font-size: 12px; + font-weight: normal; + color: var(--neutral-500); + margin-left: 4px; +} + +.supported-models-container { + background: var(--neutral-100); + border: 1px solid var(--neutral-300); + border-radius: 8px; + padding: 16px; +} + +.supported-models-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.supported-models-summary { + font-size: 12px; + color: var(--neutral-600); +} + +.supported-models-values { + min-height: 52px; +} + +.supported-models-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.supported-model-tag { + display: inline-flex; + align-items: center; + max-width: 100%; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--neutral-300); + background: var(--white); + color: var(--neutral-700); + font-size: 12px; + line-height: 1.3; +} + +.supported-models-empty { + color: var(--neutral-500); + font-size: 13px; + padding: 8px 0; +} + +.detect-models-btn { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.provider-model-picker-modal { + max-width: 720px; +} + +.provider-model-picker-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 12px; + align-items: center; + margin-bottom: 12px; +} + +.provider-model-picker-search { + width: 100%; + min-width: 0; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-primary); +} + +.provider-model-picker-select-all { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text-primary); + font-size: 14px; + white-space: nowrap; +} + +.provider-model-picker-summary { + margin-bottom: 12px; + font-size: 13px; + color: var(--neutral-600); +} + +.provider-model-picker-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; + max-height: 360px; + overflow-y: auto; +} + +.provider-model-picker-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--neutral-300); + border-radius: 8px; + background: var(--white); + cursor: pointer; +} + +.provider-model-picker-item:hover { + border-color: var(--neutral-400); + background: var(--neutral-200); +} + +.provider-model-picker-item span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--neutral-700); + font-size: 13px; +} + +.provider-model-picker-empty { + padding: 24px 12px; + text-align: center; + color: var(--neutral-500); + border: 1px dashed var(--neutral-300); + border-radius: 8px; + background: var(--neutral-100); +} + +@media (max-width: 640px) { + .provider-model-picker-toolbar { + grid-template-columns: 1fr; + } + + .provider-model-picker-list { + grid-template-columns: 1fr; + } +} + /* 授权按钮样式 */ .generate-auth-btn { display: inline-flex; @@ -1482,4 +1648,4 @@ [data-theme="dark"] .highlight-note i { color: var(--warning-color); -} \ No newline at end of file +} diff --git a/tests/provider-models.unit.test.js b/tests/provider-models.unit.test.js new file mode 100644 index 0000000..f65f7f0 --- /dev/null +++ b/tests/provider-models.unit.test.js @@ -0,0 +1,33 @@ +import { describe, expect, test } from '@jest/globals'; +import { + extractModelIdsFromNativeList, + getConfiguredSupportedModels, + usesManagedModelList +} from '../src/providers/provider-models.js'; + +describe('provider-models helpers', () => { + test('recognizes managed model list providers', () => { + expect(usesManagedModelList('openai-custom')).toBe(true); + expect(usesManagedModelList('openaiResponses-custom-lab')).toBe(true); + expect(usesManagedModelList('gemini-cli-oauth')).toBe(false); + }); + + test('normalizes supported models for managed providers', () => { + expect(getConfiguredSupportedModels('openai-custom', { + supportedModels: [' gpt-4o-mini ', '', 'gpt-4o-mini', 'gpt-4.1'] + })).toEqual(['gpt-4.1', 'gpt-4o-mini']); + + expect(getConfiguredSupportedModels('gemini-cli-oauth', { + supportedModels: ['gemini-2.5-flash'] + })).toEqual([]); + }); + + test('extracts model ids from openai-style model lists', () => { + expect(extractModelIdsFromNativeList({ + data: [ + { id: 'gpt-4o-mini' }, + { id: 'gpt-4.1' } + ] + }, 'openai-custom')).toEqual(['gpt-4.1', 'gpt-4o-mini']); + }); +});