From ba6a461a40b4872723a62609daa975598997be49 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Fri, 5 Dec 2025 02:01:21 +0800 Subject: [PATCH] feat: implement available models loading functionality with UI integration, status updates, and internationalization support --- app.js | 7 ++ i18n.js | 20 +++- index.html | 20 +++- src/core/connection.js | 211 ++++++++++++++++++++++++++++++++++++ src/modules/ai-providers.js | 40 +------ src/modules/login.js | 3 + src/utils/models.js | 104 ++++++++++++++++++ styles.css | 81 ++++++++++++++ 8 files changed, 441 insertions(+), 45 deletions(-) create mode 100644 src/utils/models.js diff --git a/app.js b/app.js index f185a93..215d321 100644 --- a/app.js +++ b/app.js @@ -65,6 +65,9 @@ class CLIProxyManager { }); this.configCache = this.configService.cache; this.cacheTimestamps = this.configService.cacheTimestamps; + this.availableModels = []; + this.availableModelApiKeysCache = null; + this.availableModelsLoading = false; // 状态更新定时器 this.statusUpdateTimer = null; @@ -276,6 +279,7 @@ class CLIProxyManager { // 连接状态检查 const connectionStatus = document.getElementById('connection-status'); const refreshAll = document.getElementById('refresh-all'); + const availableModelsRefresh = document.getElementById('available-models-refresh'); if (connectionStatus) { connectionStatus.addEventListener('click', () => this.checkConnectionStatus()); @@ -283,6 +287,9 @@ class CLIProxyManager { if (refreshAll) { refreshAll.addEventListener('click', () => this.refreshAllData()); } + if (availableModelsRefresh) { + availableModelsRefresh.addEventListener('click', () => this.loadAvailableModels({ forceRefresh: true })); + } // 基础设置 const debugToggle = document.getElementById('debug-toggle'); diff --git a/i18n.js b/i18n.js index 9b74d3d..9a96884 100644 --- a/i18n.js +++ b/i18n.js @@ -94,7 +94,7 @@ const i18n = { 'nav.usage_stats': '使用统计', 'nav.config_management': '配置管理', 'nav.logs': '日志查看', - 'nav.system_info': '系统信息', + 'nav.system_info': '管理中心信息', // 基础设置 'basic_settings.title': '基础设置', @@ -557,7 +557,7 @@ const i18n = { 'config_management.editor_placeholder': 'key: value', // 系统信息 - 'system_info.title': '系统信息', + 'system_info.title': '管理中心信息', 'system_info.connection_status_title': '连接状态', 'system_info.api_status_label': 'API 状态:', 'system_info.config_status_label': '配置状态:', @@ -566,6 +566,12 @@ const i18n = { 'system_info.real_time_data': '实时数据', 'system_info.not_loaded': '未加载', 'system_info.seconds_ago': '秒前', + 'system_info.models_title': '可用模型列表', + 'system_info.models_desc': '展示 /v1/models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。', + 'system_info.models_loading': '正在加载可用模型...', + 'system_info.models_empty': '未从 /v1/models 获取到模型数据', + 'system_info.models_error': '获取模型列表失败', + 'system_info.models_count': '可用模型 {count} 个', // 通知消息 'notification.debug_updated': '调试设置已更新', @@ -730,7 +736,7 @@ const i18n = { 'nav.usage_stats': 'Usage Statistics', 'nav.config_management': 'Config Management', 'nav.logs': 'Logs Viewer', - 'nav.system_info': 'System Info', + 'nav.system_info': 'Management Center Info', // Basic settings 'basic_settings.title': 'Basic Settings', @@ -1192,7 +1198,7 @@ const i18n = { 'config_management.editor_placeholder': 'key: value', // System info - 'system_info.title': 'System Information', + 'system_info.title': 'Management Center Info', 'system_info.connection_status_title': 'Connection Status', 'system_info.api_status_label': 'API Status:', 'system_info.config_status_label': 'Config Status:', @@ -1201,6 +1207,12 @@ const i18n = { 'system_info.real_time_data': 'Real-time Data', 'system_info.not_loaded': 'Not Loaded', 'system_info.seconds_ago': 'seconds ago', + 'system_info.models_title': 'Available Models', + 'system_info.models_desc': 'Shows the /v1/models response and uses saved API keys for auth automatically.', + 'system_info.models_loading': 'Loading available models...', + 'system_info.models_empty': 'No models returned by /v1/models', + 'system_info.models_error': 'Failed to load model list', + 'system_info.models_count': '{count} available models', // Notification messages 'notification.debug_updated': 'Debug settings updated', diff --git a/index.html b/index.html index 4ce403a..388d39a 100644 --- a/index.html +++ b/index.html @@ -182,7 +182,7 @@ 日志查看
  • - 系统信息 + 管理中心信息
  • @@ -1235,9 +1235,23 @@ - +
    -

    系统信息

    +

    管理中心信息

    + +
    +
    +

    可用模型列表

    + +
    +
    +

    展示当前服务返回的 /v1/models 列表(使用服务器保存的 API Key 自动鉴权)。

    +
    加载中...
    +
    +
    +
    diff --git a/src/core/connection.js b/src/core/connection.js index 8c7ead6..e35c31b 100644 --- a/src/core/connection.js +++ b/src/core/connection.js @@ -3,6 +3,34 @@ import { STATUS_UPDATE_INTERVAL_MS, DEFAULT_API_PORT } from '../utils/constants.js'; import { secureStorage } from '../utils/secure-storage.js'; +import { normalizeModelList, classifyModels } from '../utils/models.js'; + +const buildModelsEndpoint = (baseUrl) => { + if (!baseUrl) return ''; + const trimmed = String(baseUrl).trim().replace(/\/+$/g, ''); + if (!trimmed) return ''; + return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`; +}; + +const normalizeApiKeyList = (input) => { + if (!Array.isArray(input)) return []; + const seen = new Set(); + const keys = []; + + input.forEach(item => { + const value = typeof item === 'string' + ? item + : (item && item['api-key'] ? item['api-key'] : ''); + const trimmed = String(value || '').trim(); + if (!trimmed || seen.has(trimmed)) { + return; + } + seen.add(trimmed); + keys.push(trimmed); + }); + + return keys; +}; export const connectionModule = { // 规范化基础地址,移除尾部斜杠与 /v0/management @@ -153,6 +181,178 @@ export const connectionModule = { } }, + buildAvailableModelsEndpoint() { + return buildModelsEndpoint(this.apiBase || this.apiClient?.apiBase || ''); + }, + + setAvailableModelsStatus(message = '', type = 'info') { + const statusEl = document.getElementById('available-models-status'); + if (!statusEl) return; + statusEl.textContent = message || ''; + statusEl.className = `available-models-status ${type}`; + }, + + renderAvailableModels(models = []) { + const listEl = document.getElementById('available-models-list'); + if (!listEl) return; + + if (!models.length) { + listEl.innerHTML = ` +
    + + ${i18n.t('system_info.models_empty')} +
    + `; + return; + } + + const language = (i18n?.currentLanguage || '').toLowerCase(); + const otherLabel = language.startsWith('zh') ? '其他' : 'Other'; + const groups = classifyModels(models, { otherLabel }); + + const groupHtml = groups.map(group => { + const pills = group.items.map(model => { + const name = this.escapeHtml(model.name || ''); + const alias = model.alias ? `${this.escapeHtml(model.alias)}` : ''; + const description = model.description ? this.escapeHtml(model.description) : ''; + const titleAttr = description ? ` title="${description}"` : ''; + return ` + + ${name} + ${alias} + + `; + }).join(''); + + const label = this.escapeHtml(group.label || group.id || ''); + return ` +
    +
    +
    + ${label} + ${group.items.length} +
    +
    +
    + ${pills} +
    +
    + `; + }).join(''); + + listEl.innerHTML = groupHtml; + }, + + clearAvailableModels(messageKey = 'system_info.models_empty') { + this.availableModels = []; + this.availableModelApiKeysCache = null; + const listEl = document.getElementById('available-models-list'); + if (listEl) { + listEl.innerHTML = ''; + } + this.setAvailableModelsStatus(i18n.t(messageKey), 'warning'); + }, + + async resolveApiKeysForModels({ config = null, forceRefresh = false } = {}) { + if (!forceRefresh && Array.isArray(this.availableModelApiKeysCache) && this.availableModelApiKeysCache.length) { + return this.availableModelApiKeysCache; + } + + const configKeys = normalizeApiKeyList(config?.['api-keys'] || this.configCache?.['api-keys']); + if (configKeys.length) { + this.availableModelApiKeysCache = configKeys; + return configKeys; + } + + try { + const data = await this.makeRequest('/api-keys'); + const keys = normalizeApiKeyList(data?.['api-keys']); + if (keys.length) { + this.availableModelApiKeysCache = keys; + } + return keys; + } catch (error) { + console.warn('自动获取 API Key 失败:', error); + return []; + } + }, + + async loadAvailableModels({ config = null, forceRefresh = false } = {}) { + const listEl = document.getElementById('available-models-list'); + const statusEl = document.getElementById('available-models-status'); + + if (!listEl || !statusEl) { + return; + } + + if (!this.isConnected) { + this.setAvailableModelsStatus(i18n.t('common.disconnected'), 'warning'); + listEl.innerHTML = ''; + return; + } + + const endpoint = this.buildAvailableModelsEndpoint(); + if (!endpoint) { + this.setAvailableModelsStatus(i18n.t('system_info.models_error'), 'error'); + listEl.innerHTML = ` +
    + + ${i18n.t('login.error_invalid')} +
    + `; + return; + } + + this.availableModelsLoading = true; + this.setAvailableModelsStatus(i18n.t('system_info.models_loading'), 'info'); + listEl.innerHTML = '
    '; + + try { + const headers = {}; + const keys = await this.resolveApiKeysForModels({ config, forceRefresh }); + if (keys.length) { + headers.Authorization = `Bearer ${keys[0]}`; + } + + const response = await fetch(endpoint, { headers }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + let data; + try { + data = await response.json(); + } catch (err) { + const text = await response.text(); + throw new Error(text || err.message || 'Invalid JSON'); + } + + const models = normalizeModelList(data, { dedupe: true }); + this.availableModels = models; + + if (!models.length) { + this.setAvailableModelsStatus(i18n.t('system_info.models_empty'), 'warning'); + this.renderAvailableModels([]); + return; + } + + this.setAvailableModelsStatus(i18n.t('system_info.models_count', { count: models.length }), 'success'); + this.renderAvailableModels(models); + } catch (error) { + console.error('加载可用模型失败:', error); + this.availableModels = []; + this.setAvailableModelsStatus(`${i18n.t('system_info.models_error')}: ${error.message}`, 'error'); + listEl.innerHTML = ` +
    + + ${this.escapeHtml(error.message || '')} +
    + `; + } finally { + this.availableModelsLoading = false; + } + }, + // 测试连接(简化版,用于内部调用) async testConnection() { try { @@ -203,6 +403,11 @@ export const connectionModule = { apiStatus.textContent = i18n.t('common.disconnected'); configStatus.textContent = i18n.t('system_info.not_loaded'); configStatus.style.color = '#6b7280'; + this.setAvailableModelsStatus(i18n.t('common.disconnected'), 'warning'); + const modelsList = document.getElementById('available-models-list'); + if (modelsList) { + modelsList.innerHTML = ''; + } } lastUpdate.textContent = new Date().toLocaleString('zh-CN'); @@ -280,8 +485,12 @@ export const connectionModule = { this.configService.clearCache(section); this.configCache = this.configService.cache; this.cacheTimestamps = this.configService.cacheTimestamps; + if (!section || section === 'api-keys') { + this.availableModelApiKeysCache = null; + } if (!section) { this.configYamlCache = ''; + this.availableModels = []; } }, @@ -329,6 +538,8 @@ export const connectionModule = { // 从配置中提取并设置各个设置项(现在传递keyStats) await this.updateSettingsFromConfig(config, keyStats); + await this.loadAvailableModels({ config, forceRefresh }); + if (this.events && typeof this.events.emit === 'function') { this.events.emit('data:config-loaded', { config, diff --git a/src/modules/ai-providers.js b/src/modules/ai-providers.js index d1056c6..2bb5e01 100644 --- a/src/modules/ai-providers.js +++ b/src/modules/ai-providers.js @@ -2,6 +2,8 @@ // 这些函数依赖于 CLIProxyManager 实例上的 makeRequest/getConfig/clearCache/showNotification 等能力, // 以及 apiKeysModule 中的工具方法(如 applyHeadersToConfig/renderHeaderBadges)。 +import { normalizeModelList } from '../utils/models.js'; + const getStatsBySource = (stats) => { if (stats && typeof stats === 'object' && stats.bySource) { return stats.bySource; @@ -21,44 +23,6 @@ const buildModelEndpoint = (baseUrl) => { return `${trimmed}/v1/models`; }; -const normalizeModelList = (payload) => { - const toModel = (entry) => { - if (typeof entry === 'string') { - return { name: entry }; - } - if (!entry || typeof entry !== 'object') { - return null; - } - const name = entry.id || entry.name || entry.model || entry.value; - if (!name) return null; - const alias = entry.alias || entry.display_name || entry.displayName; - const description = entry.description || entry.note || entry.comment; - const model = { name: String(name) }; - if (alias && alias !== name) { - model.alias = String(alias); - } - if (description) { - model.description = String(description); - } - return model; - }; - - if (Array.isArray(payload)) { - return payload.map(toModel).filter(Boolean); - } - - if (payload && typeof payload === 'object') { - if (Array.isArray(payload.data)) { - return payload.data.map(toModel).filter(Boolean); - } - if (Array.isArray(payload.models)) { - return payload.models.map(toModel).filter(Boolean); - } - } - - return []; -}; - const normalizeExcludedModels = (input) => { const rawList = Array.isArray(input) ? input diff --git a/src/modules/login.js b/src/modules/login.js index d40dae7..701d97d 100644 --- a/src/modules/login.js +++ b/src/modules/login.js @@ -106,6 +106,9 @@ export const loginModule = { if (typeof this.renderOauthExcludedModels === 'function') { this.renderOauthExcludedModels('all'); } + if (typeof this.clearAvailableModels === 'function') { + this.clearAvailableModels('common.disconnected'); + } localStorage.removeItem('isLoggedIn'); secureStorage.removeItem('managementKey'); diff --git a/src/utils/models.js b/src/utils/models.js new file mode 100644 index 0000000..ef4559b --- /dev/null +++ b/src/utils/models.js @@ -0,0 +1,104 @@ +/** + * 模型工具函数 + * 提供模型列表的规范化与去重能力 + */ +export function normalizeModelList(payload, { dedupe = false } = {}) { + const toModel = (entry) => { + if (typeof entry === 'string') { + return { name: entry }; + } + if (!entry || typeof entry !== 'object') { + return null; + } + const name = entry.id || entry.name || entry.model || entry.value; + if (!name) return null; + + const alias = entry.alias || entry.display_name || entry.displayName; + const description = entry.description || entry.note || entry.comment; + const model = { name: String(name) }; + if (alias && alias !== name) { + model.alias = String(alias); + } + if (description) { + model.description = String(description); + } + return model; + }; + + let models = []; + + if (Array.isArray(payload)) { + models = payload.map(toModel).filter(Boolean); + } else if (payload && typeof payload === 'object') { + if (Array.isArray(payload.data)) { + models = payload.data.map(toModel).filter(Boolean); + } else if (Array.isArray(payload.models)) { + models = payload.models.map(toModel).filter(Boolean); + } + } + + if (!dedupe) { + return models; + } + + const seen = new Set(); + return models.filter(model => { + const key = (model?.name || '').toLowerCase(); + if (!key || seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +const MODEL_CATEGORIES = [ + { id: 'gpt', label: 'GPT', patterns: [/gpt/i, /\bo\d\b/i, /\bo\d+\.?/i, /\bchatgpt/i] }, + { id: 'claude', label: 'Claude', patterns: [/claude/i] }, + { id: 'gemini', label: 'Gemini', patterns: [/gemini/i, /\bgai\b/i] }, + { id: 'kimi', label: 'Kimi', patterns: [/kimi/i] }, + { id: 'qwen', label: 'Qwen', patterns: [/qwen/i] }, + { id: 'glm', label: 'GLM', patterns: [/glm/i, /chatglm/i] }, + { id: 'grok', label: 'Grok', patterns: [/grok/i] }, + { id: 'deepseek', label: 'DeepSeek', patterns: [/deepseek/i] } +]; + +function matchCategory(text) { + for (const category of MODEL_CATEGORIES) { + if (category.patterns.some(pattern => pattern.test(text))) { + return category.id; + } + } + return null; +} + +export function classifyModels(models = [], { otherLabel = 'Other' } = {}) { + const groups = MODEL_CATEGORIES.map(category => ({ + id: category.id, + label: category.label, + items: [] + })); + + const otherGroup = { id: 'other', label: otherLabel, items: [] }; + + models.forEach(model => { + const name = (model?.name || '').toString(); + const alias = (model?.alias || '').toString(); + const haystack = `${name} ${alias}`.toLowerCase(); + const matchedId = matchCategory(haystack); + const target = matchedId ? groups.find(group => group.id === matchedId) : null; + + if (target) { + target.items.push(model); + } else { + otherGroup.items.push(model); + } + }); + + const populatedGroups = groups.filter(group => group.items.length > 0); + if (otherGroup.items.length) { + populatedGroups.push(otherGroup); + } + + return populatedGroups; +} diff --git a/styles.css b/styles.css index d91949e..c9cdf91 100644 --- a/styles.css +++ b/styles.css @@ -2242,6 +2242,87 @@ input:checked+.slider:before { font-style: italic; } +.available-models-status { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-secondary); + font-size: 0.95rem; + margin-bottom: 10px; +} + +.available-models-status.success { + color: #10b981; +} + +.available-models-status.warning { + color: #d97706; +} + +.available-models-status.error { + color: #dc2626; +} + +.available-models-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.available-models-empty { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-tertiary); + padding: 6px 0; +} + +.available-models-placeholder { + color: var(--text-tertiary); +} + +.available-model-group { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 10px 12px; +} + +.available-model-group-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.available-model-group-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--text-primary); +} + +.available-model-group-label { + font-size: 0.95rem; + letter-spacing: 0.3px; +} + +.available-model-group-count { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 999px; + padding: 2px 8px; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.available-model-group-body { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + .item-value { font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; background: var(--bg-tertiary);