// 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`) 的状态和内容。
*
* 主要逻辑:
* - 清空模型选择器的现有选项。
* - 如果模型列表不为空:
* - 遍历模型列表,为每个模型创建一个 `