paper-burner/js/api/model-detector.js

848 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = '<iconify-icon icon="carbon:circle-dash" class="mr-2 animate-spin" width="16"></iconify-icon>正在检测...';
}
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 = '<iconify-icon icon="carbon:model-alt" class="mr-2" width="16"></iconify-icon>检测可用模型';
}
}
}
/**
* (旧版 UI 更新)
* 根据提供的模型列表 (`models`) 更新主界面上的模型选择器 (`customModelId`) 和模型输入框 (`customModelIdInput`) 的状态和内容。
*
* 主要逻辑:
* - 清空模型选择器的现有选项。
* - 如果模型列表不为空:
* - 遍历模型列表,为每个模型创建一个 `<option>`元素并添加到选择器中。
* - 添加一个特殊的"其他模型" (`manual-input`) 选项到选择器末尾。
* - 显示模型选择器,隐藏手动输入框。
* - 尝试恢复 `lastSelectedModel`:如果 `lastSelectedModel` 存在且在新的模型列表中,则选中它;
* 如果不在列表中,则选中"其他模型"并将 `lastSelectedModel` 的值填入输入框。
* - 如果模型列表为空,则隐藏模型选择器,仅显示手动输入框。
*
* @param {Array<Object>} models - 检测到的可用模型对象数组,每个对象应至少包含 `id` 属性。
* @deprecated 此函数与旧版全局自定义模型UI相关。
*/
function updateModelSelector(models) {
const modelSelector = document.getElementById('customModelId');
const modelInput = document.getElementById('customModelIdInput');
if (!modelSelector || !modelInput) {
console.warn('updateModelSelector: customModelId or customModelIdInput element not found. Cannot update selector.');
return;
}
// 清空当前选项
modelSelector.innerHTML = '';
if (models.length > 0) {
// 添加模型选项
models.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.id;
modelSelector.appendChild(option);
});
// 添加"其他模型"选项
const manualOption = document.createElement('option');
manualOption.value = 'manual-input';
manualOption.textContent = '- 输入其他模型 -';
modelSelector.appendChild(manualOption);
// 显示下拉选择框,隐藏输入框
modelSelector.style.display = 'block';
modelInput.style.display = 'none';
// 如果之前有选择的模型,尝试选中它
if (lastSelectedModel && lastSelectedModel !== 'manual-input') {
const option = Array.from(modelSelector.options).find(opt => opt.value === lastSelectedModel);
if (option) {
modelSelector.value = lastSelectedModel;
} else {
// 如果之前选择的模型不在列表中,选择"其他模型"并显示输入框
modelSelector.value = 'manual-input';
modelInput.style.display = 'block';
modelInput.value = lastSelectedModel;
}
}
} else {
// 没有检测到模型时,仅显示输入框
modelSelector.style.display = 'none';
modelInput.style.display = 'block';
}
}
/**
* (旧版存储)
* 将通过 `detectAvailableModels` 检测到的模型列表和检测时间保存到 localStorage。
* - 模型列表以 JSON 字符串形式存储在 `availableCustomModels`键下。
* - 检测时间的时间戳存储在 `lastDetectedModelTime`键下。
*
* @param {Array<Object>} models - 要保存的可用模型对象数组。
* @deprecated 与旧版模型检测流程绑定。
*/
function saveModelsToStorage(models) {
try {
localStorage.setItem('availableCustomModels', JSON.stringify(models));
localStorage.setItem('lastDetectedModelTime', Date.now());
} catch (e) {
console.error('保存模型列表到本地存储失败:', e);
}
}
/**
* (旧版加载)
* 从 localStorage 加载先前保存的可用模型列表和用户最后选择的模型ID并尝试更新相关的旧版 UI 元素。
*
* 主要逻辑:
* - 检查相关的 UI 元素 (`customModelId`, `customModelIdInput`) 是否存在于当前上下文中。如果不存在,则不执行 UI 更新。
* - 从 `localStorage` 读取 `availableCustomModels` 和 `lastDetectedModelTime`。
* - 如果存储的模型列表存在并且检测时间在最近7天内则认为缓存有效调用 `updateModelSelector` 使用这些模型更新 UI。
* - 从 `localStorage` 读取 `lastSelectedCustomModel` 并赋值给 `lastSelectedModel` 变量。
*
* @deprecated 与旧版模型检测和 UI 相关。
*/
function loadModelsFromStorage() {
const modelSelector = document.getElementById('customModelId');
const modelInput = document.getElementById('customModelIdInput');
// If the target elements for the model selector don't exist globally,
// then there's no UI to update with stored models in this context.
// So, we can skip calling updateModelSelector.
if (!modelSelector || !modelInput) {
// console.warn('loadModelsFromStorage: Target elements (customModelId/customModelIdInput) not found. Skipping model list update for global UI.');
// Still try to load lastSelectedModel as it might be used elsewhere or by other logic.
try {
lastSelectedModel = localStorage.getItem('lastSelectedCustomModel') || '';
} catch (e) {
console.error('Failed to load lastSelectedCustomModel from storage:', e);
}
return;
}
try {
const storedModels = localStorage.getItem('availableCustomModels');
const lastDetectedTime = localStorage.getItem('lastDetectedModelTime');
if (storedModels) {
const models = JSON.parse(storedModels);
// 检查是否是在过去7天内检测的
const isRecent = lastDetectedTime &&
(Date.now() - parseInt(lastDetectedTime)) < 7 * 24 * 60 * 60 * 1000;
if (models.length > 0 && isRecent) {
availableModels = models;
updateModelSelector(models);
}
}
// 从存储中加载最后选择的模型
lastSelectedModel = localStorage.getItem('lastSelectedCustomModel') || '';
} catch (e) {
console.error('从本地存储加载模型列表失败:', e);
}
}
/**
* (旧版获取)
* 获取当前在旧版全局自定义模型UI中选择或输入的模型ID。
* 它会检查模型选择器 (`customModelId`) 是否可见且选中的不是"手动输入"选项。
* 如果是,则返回选择器的值;否则,返回模型手动输入框 (`customModelIdInput`) 的值。
*
* @returns {string} 当前选定或输入的模型ID。如果相关UI元素不存在则返回空字符串。
* @deprecated 与旧版全局自定义模型UI相关。
*/
function getCurrentModelId() {
const modelSelector = document.getElementById('customModelId');
const modelInput = document.getElementById('customModelIdInput');
if (!modelSelector || !modelInput) {
console.warn('getCurrentModelId: modelSelector or modelInput not found.');
return '';
}
if (modelSelector.style.display !== 'none' && modelSelector.value !== 'manual-input') {
// 从选择器获取
return modelSelector.value;
} else {
// 从输入框获取
return modelInput.value.trim();
}
}
/**
* (旧版获取)
* 根据旧版全局自定义 Base URL 输入框 (`customApiEndpoint`) 的值,构建并返回一个完整的、
* 通常与 OpenAI 兼容的聊天模型 API 端点 (例如,追加 `/v1/chat/completions`)。
*
* @returns {string} 构建的完整 API 端点。如果 Base URL 输入框不存在或为空,则返回空字符串。
* @deprecated 与旧版全局自定义模型UI相关。
*/
function getFullApiEndpoint() {
const baseUrlInput = document.getElementById('customApiEndpoint');
if (!baseUrlInput) {
console.warn('getFullApiEndpoint: customApiEndpoint input not found.');
return '';
}
const baseUrl = baseUrlInput.value.trim();
if (!baseUrl) return '';
// 移除末尾的斜杠(如果有)
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
return `${cleanBaseUrl}/v1/chat/completions`;
}
/**
* (旧版更新配置)
* 收集当前在旧版全局自定义模型 UI 中设置的模型 ID 和完整 API 端点,
* 并将选择的模型 ID 保存到 localStorage (`lastSelectedCustomModel`)。
*
* @returns {{modelId: string, endpoint: string}} 包含当前模型ID和端点的对象。
* @deprecated 与旧版全局自定义模型UI相关。
*/
function updateCustomApiConfig() {
const modelId = getCurrentModelId();
const fullEndpoint = getFullApiEndpoint();
// 存储最后选择的模型
if (modelId) {
localStorage.setItem('lastSelectedCustomModel', modelId);
}
return {
modelId: modelId,
endpoint: fullEndpoint
};
}
/**
* 当 DOM 完全加载后,如果 `window.modelDetectorInitialized` 尚未定义,
* 则调用 `initModelDetectorUI` 初始化旧版的模型检测器 UI并设置标志。
* @deprecated 依赖于旧版UI初始化。
*/
document.addEventListener('DOMContentLoaded', function() {
if (typeof window.modelDetectorInitialized === 'undefined') {
initModelDetectorUI();
window.modelDetectorInitialized = true;
}
});
/**
* 为特定模态框或配置界面设计的模型检测函数。
* 它向指定的 API Base URL 的 `/v1/models` 端点发起请求,使用提供的 API Key 进行认证。
*
* 主要步骤:
* 1. 参数校验:确保 `baseUrl` 和 `apiKey` 已提供。
* 2. URL 构建:清理 `baseUrl` (移除末尾斜杠),并构建完整的 `/v1/models` 端点 URL。
* 3. API 请求:使用 `fetch` 发送 GET 请求,携带 `Authorization: Bearer {apiKey}` 头部。
* 4. 响应处理:
* - 如果响应不成功 (`!response.ok`),尝试获取错误文本,并抛出一个包含状态码和详情的错误。
* - 如果响应成功但数据格式不符合预期 (例如JSON 中没有 `data` 数组),抛出错误。
* - 从 `data` 数组中提取模型信息,筛选有效模型,按特定优先级和字母顺序排序,并简化为 `{id, name}` 对象数组。
* 5. 返回结果:返回检测到的模型对象数组。
* 6. 错误处理:捕获任何在过程中发生的错误,并将其向上抛出,以便调用方能够处理并向用户显示。
*
* @async
* @param {string} baseUrl - 要检测模型的 API 基础 URL (例如 `https://api.openai.com`)。
* @param {string} apiKey - 用于认证的 API 密钥。
* @returns {Promise<Array<Object>>} 返回一个承诺,解析为检测到的模型对象数组 (每个对象包含 `id` 和 `name`)。
* @throws {Error} 如果检测过程中发生任何错误 (如网络问题、API错误、数据格式错误)。
*/
async function detectModelsForModal(baseUrl, apiKey, requestFormat = 'openai', endpointMode = 'auto') {
if (!baseUrl) {
throw new Error('进行模型检测需要有效的 API Base URL。');
}
if (!apiKey) {
throw new Error('进行模型检测需要一个 API Key。');
}
try {
return await performModelDetection(baseUrl, apiKey, requestFormat, endpointMode);
} catch (error) {
console.error('模型检测 (弹窗内) 失败:', error);
throw error;
}
}
/**
* @global
* @namespace modelDetector (旧版定义)
* @description (可能已部分过时) 全局暴露的模型检测器相关函数集合。
* 这个版本的 `window.modelDetector` 包含了与旧版全局自定义模型设置UI交互的函数。
* @property {function} updateApiEndpointDisplay - 更新API端点显示。
* @property {function} detectAvailableModels - (旧版) 检测可用模型。
* @property {function} detectModelsForModal - (新版接口,也在此处暴露) 为模态框检测模型。
* @property {function} getCurrentModelId - (旧版) 获取当前选择的模型ID。
* @property {function} getFullApiEndpoint - (旧版) 获取完整API端点。
* @property {function} updateCustomApiConfig - (旧版) 更新自定义API配置。
*/
window.modelDetector = {
updateApiEndpointDisplay,
detectAvailableModels, // 保留旧的,用于主设置区
detectModelsForModal, // 新增的,用于弹窗
getCurrentModelId,
getFullApiEndpoint,
updateCustomApiConfig
};
/**
* @file 立即执行函数表达式 (IIFE) 内部的模型检测器实现。
* 这部分似乎是较新的或重构后的模型检测逻辑,旨在提供更通用的模型检测功能。
* 它也将其接口暴露到 `window.modelDetector`,可能会覆盖上面较旧的定义。
*/
(function () {
/**
* 创建一个在指定毫秒数后解析的 Promise用于实现延迟。
* @param {number} ms - 延迟的毫秒数。
* @returns {Promise<void>} 在延迟结束后解析的 Promise。
* @private
*/
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* (IIFE内部核心函数)
* 检测指定 API 端点支持的模型列表。
* 它会规范化 `apiEndpoint` (确保以 `/` 结尾,移除多余的 `v1/` 等)
* 然后向构建好的 `/v1/models` URL 发送 GET 请求,并处理响应。
*
* @async
* @private
* @param {string} apiEndpoint - API 的基础端点 URL。
* @param {string} apiKey - 用于认证的 API 密钥。
* @returns {Promise<Array<Object>>} 返回一个承诺,解析为经过 `processModelsResponse` 处理后的模型对象数组。
* @throws {Error} 如果 API 请求失败或发生其他错误。
*/
async function detectModels(apiEndpoint, apiKey, requestFormat = 'openai', endpointMode = 'auto') {
try {
return await performModelDetection(apiEndpoint, apiKey, requestFormat, endpointMode);
} catch (error) {
console.error('检测模型时出错:', error);
throw error;
}
}
/**
* (IIFE内部辅助函数)
* 处理从 `/v1/models` 端点返回的原始响应数据,将其转换为统一格式的模型对象数组。
* 支持处理多种可能的响应格式,例如:
* - OpenAI 格式: 响应包含一个 `data` 数组,其中每个元素是一个模型对象。
* - Anthropic 格式 (推测): 响应包含一个 `models` 数组。
* - 其他格式: 响应的 `models` 属性是一个对象其键是模型ID。
* 模型对象至少包含 `id` 和 `name` 属性。OpenAI 格式的模型还会尝试按 GPT 版本排序,然后按字母顺序排序。
*
* @private
* @param {Object} responseData - 从 API 获取的原始 JSON 响应数据。
* @returns {Array<Object>} 处理和规范化后的模型对象数组。
*/
function processModelsResponse(responseData) {
let models = [];
if (responseData && responseData.data && Array.isArray(responseData.data)) {
// OpenAI格式
models = responseData.data.map(model => ({
id: model.id,
name: model.id,
created: model.created,
// 可能的其他信息
}));
// 根据模型名称排序
models.sort((a, b) => {
// 首先尝试按GPT模型版本排序
const gptRegex = /gpt-(\d)/;
const aMatch = a.id.match(gptRegex);
const bMatch = b.id.match(gptRegex);
if (aMatch && bMatch) {
return parseInt(bMatch[1]) - parseInt(aMatch[1]); // 新版本在前
}
// 然后按名称排序
return a.id.localeCompare(b.id);
});
} else if (responseData && Array.isArray(responseData.models)) {
// Anthropic格式
models = responseData.models.map(model => ({
id: model.id || model.name,
name: model.name || model.id,
// 其他Anthropic特定信息
}));
} else if (responseData && responseData.models && !Array.isArray(responseData.models)) {
// 某些API可能以对象形式返回模型
models = Object.keys(responseData.models).map(key => ({
id: key,
name: responseData.models[key].name || key,
// 其他可能的信息
}));
}
return models;
}
/**
* (IIFE内部接口)
* 为模态框或特定的UI交互场景检测模型。
* 实际上是 `detectModels` 函数的一个封装,提供了专门的错误处理上下文日志。
*
* @async
* @param {string} apiEndpoint - API 的基础端点 URL。
* @param {string} apiKey - 用于认证的 API 密钥。
* @returns {Promise<Array<Object>>} 返回一个承诺,解析为模型对象数组。
* @throws {Error} 如果模型检测失败。
*/
async function detectModelsForModal(apiEndpoint, apiKey, requestFormat = 'openai', endpointMode = 'auto') {
try {
return await detectModels(apiEndpoint, apiKey, requestFormat, endpointMode);
} catch (error) {
console.error('通过模态框检测模型失败:', error);
throw error;
}
}
/**
* (IIFE内部接口)
* 为已配置的源站点直接检测模型。
* 也是 `detectModels` 函数的封装,用于特定场景的错误日志。
*
* @async
* @param {string} apiEndpoint - 源站点的 API 基础端点 URL。
* @param {string} apiKey - 用于认证的 API 密钥。
* @returns {Promise<Array<Object>>} 返回一个承诺,解析为模型对象数组。
* @throws {Error} 如果模型检测失败。
*/
async function detectModelsForSite(apiEndpoint, apiKey, requestFormat = 'openai', endpointMode = 'auto') {
try {
return await detectModels(apiEndpoint, apiKey, requestFormat, endpointMode);
} catch (error) {
console.error('为源站点检测模型失败:', error);
throw error;
}
}
/**
* (IIFE内部占位符/未使用)
* 模型检测器 UI 的初始化函数。
* 在这个 IIFE 版本的 `modelDetector` 中,此函数体为空,表明 UI 初始化可能由外部处理,
* 或者此版本的检测器更侧重于纯粹的API交互逻辑而非直接的UI管理。
* @private
*/
function initModelDetectorUI() {
// UI初始化逻辑 (在此版本中为空)
}
/**
* @global
* @namespace modelDetector (IIFE版本定义)
* @description (较新版本) 全局暴露的模型检测器 API。
* 此版本更侧重于通用的模型检测逻辑,可能旨在替换或增强旧版定义。
* @property {function} detectModelsForModal - 为模态框检测模型。
* @property {function} detectModelsForSite - 为已配置的源站点检测模型。
* @property {function} initModelDetectorUI - (占位符) UI 初始化函数。
*/
window.modelDetector = {
detectModelsForModal,
detectModelsForSite,
initModelDetectorUI
};
})();