diff --git a/app.js b/app.js index f903604..9fa5919 100644 --- a/app.js +++ b/app.js @@ -614,6 +614,9 @@ class CLIProxyManager { // 关闭模态框 closeModal() { document.getElementById('modal').style.display = 'none'; + if (typeof this.closeOpenAIModelDiscovery === 'function') { + this.closeOpenAIModelDiscovery(); + } } } diff --git a/i18n.js b/i18n.js index 5473708..502973e 100644 --- a/i18n.js +++ b/i18n.js @@ -210,6 +210,18 @@ const i18n = { 'ai_providers.openai_model_name_placeholder': '模型名称,如 moonshotai/kimi-k2:free', 'ai_providers.openai_model_alias_placeholder': '模型别名 (可选)', 'ai_providers.openai_models_add_btn': '添加模型', + 'ai_providers.openai_models_fetch_button': '从 /v1/model 获取', + 'ai_providers.openai_models_fetch_title': '从 /v1/model 选择模型', + 'ai_providers.openai_models_fetch_hint': '使用上方 Base URL 调用 /v1/model 端点,附带首个 API Key(Bearer)与自定义请求头。', + 'ai_providers.openai_models_fetch_url_label': '请求地址', + 'ai_providers.openai_models_fetch_refresh': '重新获取', + 'ai_providers.openai_models_fetch_loading': '正在从 /v1/model 获取模型列表...', + 'ai_providers.openai_models_fetch_empty': '未获取到模型,请检查端点或鉴权信息。', + 'ai_providers.openai_models_fetch_error': '获取模型失败', + 'ai_providers.openai_models_fetch_back': '返回编辑', + 'ai_providers.openai_models_fetch_apply': '添加所选模型', + 'ai_providers.openai_models_fetch_invalid_url': '请先填写有效的 Base URL', + 'ai_providers.openai_models_fetch_added': '已添加 {count} 个新模型', 'ai_providers.openai_edit_modal_title': '编辑OpenAI兼容提供商', 'ai_providers.openai_edit_modal_name_label': '提供商名称:', 'ai_providers.openai_edit_modal_url_label': 'Base URL:', @@ -718,6 +730,18 @@ const i18n = { 'ai_providers.openai_model_name_placeholder': 'Model name, e.g. moonshotai/kimi-k2:free', 'ai_providers.openai_model_alias_placeholder': 'Model alias (optional)', 'ai_providers.openai_models_add_btn': 'Add Model', + 'ai_providers.openai_models_fetch_button': 'Fetch via /v1/model', + 'ai_providers.openai_models_fetch_title': 'Pick Models from /v1/model', + 'ai_providers.openai_models_fetch_hint': 'Call the /v1/model endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.', + 'ai_providers.openai_models_fetch_url_label': 'Request URL', + 'ai_providers.openai_models_fetch_refresh': 'Refresh', + 'ai_providers.openai_models_fetch_loading': 'Fetching models from /v1/model...', + 'ai_providers.openai_models_fetch_empty': 'No models returned. Please check the endpoint or auth.', + 'ai_providers.openai_models_fetch_error': 'Failed to fetch models', + 'ai_providers.openai_models_fetch_back': 'Back to edit', + 'ai_providers.openai_models_fetch_apply': 'Add selected models', + 'ai_providers.openai_models_fetch_invalid_url': 'Please enter a valid Base URL first', + 'ai_providers.openai_models_fetch_added': '{count} new models added', 'ai_providers.openai_edit_modal_title': 'Edit OpenAI Compatible Provider', 'ai_providers.openai_edit_modal_name_label': 'Provider Name:', 'ai_providers.openai_edit_modal_url_label': 'Base URL:', diff --git a/src/modules/ai-providers.js b/src/modules/ai-providers.js index a91173a..9f9ac03 100644 --- a/src/modules/ai-providers.js +++ b/src/modules/ai-providers.js @@ -9,6 +9,54 @@ const getStatsBySource = (stats) => { return stats || {}; }; +const buildModelEndpoint = (baseUrl) => { + if (!baseUrl) return ''; + try { + return new URL('/v1/model', baseUrl).toString(); + } catch (_) { + const trimmed = String(baseUrl).trim().replace(/\/+$/g, ''); + return trimmed ? `${trimmed}/v1/model` : ''; + } +}; + +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 []; +}; + export async function loadGeminiKeys() { try { const config = await this.getConfig(); @@ -979,6 +1027,251 @@ export async function renderOpenAIProviders(providers, keyStats = null) { }).join(''); } +const getOpenAIContext = (mode = 'new') => { + const isEdit = mode === 'edit'; + return { + mode: isEdit ? 'edit' : 'new', + baseUrlInputId: isEdit ? 'edit-provider-url' : 'new-provider-url', + apiKeyWrapperId: isEdit ? 'edit-openai-keys-wrapper' : 'new-openai-keys-wrapper', + headerWrapperId: isEdit ? 'edit-openai-headers-wrapper' : 'new-openai-headers-wrapper', + modelWrapperId: isEdit ? 'edit-provider-models-wrapper' : 'new-provider-models-wrapper' + }; +}; + +function ensureOpenAIModelDiscoveryCard(manager) { + let overlay = document.getElementById('openai-model-discovery'); + if (overlay) { + return overlay; + } + + overlay = document.createElement('div'); + overlay.id = 'openai-model-discovery'; + overlay.className = 'model-discovery-overlay'; + overlay.innerHTML = ` +
+
+
+

${i18n.t('ai_providers.openai_models_fetch_title')}

+

${i18n.t('ai_providers.openai_models_fetch_hint')}

+
+ +
+
+ +
+ + +
+
+
+
+ +
+ `; + + document.body.appendChild(overlay); + + const bind = (id, handler) => { + const el = document.getElementById(id); + if (el) { + el.addEventListener('click', handler); + } + }; + + bind('openai-model-discovery-back', () => manager.closeOpenAIModelDiscovery()); + bind('openai-model-discovery-cancel', () => manager.closeOpenAIModelDiscovery()); + bind('openai-model-discovery-refresh', () => manager.refreshOpenAIModelDiscovery()); + bind('openai-model-discovery-apply', () => manager.applyOpenAIModelDiscoverySelection()); + + return overlay; +} + +export function setOpenAIModelDiscoveryStatus(message = '', type = 'info') { + const status = document.getElementById('openai-model-discovery-status'); + if (!status) return; + status.textContent = message; + status.className = `model-discovery-status ${type}`; +} + +export function renderOpenAIModelDiscoveryList(models = []) { + const list = document.getElementById('openai-model-discovery-list'); + if (!list) return; + + if (!models.length) { + list.innerHTML = ` +
+ + ${i18n.t('ai_providers.openai_models_fetch_empty')} +
+ `; + return; + } + + list.innerHTML = models.map((model, index) => { + const name = this.escapeHtml(model.name || ''); + const alias = model.alias ? `${this.escapeHtml(model.alias)}` : ''; + const desc = model.description ? `
${this.escapeHtml(model.description)}
` : ''; + return ` + + `; + }).join(''); +} + +export function openOpenAIModelDiscovery(mode = 'new') { + const context = getOpenAIContext(mode); + const baseInput = document.getElementById(context.baseUrlInputId); + const baseUrl = baseInput ? baseInput.value.trim() : ''; + + if (!baseUrl) { + this.showNotification(i18n.t('ai_providers.openai_models_fetch_invalid_url'), 'error'); + return; + } + + const endpoint = buildModelEndpoint(baseUrl); + if (!endpoint) { + this.showNotification(i18n.t('ai_providers.openai_models_fetch_invalid_url'), 'error'); + return; + } + + const apiKeyEntries = this.collectApiKeyEntryInputs(context.apiKeyWrapperId); + const firstKey = Array.isArray(apiKeyEntries) ? apiKeyEntries.find(entry => entry && entry['api-key']) : null; + const headers = this.collectHeaderInputs(context.headerWrapperId) || {}; + + if (firstKey && !headers.Authorization && !headers.authorization) { + headers.Authorization = `Bearer ${firstKey['api-key']}`; + } + + ensureOpenAIModelDiscoveryCard(this).classList.add('active'); + this.openAIModelDiscoveryContext = { + ...context, + endpoint, + headers, + discoveredModels: [] + }; + + const urlInput = document.getElementById('openai-model-discovery-url'); + if (urlInput) { + urlInput.value = endpoint; + } + + this.renderOpenAIModelDiscoveryList([]); + this.setOpenAIModelDiscoveryStatus(i18n.t('ai_providers.openai_models_fetch_loading'), 'info'); + this.refreshOpenAIModelDiscovery(); +} + +export async function refreshOpenAIModelDiscovery() { + const context = this.openAIModelDiscoveryContext; + if (!context || !context.endpoint) { + return; + } + + this.setOpenAIModelDiscoveryStatus(i18n.t('ai_providers.openai_models_fetch_loading'), 'info'); + const list = document.getElementById('openai-model-discovery-list'); + if (list) { + list.innerHTML = '
'; + } + + try { + const response = await fetch(context.endpoint, { + headers: context.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); + context.discoveredModels = models; + + this.renderOpenAIModelDiscoveryList(models); + if (!models.length) { + this.setOpenAIModelDiscoveryStatus(i18n.t('ai_providers.openai_models_fetch_empty'), 'warning'); + } else { + this.setOpenAIModelDiscoveryStatus('', 'info'); + } + } catch (error) { + context.discoveredModels = []; + this.renderOpenAIModelDiscoveryList([]); + this.setOpenAIModelDiscoveryStatus(`${i18n.t('ai_providers.openai_models_fetch_error')}: ${error.message}`, 'error'); + } +} + +export function applyOpenAIModelDiscoverySelection() { + const context = this.openAIModelDiscoveryContext; + if (!context || !Array.isArray(context.discoveredModels) || !context.discoveredModels.length) { + this.closeOpenAIModelDiscovery(); + return; + } + + const list = document.getElementById('openai-model-discovery-list'); + if (!list) { + this.closeOpenAIModelDiscovery(); + return; + } + + const selectedIndices = Array.from(list.querySelectorAll('.model-discovery-checkbox:checked')) + .map(input => Number.parseInt(input.getAttribute('data-model-index') || '-1', 10)) + .filter(index => Number.isFinite(index) && index >= 0 && index < context.discoveredModels.length); + + const selectedModels = selectedIndices.map(index => context.discoveredModels[index]); + if (!selectedModels.length) { + this.closeOpenAIModelDiscovery(); + return; + } + + const existing = this.collectModelInputs(context.modelWrapperId); + const mergedMap = new Map(); + existing.forEach(model => { + if (model && model.name) { + mergedMap.set(model.name, { ...model }); + } + }); + + let addedCount = 0; + selectedModels.forEach(model => { + const name = model && model.name; + if (!name) return; + if (!mergedMap.has(name)) { + mergedMap.set(name, { name, ...(model.alias ? { alias: model.alias } : {}) }); + addedCount++; + } + }); + + this.populateModelFields(context.modelWrapperId, Array.from(mergedMap.values())); + this.closeOpenAIModelDiscovery(); + + if (addedCount > 0) { + const template = i18n.t('ai_providers.openai_models_fetch_added'); + const message = template.replace('{count}', addedCount); + this.showNotification(message, 'success'); + } +} + +export function closeOpenAIModelDiscovery() { + const overlay = document.getElementById('openai-model-discovery'); + if (overlay) { + overlay.classList.remove('active'); + } + this.openAIModelDiscoveryContext = null; +} + export function showAddOpenAIProviderModal() { const modal = document.getElementById('modal'); const modalBody = document.getElementById('modal-body'); @@ -1009,7 +1302,12 @@ export function showAddOpenAIProviderModal() {

${i18n.t('ai_providers.openai_models_hint')}

- +
+ + +