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 `
+
+ `;
+ }).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);