// js/model-detector.js // ===================== // 自定义模型检测与相关工具 // ===================== /** * @file js/model-detector.js * @description * 负责处理与自定义翻译模型检测相关的功能。允许用户输入 API Base URL 和 Key (通过 KeyManager 获取), * 然后尝试从该 Base URL 的 `/v1/models` 端点获取可用的模型列表,并更新 UI 元素以供选择。 * 功能也扩展到了在特定模态框或配置界面中按需检测模型。 * * 主要功能: * - 初始化与自定义模型相关的 UI 元素 (输入框、按钮、显示区域)。 * - 根据用户输入的 Base URL 动态更新预期的完整 API 端点显示。 * - (旧版) `detectAvailableModels`: 针对主界面全局自定义设置区域的可用模型检测逻辑。 * - `detectModelsForModal`: 专门为模态框或特定配置界面设计的模型检测逻辑,接收 Base URL 和 API Key 作为参数。 * - 更新模型选择器 (`customModelId` 下拉框或 `customModelIdInput` 输入框) 以展示检测到的模型,或允许手动输入。 * - 将检测到的可用模型列表以及用户上次选择的模型ID持久化到 localStorage,并在加载时恢复。 * - 提供获取当前选定模型ID和完整API端点的辅助函数。 * - 通过 `window.modelDetector` 对象暴露公共接口。 * * 注意: 此文件包含两套 `window.modelDetector` 的定义,后者 (IIFE内部) 是较新的版本, * 前者可能是旧版或过渡版本。在维护时需注意它们之间的功能重叠和最终应该使用的版本。 */ function appendQueryParamToUrl(urlString, param, value) { try { const url = new URL(urlString); url.searchParams.set(param, value); return url.toString(); } catch (error) { const sanitized = urlString.replace(new RegExp(`([?&])${param}=[^&]*`, 'i'), '$1').replace(/[?&]$/, ''); const separator = sanitized.includes('?') ? '&' : '?'; return `${sanitized}${separator}${encodeURIComponent(param)}=${encodeURIComponent(value)}`; } } function normalizeOpenAIModelsUrl(baseUrlInput, endpointMode = 'auto') { if (!baseUrlInput || typeof baseUrlInput !== 'string') { throw new Error('API Base URL 不能为空'); } let base = baseUrlInput.trim(); if (!base) { throw new Error('API Base URL 不能为空'); } base = base.replace(/\/+$/, ''); let lower = base.toLowerCase(); let mode = endpointMode || 'auto'; const normalizedSegment = 'models'; const v1Segment = `v1/${normalizedSegment}`; if (mode === 'manual') { const stripped = base .replace(/\/(?:v\d+\/)?chat\/completions$/i, '') .replace(/\/(?:v\d+\/)?messages$/i, '') .replace(/\/(?:v\d+\/)?completions$/i, ''); if (stripped !== base) { base = stripped.replace(/\/+$/, ''); lower = base.toLowerCase(); mode = 'auto'; } else { return base; } } const terminalPaths = [ normalizedSegment, `/${normalizedSegment}`, v1Segment, `/${v1Segment}` ]; if (terminalPaths.some(path => lower.endsWith(path))) { return base; } if (mode === 'chat') { return `${base}/${normalizedSegment}`; } if (lower.endsWith('/v1')) { return `${base}/${normalizedSegment}`; } return `${base}/${v1Segment}`; } function normalizeGeminiModelsUrl(baseUrlInput) { if (!baseUrlInput || typeof baseUrlInput !== 'string') { throw new Error('Gemini API Base URL 不能为空'); } let url; try { url = new URL(baseUrlInput.trim()); } catch (error) { try { url = new URL(`https://${baseUrlInput.trim()}`); } catch (_) { throw new Error('Gemini API Base URL 必须包含协议(例如 https://generativelanguage.googleapis.com)'); } } url.searchParams.delete('key'); let path = url.pathname || ''; if (path.length > 1 && path.endsWith('/')) { path = path.slice(0, -1); } if (!path || path === '/') { path = '/v1beta/models'; } else if (/\/models\/[^/]+$/i.test(path)) { path = path.replace(/\/models\/[^/]+$/i, '/models'); } else if (!/\/models$/i.test(path)) { if (/\/v1beta$/i.test(path) || /\/v1$/i.test(path)) { path = `${path}/models`; } else { path = `${path}/v1beta/models`; } } url.pathname = path; url.search = ''; return url.toString(); } function mapGeminiModelsResponse(modelsArray) { if (!Array.isArray(modelsArray)) return []; const mapped = modelsArray .map(model => { if (!model) return null; const fullName = model.name || model.id || ''; if (!fullName) return null; const normalizedId = fullName.includes('/') ? fullName.split('/').pop() : fullName; if (!normalizedId) return null; return { id: normalizedId, name: normalizedId, rawName: fullName, rawDisplayName: model.displayName || '' }; }) .filter(Boolean); const uniqueById = new Map(); for (const item of mapped) { if (!uniqueById.has(item.id)) { uniqueById.set(item.id, item); } } return Array.from(uniqueById.values()); } function isGeminiFormat(requestFormat, baseUrl) { const formatLower = (requestFormat || '').toLowerCase(); if (formatLower === 'gemini' || formatLower === 'gemini-preview') { return true; } if (typeof baseUrl === 'string' && /generativelanguage\.googleapis\.com/i.test(baseUrl)) { return true; } return false; } async function performModelDetection(baseUrlInput, apiKey, requestFormat = 'openai', endpointMode = 'auto') { if (!baseUrlInput || typeof baseUrlInput !== 'string') { throw new Error('进行模型检测需要有效的 API Base URL。'); } if (!apiKey) { throw new Error('进行模型检测需要一个 API Key。'); } const treatAsGemini = isGeminiFormat(requestFormat, baseUrlInput); const normalizedUrl = treatAsGemini ? normalizeGeminiModelsUrl(baseUrlInput) : normalizeOpenAIModelsUrl(baseUrlInput, endpointMode); const requestUrl = treatAsGemini ? appendQueryParamToUrl(normalizedUrl, 'key', apiKey) : normalizedUrl; const headers = treatAsGemini ? { 'Content-Type': 'application/json' } : { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }; const response = await fetch(requestUrl, { method: 'GET', headers }); if (!response.ok) { const errorText = await response.text(); throw new Error(`API 错误 (${response.status}): ${response.statusText}. ${errorText ? 'Details: ' + errorText.substring(0, 200) : ''}`); } const data = await response.json(); if (treatAsGemini) { const mapped = mapGeminiModelsResponse(data.models); mapped.sort((a, b) => a.name.localeCompare(b.name)); return mapped; } if (!data || !Array.isArray(data.data)) { throw new Error('API返回格式不符合预期'); } const popularModels = ['gpt-4', 'gpt-3.5-turbo', 'grok-', 'claude-']; return data.data .filter(model => model && model.id) .sort((a, b) => { const aPriority = popularModels.some(m => a.id.includes(m)); const bPriority = popularModels.some(m => b.id.includes(m)); if (aPriority && !bPriority) return -1; if (!aPriority && bPriority) return 1; return a.id.localeCompare(b.id); }) .map(model => ({ id: model.id, name: model.id, created: model.created })); } let availableModels = []; // 存储通过旧版 detectAvailableModels 函数检测到的可用模型列表。 let lastSelectedModel = ''; // 保存用户在旧版模型选择器中上次选择或输入的模型ID。 /** * 初始化与旧版自定义模型检测相关的UI元素。 * 主要操作: * - 获取必要的 DOM 元素 (如 Base URL 输入框, 模型ID选择器/输入框, 完整端点显示区域)。 * - 初始状态下,隐藏模型ID下拉选择框 (`customModelId`),显示模型ID手动输入框 (`customModelIdInput`)。 * - 为 Base URL 输入框添加 `input` 事件监听,当其值变化时调用 `updateApiEndpointDisplay` 更新完整API端点的显示。 * - 为模型ID下拉选择框添加 `change` 事件监听,处理选择不同模型(包括"手动输入"选项)时的逻辑: * - 选择"手动输入"时,显示输入框并聚焦,如果之前有选中的模型ID,则填充到输入框。 * - 选择列表中的具体模型时,隐藏输入框,并记录当前选择的模型ID到 `lastSelectedModel`。 * - 调用 `loadModelsFromStorage` 尝试从本地存储加载并恢复之前检测到的模型列表和用户选择。 * @deprecated 此函数主要服务于旧的全局自定义模型配置UI,新版可能使用 KeyManager 内部的配置或模态框。 */ function initModelDetectorUI() { const customApiEndpoint = document.getElementById('customApiEndpoint'); const customModelId = document.getElementById('customModelId'); const customModelIdInput = document.getElementById('customModelIdInput'); const fullApiEndpointDisplay = document.getElementById('fullApiEndpointDisplay'); const detectModelsBtn = document.getElementById('detectModelsBtn'); if (customModelId && customModelIdInput) { // 初始隐藏下拉选择框,显示输入框 customModelId.style.display = 'none'; } if (customApiEndpoint) { updateApiEndpointDisplay(); // 当Base URL输入框值变化时,更新完整API端点显示 customApiEndpoint.addEventListener('input', updateApiEndpointDisplay); } if (customModelId) { // 当模型选择器变化时,更新输入框值 customModelId.addEventListener('change', function() { if (this.value === 'manual-input') { // 选择"其他模型"时,显示输入框,并聚焦 if(customModelIdInput) customModelIdInput.style.display = 'block'; if(customModelIdInput) customModelIdInput.focus(); // 如果有上次选择的模型,填入输入框 if (lastSelectedModel && lastSelectedModel !== 'manual-input') { if(customModelIdInput) customModelIdInput.value = lastSelectedModel; } } else { // 选择列表中的模型时,隐藏输入框 if(customModelIdInput) customModelIdInput.style.display = 'none'; lastSelectedModel = this.value; } }); } // 从本地存储加载之前的可用模型 loadModelsFromStorage(); } /** * 根据用户在 Base URL 输入框 (`customApiEndpoint`) 中输入的内容, * 动态更新一个用于显示完整 OpenAI 兼容 API 端点 (`fullApiEndpointDisplay`) 的文本区域。 * 通常它会在 Base URL 后追加 `/v1/chat/completions`。 * 如果 Base URL 为空,则显示 "-"。 * @deprecated 此函数与旧版 UI 相关联。 */ function updateApiEndpointDisplay() { const baseUrlInput = document.getElementById('customApiEndpoint'); const fullApiEndpointDisplay = document.getElementById('fullApiEndpointDisplay'); if (!baseUrlInput || !fullApiEndpointDisplay) return; const baseUrl = baseUrlInput.value.trim(); if (baseUrl) { // 移除末尾的斜杠(如果有) const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; fullApiEndpointDisplay.textContent = `${cleanBaseUrl}/v1/chat/completions`; } else { fullApiEndpointDisplay.textContent = '-'; } } /** * (旧版全局模型检测功能) * 尝试从用户在主设置界面提供的自定义 API Base URL (`customApiEndpoint`) 和 API Key (`translationApiKeys` - 已移除或逻辑变更) * 来检测可用的模型列表。检测成功后会更新模型选择 UI 并将结果保存到 localStorage。 * * 主要步骤: * 1. 获取必要的 UI 元素。 * 2. 检查 API Key 是否提供 (此逻辑可能已过时,因为 Key 现在由 KeyManager 管理)。 * 3. 保存当前用户在模型选择器或输入框中的值到 `lastSelectedModel`。 * 4. 校验 Base URL 是否已输入。 * 5. 禁用检测按钮并显示加载状态。 * 6. 构建指向 `/v1/models` 的请求 URL。 * 7. 使用提供的 API Key (旧逻辑) 发起 GET 请求到该 URL。 * 8. 处理响应: * - 如果请求不成功,抛出错误。 * - 如果响应成功但数据格式不符合预期 (不是包含 `data` 数组的 JSON),抛出错误。 * - 提取 `data` 数组中的模型对象,筛选有效模型ID,并按特定优先级 (如 GPT 模型在前) 和字母顺序排序。 * 9. 调用 `updateModelSelector` 更新 UI 中的模型选择器。 * 10. 调用 `saveModelsToStorage` 将检测到的模型列表保存到 localStorage。 * 11. 显示成功或失败的通知。 * 12. 无论成功或失败,最后都恢复检测按钮的状态。 * * @async * @deprecated 此函数依赖于现已更改或移除的全局 API Key 输入方式,并且其功能正被更模块化的方法 (如 `detectModelsForModal` 或 KeyManager 内的检测) 所取代。 */ async function detectAvailableModels() { const customApiEndpoint = document.getElementById('customApiEndpoint'); const apiKeyInput = document.getElementById('translationApiKeys'); const modelSelector = document.getElementById('customModelId'); const modelInput = document.getElementById('customModelIdInput'); const detectBtn = document.getElementById('detectModelsBtn'); if (!customApiEndpoint || !modelSelector || !modelInput) { showNotification('旧版自定义模型检测所需的UI元素缺失。请使用模型管理弹窗中的功能。 ', 'warning'); console.warn('detectAvailableModels: Missing one or more required elements (customApiEndpoint, customModelId, customModelIdInput).'); return; } if (!apiKeyInput || !apiKeyInput.value) { showNotification('旧版自定义模型检测需要API Key,但相关输入框已移除。请使用模型管理。 ', 'warning'); console.warn('detectAvailableModels: translationApiKeys input not found or empty.'); return; } const apiKey = apiKeyInput.value.trim().split('\n')[0]; // 保存当前选择/输入的模型ID lastSelectedModel = modelSelector.style.display !== 'none' ? modelSelector.value : modelInput.value; if (!customApiEndpoint.value) { showNotification('请先输入有效的Base URL', 'error'); return; } // API Key is now handled by KeyManager for each source/model. // This generic apiKey from translationApiKeys might not be relevant. // if (!apiKey) { // showNotification('请先输入API Key', 'error'); // return; // } // 禁用按钮,显示加载中状态 if (detectBtn) { detectBtn.disabled = true; detectBtn.innerHTML = '正在检测...'; } try { const requestFormatSelect = document.getElementById('customRequestFormat'); const requestFormat = requestFormatSelect ? requestFormatSelect.value : 'openai'; const detectedModels = await performModelDetection(customApiEndpoint.value.trim(), apiKey, requestFormat); availableModels = detectedModels.map(model => ({ ...model })); // 更新UI updateModelSelector(availableModels); // 保存到本地存储 saveModelsToStorage(availableModels); showNotification(`成功检测到 ${availableModels.length} 个可用模型`, 'success'); } catch (error) { console.error('检测模型失败:', error); showNotification(`检测失败: ${error.message}`, 'error'); // 如果发生错误,保持输入框可见 modelSelector.style.display = 'none'; modelInput.style.display = 'block'; } finally { // 恢复按钮状态 if (detectBtn) { detectBtn.disabled = false; detectBtn.innerHTML = '检测可用模型'; } } } /** * (旧版 UI 更新) * 根据提供的模型列表 (`models`) 更新主界面上的模型选择器 (`customModelId`) 和模型输入框 (`customModelIdInput`) 的状态和内容。 * * 主要逻辑: * - 清空模型选择器的现有选项。 * - 如果模型列表不为空: * - 遍历模型列表,为每个模型创建一个 `