2237 lines
123 KiB
JavaScript
2237 lines
123 KiB
JavaScript
// js/ui.js
|
||
|
||
// =====================
|
||
// UI 相关操作与交互函数
|
||
// =====================
|
||
|
||
// ---------------------
|
||
// DOM 元素获取(集中管理,便于维护)
|
||
// ---------------------
|
||
/** @type {HTMLTextAreaElement | null} mistralApiKeysTextarea - Mistral API 密钥输入框。 */
|
||
const mistralApiKeysTextarea = document.getElementById('mistralApiKeys');
|
||
/** @type {HTMLInputElement | null} rememberMistralKeyCheckbox - "记住 Mistral 密钥"复选框。 */
|
||
const rememberMistralKeyCheckbox = document.getElementById('rememberMistralKey');
|
||
/** @type {HTMLTextAreaElement | null} translationApiKeysTextarea - (通用)翻译服务 API 密钥输入框。 */
|
||
const translationApiKeysTextarea = document.getElementById('translationApiKeys');
|
||
/** @type {HTMLInputElement | null} rememberTranslationKeyCheckbox - "记住翻译密钥"复选框。 */
|
||
const rememberTranslationKeyCheckbox = document.getElementById('rememberTranslationKey');
|
||
/** @type {HTMLSelectElement | null} translationModelSelect - 翻译模型选择下拉框。 */
|
||
const translationModelSelect = document.getElementById('translationModel');
|
||
/** @type {HTMLElement | null} customModelSettingsContainer - (旧版)自定义模型设置区域的容器。 */
|
||
const customModelSettingsContainer = document.getElementById('customModelSettingsContainer');
|
||
/** @type {HTMLElement | null} customModelSettings - (旧版)自定义模型具体设置的容器。 */
|
||
const customModelSettings = document.getElementById('customModelSettings');
|
||
/** @type {HTMLElement | null} advancedSettingsToggle - 高级设置区域的切换按钮。 */
|
||
const advancedSettingsToggle = document.getElementById('advancedSettingsToggle');
|
||
/** @type {HTMLElement | null} advancedSettings - 高级设置区域的容器。 */
|
||
const advancedSettings = document.getElementById('advancedSettings');
|
||
/** @type {HTMLElement | null} advancedSettingsIcon - 高级设置切换按钮中的图标。 */
|
||
const advancedSettingsIcon = document.getElementById('advancedSettingsIcon');
|
||
/** @type {HTMLInputElement | null} maxTokensPerChunk - 每个文本块最大 Token 数的滑块输入。 */
|
||
const maxTokensPerChunk = document.getElementById('maxTokensPerChunk');
|
||
/** @type {HTMLElement | null} maxTokensPerChunkValue - 显示当前最大 Token 数的元素。 */
|
||
const maxTokensPerChunkValue = document.getElementById('maxTokensPerChunkValue');
|
||
/** @type {HTMLInputElement | null} skipProcessedFilesCheckbox - "跳过已处理文件"复选框。 */
|
||
const skipProcessedFilesCheckbox = document.getElementById('skipProcessedFiles');
|
||
/** @type {HTMLInputElement | null} concurrencyLevelInput - (OCR/通用)并发级别输入框。 */
|
||
const concurrencyLevelInput = document.getElementById('concurrencyLevel');
|
||
/** @type {HTMLElement | null} dropZone - 文件拖放区域。 */
|
||
const dropZone = document.getElementById('dropZone');
|
||
/** @type {HTMLInputElement | null} pdfFileInput - 文件选择输入框 (type="file")。 */
|
||
const pdfFileInput = document.getElementById('pdfFileInput');
|
||
/** @type {HTMLButtonElement | null} browseFilesBtn - "浏览文件"按钮。 */
|
||
const browseFilesBtn = document.getElementById('browseFilesBtn');
|
||
/** @type {HTMLElement | null} fileListContainer - 文件列表的容器。 */
|
||
const fileListContainer = document.getElementById('fileListContainer');
|
||
/** @type {HTMLElement | null} fileList - 文件列表的 UL 或 OL 元素。 */
|
||
const fileList = document.getElementById('fileList');
|
||
/** @type {HTMLButtonElement | null} clearFilesBtn - "清空文件列表"按钮。 */
|
||
const clearFilesBtn = document.getElementById('clearFilesBtn');
|
||
/** @type {HTMLSelectElement | null} targetLanguage - 目标语言选择下拉框。 */
|
||
const targetLanguage = document.getElementById('targetLanguage');
|
||
/** @type {HTMLButtonElement | null} processBtn - "开始处理"按钮。 */
|
||
const processBtn = document.getElementById('processBtn');
|
||
/** @type {HTMLButtonElement | null} downloadAllBtn - "全部下载"按钮。 */
|
||
const downloadAllBtn = document.getElementById('downloadAllBtn');
|
||
/** @type {HTMLElement | null} batchModeToggleWrapper - 批量模式开关容器。 */
|
||
const batchModeToggleWrapper = document.getElementById('batchModeToggleWrapper');
|
||
/** @type {HTMLInputElement | null} batchModeToggle - 批量模式开关。 */
|
||
const batchModeToggle = document.getElementById('batchModeToggle');
|
||
/** @type {HTMLElement | null} batchModeConfigPanel - 批量模式配置面板。 */
|
||
const batchModeConfigPanel = document.getElementById('batchModeConfig');
|
||
/** @type {HTMLElement | null} resultsSection - 处理结果显示区域。 */
|
||
const resultsSection = document.getElementById('resultsSection');
|
||
/** @type {HTMLElement | null} resultsSummary - 处理结果总结信息的容器。 */
|
||
const resultsSummary = document.getElementById('resultsSummary');
|
||
/** @type {HTMLElement | null} progressSection - 进度显示区域。 */
|
||
const progressSection = document.getElementById('progressSection');
|
||
/** @type {HTMLElement | null} batchProgressText - 批处理整体进度文本显示元素。 */
|
||
const batchProgressText = document.getElementById('batchProgressText');
|
||
/** @type {HTMLElement | null} concurrentProgressText - 当前并发任务数文本显示元素。 */
|
||
const concurrentProgressText = document.getElementById('concurrentProgressText');
|
||
/** @type {HTMLElement | null} progressStep - 当前处理步骤文本显示元素。 */
|
||
const progressStep = document.getElementById('progressStep');
|
||
/** @type {HTMLElement | null} progressPercentage - 进度百分比文本显示元素。 */
|
||
const progressPercentage = document.getElementById('progressPercentage');
|
||
/** @type {HTMLElement | null} progressBar - 进度条的内部填充元素。 */
|
||
const progressBar = document.getElementById('progressBar');
|
||
/** @type {HTMLElement | null} progressLog - 详细进度日志的容器。 */
|
||
const progressLog = document.getElementById('progressLog');
|
||
/** @type {HTMLElement | null} notificationContainer - 通知消息的容器。 */
|
||
const notificationContainer = document.getElementById('notification-container');
|
||
/** @type {HTMLElement | null} customModelSettingsToggle - (旧版)自定义模型设置的切换按钮。 */
|
||
const customModelSettingsToggle = document.getElementById('customModelSettingsToggle');
|
||
/** @type {HTMLElement | null} customModelSettingsToggleIcon - (旧版)自定义模型设置切换按钮中的图标。 */
|
||
const customModelSettingsToggleIcon = document.getElementById('customModelSettingsToggleIcon');
|
||
/** @type {HTMLElement | null} customSourceSiteContainer - 自定义API源站点选择区域的容器。 */
|
||
const customSourceSiteContainer = document.getElementById('customSourceSiteContainer');
|
||
/** @type {HTMLSelectElement | null} customSourceSiteSelect - 自定义API源站点选择下拉框。 */
|
||
const customSourceSiteSelect = document.getElementById('customSourceSiteSelect');
|
||
/** @type {HTMLElement | null} customSourceSiteToggleIcon - 自定义源站点设置区域切换按钮的图标 (可能与高级设置共用或独立)。 */
|
||
const customSourceSiteToggleIcon = document.getElementById('customSourceSiteToggleIcon'); // 注意:此ID可能与 advancedSettingsIcon 描述冲突,需确认实际HTML结构
|
||
/** @type {HTMLButtonElement | null} detectModelsBtn - "检测可用模型"按钮,通常用于自定义源站点。 */
|
||
const detectModelsBtn = document.getElementById('detectModelsBtn');
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// ... 其它初始化 ...
|
||
if (customModelSettingsToggle && customModelSettings && customModelSettingsToggleIcon) {
|
||
customModelSettingsToggle.addEventListener('click', function() {
|
||
customModelSettings.classList.toggle('hidden');
|
||
if (customModelSettings.classList.contains('hidden')) {
|
||
customModelSettingsToggleIcon.setAttribute('icon', 'carbon:chevron-down');
|
||
} else {
|
||
customModelSettingsToggleIcon.setAttribute('icon', 'carbon:chevron-up');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ===== 模型管理器变量声明 =====
|
||
const modelKeyManagerBtn = document.getElementById('modelKeyManagerBtn');
|
||
const modelKeyManagerModal = document.getElementById('modelKeyManagerModal');
|
||
const closeModelKeyManager = document.getElementById('closeModelKeyManager');
|
||
const modelListColumn = document.getElementById('modelListColumn');
|
||
const modelConfigColumn = document.getElementById('modelConfigColumn');
|
||
const keyManagerColumn = document.getElementById('keyManagerColumn');
|
||
|
||
let currentManagerUI = null;
|
||
let currentSelectedSourceSiteId = null; // 用于自定义源站选择
|
||
let selectedModelForManager = null;
|
||
const supportedModelsForKeyManager = window.supportedModelsForKeyManager || [];
|
||
|
||
// 渲染模型列表 (委托给模块)
|
||
function renderModelList() {
|
||
if (window.modelManager) {
|
||
window.modelManager.renderModelList();
|
||
}
|
||
}
|
||
|
||
// 选择模型 (委托给模块)
|
||
function selectModelForManager(modelKey) {
|
||
if (window.modelManager) {
|
||
window.modelManager.selectModel(modelKey);
|
||
selectedModelForManager = window.modelManager.getSelectedModel();
|
||
}
|
||
currentSelectedSourceSiteId = null;
|
||
}
|
||
|
||
function renderModelConfigSection(modelKey) {
|
||
modelConfigColumn.innerHTML = '';
|
||
const modelDefinition = supportedModelsForKeyManager.find(m => m.key === modelKey);
|
||
if (!modelDefinition) return;
|
||
|
||
const title = document.createElement('h3');
|
||
title.className = 'text-lg font-semibold mb-3 text-gray-800';
|
||
modelConfigColumn.appendChild(title);
|
||
|
||
if (modelKey === 'custom') {
|
||
title.textContent = `自定义源站管理`;
|
||
|
||
const addNewButton = document.createElement('button');
|
||
addNewButton.id = 'addNewSourceSiteBtn';
|
||
addNewButton.innerHTML = '<iconify-icon icon="carbon:add-filled" class="mr-2"></iconify-icon>添加新源站';
|
||
addNewButton.className = 'mb-4 px-3 py-1.5 text-sm bg-green-500 hover:bg-green-600 text-white rounded transition-colors flex items-center';
|
||
addNewButton.addEventListener('click', () => {
|
||
currentSelectedSourceSiteId = null; // 清除选中状态,表示新增
|
||
renderSourceSitesList(); // 更新列表,移除高亮
|
||
renderSourceSiteForm(null);
|
||
});
|
||
modelConfigColumn.appendChild(addNewButton);
|
||
|
||
const sitesListContainer = document.createElement('div');
|
||
sitesListContainer.id = 'sourceSitesListContainer';
|
||
modelConfigColumn.appendChild(sitesListContainer);
|
||
|
||
const siteConfigFormContainer = document.createElement('div');
|
||
siteConfigFormContainer.id = 'sourceSiteConfigFormContainer';
|
||
siteConfigFormContainer.className = 'mt-4 p-4 border border-gray-200 rounded-md hidden';
|
||
modelConfigColumn.appendChild(siteConfigFormContainer);
|
||
|
||
renderSourceSitesList();
|
||
|
||
if (!currentSelectedSourceSiteId && Object.keys(loadAllCustomSourceSites()).length === 0) {
|
||
keyManagerColumn.innerHTML = '<p class="text-sm text-gray-500">请添加并选择一个源站以管理其 API Keys。</p>';
|
||
} else if (!currentSelectedSourceSiteId) {
|
||
keyManagerColumn.innerHTML = '<p class="text-sm text-gray-500">请从上方列表选择一个源站以管理其 API Keys。</p>';
|
||
}
|
||
|
||
} else if (modelKey === 'embedding') {
|
||
title.textContent = `向量搜索与重排 - 配置`;
|
||
renderEmbeddingConfig();
|
||
} else if (modelKey === 'academicSearch') {
|
||
title.textContent = `学术搜索与代理 - 配置`;
|
||
renderAcademicSearchConfig();
|
||
} else if (modelKey === 'mistral') {
|
||
title.textContent = `${modelDefinition.name} - 配置`;
|
||
renderMistralOcrConfig();
|
||
} else if (modelKey === 'mineru') {
|
||
title.textContent = `${modelDefinition.name} - 配置`;
|
||
renderMinerUConfig();
|
||
} else if (modelKey === 'doc2x') {
|
||
title.textContent = `${modelDefinition.name} - 配置`;
|
||
renderDoc2XConfig();
|
||
} else {
|
||
title.textContent = `${modelDefinition.name} - 配置`;
|
||
}
|
||
}
|
||
|
||
// 导出到全局,供模块使用
|
||
window.renderModelConfigSection = renderModelConfigSection;
|
||
|
||
// ===== 初始化模型管理器模块 (在函数定义之后) =====
|
||
if (window.modelManager) {
|
||
window.modelManager.init({
|
||
modelKeyManagerBtn,
|
||
modelKeyManagerModal,
|
||
closeModelKeyManager,
|
||
modelListColumn,
|
||
modelConfigColumn,
|
||
keyManagerColumn
|
||
});
|
||
}
|
||
|
||
// 显示嵌入模型选择器的辅助函数
|
||
function showEmbeddingModelSelector(models, targetInput) {
|
||
// 创建一个简单的选择对话框
|
||
const container = document.createElement('div');
|
||
container.style.cssText = `
|
||
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||
width: 400px; max-width: 90vw; max-height: 60vh;
|
||
background: #fff; border-radius: 12px;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||
z-index: 100002; padding: 0; overflow: hidden;
|
||
`;
|
||
|
||
const header = document.createElement('div');
|
||
header.style.cssText = 'padding: 16px 20px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center;';
|
||
header.innerHTML = `
|
||
<h4 style="margin: 0; font-size: 16px; font-weight: 600; color: #111827;">选择嵌入模型</h4>
|
||
<button class="model-selector-close" style="border: none; background: none; font-size: 24px; color: #6b7280; cursor: pointer; line-height: 1;">×</button>
|
||
`;
|
||
|
||
const list = document.createElement('div');
|
||
list.style.cssText = 'max-height: 400px; overflow-y: auto; padding: 8px;';
|
||
|
||
models.forEach(model => {
|
||
const item = document.createElement('div');
|
||
item.style.cssText = `
|
||
padding: 12px 16px; margin: 4px 0; border-radius: 8px;
|
||
cursor: pointer; transition: all 0.2s;
|
||
border: 1px solid #e5e7eb;
|
||
`;
|
||
item.innerHTML = `
|
||
<div style="font-weight: 500; color: #111827;">${model.id}</div>
|
||
${model.owned_by ? `<div style="font-size: 12px; color: #6b7280; margin-top: 2px;">by ${model.owned_by}</div>` : ''}
|
||
`;
|
||
|
||
item.onmouseover = () => {
|
||
item.style.background = '#f3f4f6';
|
||
item.style.borderColor = '#3b82f6';
|
||
};
|
||
item.onmouseout = () => {
|
||
item.style.background = '#fff';
|
||
item.style.borderColor = '#e5e7eb';
|
||
};
|
||
item.onclick = () => {
|
||
targetInput.value = model.id;
|
||
document.body.removeChild(overlay);
|
||
document.body.removeChild(container);
|
||
};
|
||
|
||
list.appendChild(item);
|
||
});
|
||
|
||
container.appendChild(header);
|
||
container.appendChild(list);
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.style.cssText = `
|
||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5); z-index: 100001;
|
||
`;
|
||
|
||
const closeHandler = () => {
|
||
document.body.removeChild(overlay);
|
||
document.body.removeChild(container);
|
||
};
|
||
|
||
overlay.onclick = closeHandler;
|
||
header.querySelector('.model-selector-close').onclick = closeHandler;
|
||
|
||
document.body.appendChild(overlay);
|
||
document.body.appendChild(container);
|
||
}
|
||
|
||
function renderEmbeddingConfig() {
|
||
// 委托给 ui_embedding_config.js 模块
|
||
if (window.UIEmbeddingConfigRenderer) {
|
||
window.UIEmbeddingConfigRenderer.renderEmbeddingConfig(modelConfigColumn);
|
||
}
|
||
}
|
||
|
||
// Rerank 模型选择器(与嵌入选择器风格一致)
|
||
function showRerankModelSelector(models, targetInput) {
|
||
const container = document.createElement('div');
|
||
container.style.cssText = `
|
||
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||
width: 420px; max-width: 92vw; max-height: 60vh;
|
||
background: #fff; border-radius: 12px;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||
z-index: 100002; padding: 0; overflow: hidden;
|
||
`;
|
||
|
||
const header = document.createElement('div');
|
||
header.style.cssText = 'padding: 16px 20px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center;';
|
||
header.innerHTML = `
|
||
<h4 style="margin: 0; font-size: 16px; font-weight: 600; color: #111827;">选择重排模型</h4>
|
||
<button class="model-selector-close" style="border: none; background: none; font-size: 24px; color: #6b7280; cursor: pointer; line-height: 1;">×</button>
|
||
`;
|
||
|
||
const list = document.createElement('div');
|
||
list.style.cssText = 'max-height: 400px; overflow-y: auto; padding: 8px;';
|
||
|
||
(models || []).forEach(model => {
|
||
const item = document.createElement('div');
|
||
item.style.cssText = `
|
||
padding: 12px 16px; margin: 4px 0; border-radius: 8px;
|
||
cursor: pointer; transition: all 0.2s;
|
||
border: 1px solid #e5e7eb;
|
||
`;
|
||
const id = model.id || model.name || '';
|
||
item.innerHTML = `
|
||
<div style="font-weight: 500; color: #111827;">${id}</div>
|
||
${model.owned_by ? `<div style=\"font-size: 12px; color: #6b7280; margin-top: 2px;\">by ${model.owned_by}</div>` : ''}
|
||
`;
|
||
item.onmouseover = () => { item.style.background = '#f3f4f6'; item.style.borderColor = '#737373'; };
|
||
item.onmouseout = () => { item.style.background = '#fff'; item.style.borderColor = '#e5e7eb'; };
|
||
item.onclick = () => {
|
||
if (id) targetInput.value = id;
|
||
document.body.removeChild(overlay);
|
||
document.body.removeChild(container);
|
||
};
|
||
list.appendChild(item);
|
||
});
|
||
|
||
container.appendChild(header);
|
||
container.appendChild(list);
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.style.cssText = `
|
||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5); z-index: 100001;
|
||
`;
|
||
const closeHandler = () => { document.body.removeChild(overlay); document.body.removeChild(container); };
|
||
overlay.onclick = closeHandler;
|
||
header.querySelector('.model-selector-close').onclick = closeHandler;
|
||
document.body.appendChild(overlay);
|
||
document.body.appendChild(container);
|
||
}
|
||
|
||
function renderMistralOcrConfig() {
|
||
// 委托给 ui_model_ocr_config.js 模块
|
||
if (window.UIModelOcrConfigRenderer) {
|
||
window.UIModelOcrConfigRenderer.renderMistralOcrConfig(modelConfigColumn);
|
||
}
|
||
}
|
||
|
||
function renderMinerUConfig() {
|
||
// 委托给 ui_model_ocr_config.js 模块
|
||
if (window.UIModelOcrConfigRenderer) {
|
||
window.UIModelOcrConfigRenderer.renderMinerUConfig(modelConfigColumn);
|
||
}
|
||
}
|
||
|
||
function renderDoc2XConfig() {
|
||
// 委托给 ui_model_ocr_config.js 模块
|
||
if (window.UIModelOcrConfigRenderer) {
|
||
window.UIModelOcrConfigRenderer.renderDoc2XConfig(modelConfigColumn);
|
||
}
|
||
}
|
||
|
||
function renderAcademicSearchConfig() {
|
||
// 从 localStorage 加载配置
|
||
const proxyConfig = JSON.parse(localStorage.getItem('academicSearchProxyConfig') || 'null') || {
|
||
enabled: false,
|
||
baseUrl: '',
|
||
semanticScholarApiKey: '',
|
||
pubmedApiKey: '',
|
||
authKey: ''
|
||
};
|
||
|
||
// 学术搜索源配置
|
||
const sourcesConfig = JSON.parse(localStorage.getItem('academicSearchSourcesConfig') || 'null') || {
|
||
sources: [
|
||
{ key: 'crossref', name: 'CrossRef', enabled: true, order: 0 },
|
||
{ key: 'openalex', name: 'OpenAlex', enabled: true, order: 1 },
|
||
{ key: 'arxiv', name: 'arXiv', enabled: true, order: 2 },
|
||
{ key: 'pubmed', name: 'PubMed', enabled: true, order: 3 },
|
||
{ key: 'semanticscholar', name: 'Semantic Scholar', enabled: true, order: 4 }
|
||
]
|
||
};
|
||
|
||
const container = document.createElement('div');
|
||
container.className = 'space-y-4';
|
||
|
||
// Tab 切换
|
||
const tabsDiv = document.createElement('div');
|
||
tabsDiv.className = 'border-b border-gray-200';
|
||
tabsDiv.innerHTML = `
|
||
<nav class="flex -mb-px space-x-4">
|
||
<button id="academic-tab-sources" class="academic-tab px-4 py-2 text-sm font-medium border-b-2 border-blue-600 text-blue-600">
|
||
搜索源管理
|
||
</button>
|
||
<button id="academic-tab-proxy" class="academic-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||
代理配置
|
||
</button>
|
||
</nav>
|
||
`;
|
||
container.appendChild(tabsDiv);
|
||
|
||
// Tab 1: 搜索源管理
|
||
const sourcesTab = document.createElement('div');
|
||
sourcesTab.id = 'academic-sources-tab-content';
|
||
sourcesTab.className = 'pt-4';
|
||
sourcesTab.innerHTML = `
|
||
<div class="text-xs text-gray-600 bg-gray-50 border border-gray-200 rounded p-2 mb-3 flex items-center gap-1">
|
||
<iconify-icon icon="carbon:information" width="14"></iconify-icon>
|
||
<span>拖动调整查询顺序,取消勾选可禁用某个源</span>
|
||
</div>
|
||
<div id="academic-sources-list" class="space-y-2"></div>
|
||
`;
|
||
container.appendChild(sourcesTab);
|
||
|
||
// Tab 2: 代理配置
|
||
const proxyTab = document.createElement('div');
|
||
proxyTab.id = 'academic-proxy-tab-content';
|
||
proxyTab.className = 'pt-4 hidden';
|
||
container.appendChild(proxyTab);
|
||
|
||
modelConfigColumn.appendChild(container);
|
||
|
||
// 渲染搜索源列表
|
||
renderAcademicSourcesList(sourcesConfig);
|
||
|
||
// 渲染代理配置
|
||
renderAcademicProxyConfig(proxyTab, proxyConfig);
|
||
|
||
// Tab 切换逻辑
|
||
document.querySelectorAll('.academic-tab').forEach(tab => {
|
||
tab.addEventListener('click', (e) => {
|
||
const targetId = e.target.id;
|
||
|
||
// 更新 tab 样式
|
||
document.querySelectorAll('.academic-tab').forEach(t => {
|
||
t.classList.remove('border-blue-600', 'text-blue-600');
|
||
t.classList.add('border-transparent', 'text-gray-500');
|
||
});
|
||
e.target.classList.remove('border-transparent', 'text-gray-500');
|
||
e.target.classList.add('border-blue-600', 'text-blue-600');
|
||
|
||
// 切换内容
|
||
if (targetId === 'academic-tab-sources') {
|
||
sourcesTab.classList.remove('hidden');
|
||
proxyTab.classList.add('hidden');
|
||
} else {
|
||
sourcesTab.classList.add('hidden');
|
||
proxyTab.classList.remove('hidden');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderAcademicSourcesList(config) {
|
||
const listContainer = document.getElementById('academic-sources-list');
|
||
if (!listContainer) return;
|
||
|
||
listContainer.innerHTML = '';
|
||
|
||
// 按 order 排序
|
||
const sortedSources = [...config.sources].sort((a, b) => a.order - b.order);
|
||
|
||
sortedSources.forEach((source, index) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'flex items-center gap-3 p-3 bg-white border border-gray-200 rounded-md hover:shadow-sm transition-shadow cursor-move';
|
||
item.draggable = true;
|
||
item.dataset.sourceKey = source.key;
|
||
|
||
item.innerHTML = `
|
||
<iconify-icon icon="carbon:draggable" width="16" class="text-gray-400"></iconify-icon>
|
||
<input type="checkbox" ${source.enabled ? 'checked' : ''} class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 source-enable-checkbox" data-key="${source.key}">
|
||
<span class="flex-grow text-sm text-gray-700 font-medium">${source.name}</span>
|
||
<span class="text-xs text-gray-400">${source.key}</span>
|
||
`;
|
||
|
||
listContainer.appendChild(item);
|
||
|
||
// 拖拽事件
|
||
item.addEventListener('dragstart', (e) => {
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.dataTransfer.setData('text/plain', source.key);
|
||
item.classList.add('opacity-50');
|
||
});
|
||
|
||
item.addEventListener('dragend', () => {
|
||
item.classList.remove('opacity-50');
|
||
});
|
||
|
||
item.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
item.classList.add('border-blue-400', 'bg-blue-50');
|
||
});
|
||
|
||
item.addEventListener('dragleave', () => {
|
||
item.classList.remove('border-blue-400', 'bg-blue-50');
|
||
});
|
||
|
||
item.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
item.classList.remove('border-blue-400', 'bg-blue-50');
|
||
|
||
const draggedKey = e.dataTransfer.getData('text/plain');
|
||
const targetKey = source.key;
|
||
|
||
if (draggedKey !== targetKey) {
|
||
// 重新排序
|
||
const draggedIndex = config.sources.findIndex(s => s.key === draggedKey);
|
||
const targetIndex = config.sources.findIndex(s => s.key === targetKey);
|
||
|
||
const [draggedItem] = config.sources.splice(draggedIndex, 1);
|
||
config.sources.splice(targetIndex, 0, draggedItem);
|
||
|
||
// 更新 order
|
||
config.sources.forEach((s, idx) => s.order = idx);
|
||
|
||
// 保存并重新渲染
|
||
localStorage.setItem('academicSearchSourcesConfig', JSON.stringify(config));
|
||
renderAcademicSourcesList(config);
|
||
showNotification && showNotification('搜索源顺序已更新', 'success', 2000);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 启用/禁用切换
|
||
document.querySelectorAll('.source-enable-checkbox').forEach(checkbox => {
|
||
checkbox.addEventListener('change', (e) => {
|
||
const key = e.target.dataset.key;
|
||
const source = config.sources.find(s => s.key === key);
|
||
if (source) {
|
||
source.enabled = e.target.checked;
|
||
localStorage.setItem('academicSearchSourcesConfig', JSON.stringify(config));
|
||
showNotification && showNotification(`${source.name} 已${source.enabled ? '启用' : '禁用'}`, 'success', 2000);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 保存按钮
|
||
const saveBtn = document.createElement('button');
|
||
saveBtn.className = 'w-full mt-3 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700';
|
||
saveBtn.textContent = '保存配置';
|
||
saveBtn.onclick = () => {
|
||
localStorage.setItem('academicSearchSourcesConfig', JSON.stringify(config));
|
||
showNotification && showNotification('搜索源配置已保存', 'success');
|
||
};
|
||
listContainer.appendChild(saveBtn);
|
||
}
|
||
|
||
function renderAcademicProxyConfig(container, config) {
|
||
// 不要覆盖 className,保留 hidden 类
|
||
container.classList.add('space-y-4');
|
||
if (!container.classList.contains('pt-4')) {
|
||
container.classList.add('pt-4');
|
||
}
|
||
|
||
// 启用开关
|
||
const enableDiv = document.createElement('div');
|
||
enableDiv.innerHTML = `
|
||
<label class="flex items-center cursor-pointer">
|
||
<input type="checkbox" id="academic-search-enabled" ${config.enabled ? 'checked' : ''} class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||
<span class="ml-2 text-sm font-medium text-gray-700">启用学术搜索代理</span>
|
||
</label>
|
||
<p class="mt-1 text-xs text-gray-500 ml-6">开启后,PubMed、Semantic Scholar 和 arXiv 查询将通过代理服务器</p>
|
||
`;
|
||
container.appendChild(enableDiv);
|
||
|
||
// Worker URL
|
||
const urlDiv = document.createElement('div');
|
||
urlDiv.innerHTML = `
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Worker URL</label>
|
||
<input type="text" id="academic-search-base-url" value="${config.baseUrl}" placeholder="https://your-worker.workers.dev" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
|
||
<p class="mt-1 text-xs text-gray-500">Cloudflare Worker 学术搜索代理地址</p>
|
||
`;
|
||
container.appendChild(urlDiv);
|
||
|
||
// 部署模式说明
|
||
const modeInfoDiv = document.createElement('div');
|
||
modeInfoDiv.className = 'border-t pt-4';
|
||
modeInfoDiv.innerHTML = `
|
||
<div class="text-xs bg-blue-50 border border-blue-200 rounded p-3 space-y-2">
|
||
<div class="font-semibold text-blue-800 flex items-center gap-1">
|
||
<iconify-icon icon="carbon:information" width="14"></iconify-icon>
|
||
<span>支持两种部署模式</span>
|
||
</div>
|
||
<div class="text-blue-700">
|
||
<strong>方案一:透传模式(推荐)</strong><br>
|
||
• 在下方填写 API Key,通过 <code class="bg-blue-100 px-1 rounded">X-Api-Key</code> 请求头透传给 Worker<br>
|
||
• Worker 可以选择配置密钥作为备用,如果前端没有提供则使用 Worker 配置的密钥<br>
|
||
• 适合个人使用或分享给他人
|
||
</div>
|
||
<div class="text-blue-700">
|
||
<strong>方案二:共享密钥模式</strong><br>
|
||
• API Key 存储在 Worker 环境变量中(必需)<br>
|
||
• 需要在下方填写 Worker Auth Key(对应 Worker 的 <code class="bg-blue-100 px-1 rounded">AUTH_SECRET</code>)<br>
|
||
• 适合团队共享,但需要保护好 Auth Key
|
||
</div>
|
||
</div>
|
||
`;
|
||
container.appendChild(modeInfoDiv);
|
||
|
||
// Semantic Scholar API Key(透传模式)
|
||
const s2KeyDiv = document.createElement('div');
|
||
s2KeyDiv.className = 'border-t pt-4';
|
||
s2KeyDiv.innerHTML = `
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Semantic Scholar API Key(可选,透传模式)</label>
|
||
<div class="flex items-center gap-2">
|
||
<input type="password" id="academic-search-s2-key" value="${config.semanticScholarApiKey || ''}" placeholder="留空则使用免费额度" class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
|
||
<button type="button" id="academic-search-s2-toggle" class="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md transition-colors flex items-center gap-1">
|
||
<iconify-icon icon="carbon:view" width="16"></iconify-icon>
|
||
<span>显示</span>
|
||
</button>
|
||
</div>
|
||
<p class="mt-1 text-xs text-gray-500">从 <a href="https://www.semanticscholar.org/product/api" target="_blank" class="text-blue-600 hover:underline">Semantic Scholar</a> 获取,提高请求限额</p>
|
||
`;
|
||
container.appendChild(s2KeyDiv);
|
||
|
||
// PubMed API Key(透传模式)
|
||
const pubmedKeyDiv = document.createElement('div');
|
||
pubmedKeyDiv.innerHTML = `
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">PubMed API Key(可选,透传模式)</label>
|
||
<div class="flex items-center gap-2">
|
||
<input type="password" id="academic-search-pubmed-key" value="${config.pubmedApiKey || ''}" placeholder="留空则使用免费额度" class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
|
||
<button type="button" id="academic-search-pubmed-toggle" class="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md transition-colors flex items-center gap-1">
|
||
<iconify-icon icon="carbon:view" width="16"></iconify-icon>
|
||
<span>显示</span>
|
||
</button>
|
||
</div>
|
||
<p class="mt-1 text-xs text-gray-500">从 <a href="https://www.ncbi.nlm.nih.gov/account/" target="_blank" class="text-blue-600 hover:underline">NCBI</a> 获取,提高请求限额</p>
|
||
`;
|
||
container.appendChild(pubmedKeyDiv);
|
||
|
||
// Worker Auth Key(共享模式)
|
||
const authKeyDiv = document.createElement('div');
|
||
authKeyDiv.className = 'border-t pt-4';
|
||
authKeyDiv.innerHTML = `
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Worker Auth Key(共享模式)</label>
|
||
<div class="flex items-center gap-2">
|
||
<input type="password" id="academic-search-auth-key" value="${config.authKey || ''}" placeholder="如果 Worker 启用了 ENABLE_AUTH,填写这里" class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
|
||
<button type="button" id="academic-search-auth-toggle" class="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md transition-colors flex items-center gap-1">
|
||
<iconify-icon icon="carbon:view" width="16"></iconify-icon>
|
||
<span>显示</span>
|
||
</button>
|
||
</div>
|
||
<p class="mt-1 text-xs text-gray-500">对应 Worker 环境变量 <code class="bg-gray-100 px-1 rounded">AUTH_SECRET</code>(仅在共享模式需要)</p>
|
||
`;
|
||
container.appendChild(authKeyDiv);
|
||
|
||
// 联系邮箱(可选,用于 CrossRef 和 OpenAlex 的 polite pool)
|
||
const emailDiv = document.createElement('div');
|
||
emailDiv.innerHTML = `
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">联系邮箱(可选)</label>
|
||
<input type="email" id="academic-search-contact-email" value="${config.contactEmail || ''}" placeholder="your-email@example.com" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
|
||
<p class="mt-1 text-xs text-gray-500">
|
||
提供邮箱(<span class="font-semibold">Polite Pool</span>)可获得
|
||
<a href="https://www.crossref.org/documentation/retrieve-metadata/rest-api/tips-for-using-the-crossref-rest-api/#00831" target="_blank" class="font-semibold text-blue-600 hover:underline">CrossRef</a> 和
|
||
<a href="https://docs.openalex.org/how-to-use-the-api/rate-limits-and-authentication#the-polite-pool" target="_blank" class="font-semibold text-blue-600 hover:underline">OpenAlex</a>
|
||
更高的速率限制(点击链接以了解更多)
|
||
</p>
|
||
`;
|
||
container.appendChild(emailDiv);
|
||
|
||
// 测试/保存按钮
|
||
const buttonsDiv = document.createElement('div');
|
||
buttonsDiv.className = 'pt-2 grid grid-cols-1 sm:grid-cols-2 gap-2';
|
||
buttonsDiv.innerHTML = `
|
||
<button id="academic-search-test" class="px-4 py-2 border border-gray-300 bg-white text-gray-700 rounded-md hover:bg-gray-50">测试连接</button>
|
||
<button id="academic-search-save" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">保存配置</button>
|
||
`;
|
||
container.appendChild(buttonsDiv);
|
||
|
||
// 测试结果显示
|
||
const resultDiv = document.createElement('div');
|
||
resultDiv.id = 'academic-search-test-result';
|
||
resultDiv.className = 'text-sm mt-2';
|
||
resultDiv.style.display = 'none';
|
||
container.appendChild(resultDiv);
|
||
|
||
modelConfigColumn.appendChild(container);
|
||
|
||
// 绑定显示/隐藏切换事件
|
||
const toggleButtons = [
|
||
{ btnId: 'academic-search-s2-toggle', inputId: 'academic-search-s2-key' },
|
||
{ btnId: 'academic-search-pubmed-toggle', inputId: 'academic-search-pubmed-key' },
|
||
{ btnId: 'academic-search-auth-toggle', inputId: 'academic-search-auth-key' }
|
||
];
|
||
|
||
toggleButtons.forEach(({ btnId, inputId }) => {
|
||
const btn = document.getElementById(btnId);
|
||
const input = document.getElementById(inputId);
|
||
if (btn && input) {
|
||
btn.addEventListener('click', () => {
|
||
const isPassword = input.type === 'password';
|
||
input.type = isPassword ? 'text' : 'password';
|
||
btn.querySelector('span').textContent = isPassword ? '隐藏' : '显示';
|
||
btn.querySelector('iconify-icon').setAttribute('icon', isPassword ? 'carbon:view-off' : 'carbon:view');
|
||
});
|
||
}
|
||
});
|
||
|
||
// 保存配置
|
||
document.getElementById('academic-search-save').onclick = () => {
|
||
try {
|
||
const newConfig = {
|
||
enabled: document.getElementById('academic-search-enabled').checked,
|
||
baseUrl: document.getElementById('academic-search-base-url').value.trim(),
|
||
semanticScholarApiKey: document.getElementById('academic-search-s2-key').value.trim(),
|
||
pubmedApiKey: document.getElementById('academic-search-pubmed-key').value.trim(),
|
||
authKey: document.getElementById('academic-search-auth-key').value.trim(),
|
||
contactEmail: document.getElementById('academic-search-contact-email').value.trim()
|
||
};
|
||
|
||
// 保留已有的 rateLimit 信息(从测试连接获取)
|
||
const existingConfig = JSON.parse(localStorage.getItem('academicSearchProxyConfig') || '{}');
|
||
if (existingConfig.rateLimit) {
|
||
newConfig.rateLimit = existingConfig.rateLimit;
|
||
}
|
||
|
||
localStorage.setItem('academicSearchProxyConfig', JSON.stringify(newConfig));
|
||
showNotification && showNotification('学术搜索配置已保存', 'success');
|
||
|
||
// 通知学术搜索设置管理器重新加载(如果存在)
|
||
if (window.academicSearchSettingsManager && typeof window.academicSearchSettingsManager.loadSettings === 'function') {
|
||
window.academicSearchSettingsManager.loadSettings();
|
||
}
|
||
} catch (e) {
|
||
alert('保存配置失败:' + e.message);
|
||
}
|
||
};
|
||
|
||
// 测试连接
|
||
document.getElementById('academic-search-test').onclick = async () => {
|
||
const baseUrl = document.getElementById('academic-search-base-url').value.trim();
|
||
const authKey = document.getElementById('academic-search-auth-key').value.trim();
|
||
const resultDiv = document.getElementById('academic-search-test-result');
|
||
|
||
if (!baseUrl) {
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.className = 'text-sm mt-2 p-2 bg-red-50 border border-red-200 text-red-700 rounded';
|
||
resultDiv.textContent = '❌ 请填写 Worker URL';
|
||
return;
|
||
}
|
||
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.className = 'text-sm mt-2 p-2 bg-blue-50 border border-blue-200 text-blue-700 rounded';
|
||
resultDiv.textContent = '⏳ 正在测试连接...';
|
||
|
||
try {
|
||
const headers = {
|
||
'Content-Type': 'application/json'
|
||
};
|
||
|
||
// 如果配置了 Auth Key,加入请求头
|
||
if (authKey) {
|
||
headers['X-Auth-Key'] = authKey;
|
||
}
|
||
|
||
// 透传 API 密钥(用于密钥状态检测)
|
||
const proxyConfig = JSON.parse(localStorage.getItem('academicSearchProxyConfig') || '{}');
|
||
if (proxyConfig.semanticScholarApiKey) {
|
||
headers['X-Api-Key'] = proxyConfig.semanticScholarApiKey;
|
||
} else if (proxyConfig.pubmedApiKey) {
|
||
headers['X-Api-Key'] = proxyConfig.pubmedApiKey;
|
||
}
|
||
|
||
const response = await fetch(`${baseUrl}/health`, {
|
||
method: 'GET',
|
||
headers: headers
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
|
||
// 保存速率限制信息到配置中
|
||
const currentConfig = JSON.parse(localStorage.getItem('academicSearchProxyConfig') || '{}');
|
||
currentConfig.rateLimit = data.rateLimit || null;
|
||
localStorage.setItem('academicSearchProxyConfig', JSON.stringify(currentConfig));
|
||
|
||
// 格式化输出
|
||
let servicesHtml = '';
|
||
if (data.services) {
|
||
servicesHtml = '<div class="mt-2"><strong>可用服务:</strong><ul class="list-disc list-inside text-xs mt-1">';
|
||
for (const [service, info] of Object.entries(data.services)) {
|
||
const status = info.enabled ? '✓' : '✗';
|
||
const apiKeyStatus = info.hasApiKey !== undefined ? (info.hasApiKey ? ' (有密钥)' : ' (无密钥)') : '';
|
||
servicesHtml += `<li>${status} ${service}${apiKeyStatus}</li>`;
|
||
}
|
||
servicesHtml += '</ul></div>';
|
||
}
|
||
|
||
let rateLimitHtml = '';
|
||
if (data.rateLimit) {
|
||
if (data.rateLimit.enabled) {
|
||
rateLimitHtml = `<div class="mt-2 text-xs">
|
||
<strong>速率限制:</strong> TPS: ${data.rateLimit.tps}, TPM: ${data.rateLimit.tpm}, 每IP TPS: ${data.rateLimit.perIpTps}, 每IP TPM: ${data.rateLimit.perIpTpm}`;
|
||
|
||
// 显示服务级别速率限制
|
||
if (data.rateLimit.services) {
|
||
rateLimitHtml += '<div class="ml-4 mt-1 text-xs opacity-80">';
|
||
if (data.rateLimit.services.pubmed) {
|
||
rateLimitHtml += `<div>• PubMed: TPS ${data.rateLimit.services.pubmed.tps}, TPM ${data.rateLimit.services.pubmed.tpm}</div>`;
|
||
}
|
||
if (data.rateLimit.services.semanticscholar) {
|
||
rateLimitHtml += `<div>• Semantic Scholar: TPS ${data.rateLimit.services.semanticscholar.tps}, TPM ${data.rateLimit.services.semanticscholar.tpm}</div>`;
|
||
}
|
||
rateLimitHtml += '</div>';
|
||
}
|
||
rateLimitHtml += '</div>';
|
||
} else {
|
||
rateLimitHtml = '<div class="mt-2 text-xs"><strong>速率限制:</strong> 未启用</div>';
|
||
}
|
||
}
|
||
|
||
let authHtml = '';
|
||
if (data.authentication) {
|
||
authHtml = `<div class="mt-1 text-xs"><strong>认证:</strong> ${data.authentication.required ? '必需' : '不需要'}</div>`;
|
||
}
|
||
|
||
resultDiv.className = 'text-sm mt-2 p-2 bg-green-50 border border-green-200 text-green-700 rounded';
|
||
resultDiv.innerHTML = `✅ 连接成功!速率限制配置已保存${servicesHtml}${rateLimitHtml}${authHtml}`;
|
||
} else {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
} catch (error) {
|
||
resultDiv.className = 'text-sm mt-2 p-2 bg-red-50 border border-red-200 text-red-700 rounded';
|
||
resultDiv.textContent = `❌ 连接失败:${error.message}`;
|
||
}
|
||
};
|
||
}
|
||
|
||
function renderSourceSitesList() {
|
||
const sitesListContainer = document.getElementById('sourceSitesListContainer');
|
||
if (!sitesListContainer) return;
|
||
sitesListContainer.innerHTML = '';
|
||
|
||
const sites = loadAllCustomSourceSites();
|
||
const siteIds = Object.keys(sites);
|
||
|
||
if (siteIds.length === 0) {
|
||
sitesListContainer.innerHTML = '<p class="text-sm text-gray-500">还没有自定义源站。请点击上方按钮添加一个。</p>';
|
||
document.getElementById('sourceSiteConfigFormContainer').classList.add('hidden');
|
||
if (selectedModelForManager === 'custom') {
|
||
keyManagerColumn.innerHTML = '<p class="text-sm text-gray-500">请添加并选择一个源站以管理其 API Keys。</p>';
|
||
}
|
||
return;
|
||
}
|
||
|
||
const ul = document.createElement('ul');
|
||
ul.className = 'space-y-2';
|
||
|
||
siteIds.forEach(id => {
|
||
const site = sites[id];
|
||
const li = document.createElement('li');
|
||
li.className = `p-3 border rounded-md flex justify-between items-center cursor-pointer hover:bg-gray-100 transition-colors ${currentSelectedSourceSiteId === id ? 'bg-blue-50 border-blue-300 shadow-md' : 'bg-white'}`;
|
||
li.dataset.siteId = id;
|
||
|
||
li.addEventListener('click', () => {
|
||
selectSourceSite(id);
|
||
});
|
||
|
||
const displayNameSpan = document.createElement('span');
|
||
displayNameSpan.textContent = site.displayName || `源站 (ID: ${id.substring(0,8)}...)`;
|
||
displayNameSpan.className = 'font-medium text-sm text-gray-700 flex-grow';
|
||
|
||
const buttonsDiv = document.createElement('div');
|
||
buttonsDiv.className = 'space-x-2 flex-shrink-0';
|
||
|
||
const editButton = document.createElement('button');
|
||
editButton.innerHTML = '<iconify-icon icon="carbon:edit" width="16"></iconify-icon>';
|
||
editButton.title = '编辑此源站配置';
|
||
editButton.className = 'p-1.5 text-gray-500 hover:text-blue-700 rounded hover:bg-blue-100';
|
||
editButton.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
currentSelectedSourceSiteId = id;
|
||
renderSourceSitesList();
|
||
renderSourceSiteForm(site);
|
||
keyManagerColumn.innerHTML = '<p class="text-sm text-gray-500">编辑源站配置中。保存或取消以管理 Keys。</p>';
|
||
});
|
||
|
||
const deleteButton = document.createElement('button');
|
||
deleteButton.innerHTML = '<iconify-icon icon="carbon:trash-can" width="16"></iconify-icon>';
|
||
deleteButton.title = '删除此源站';
|
||
deleteButton.className = 'p-1.5 text-gray-500 hover:text-red-700 rounded hover:bg-red-100';
|
||
deleteButton.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
if (confirm(`确定要删除源站 "${site.displayName || id}" 吗?其关联的API Keys也将被删除。`)) {
|
||
deleteCustomSourceSite(id);
|
||
if (typeof showNotification === 'function') showNotification(`源站 "${site.displayName || id}" 已删除。`, 'success');
|
||
if (currentSelectedSourceSiteId === id) {
|
||
currentSelectedSourceSiteId = null;
|
||
keyManagerColumn.innerHTML = '<p class="text-sm text-gray-500">请选择一个源站以管理其 API Keys。</p>';
|
||
document.getElementById('sourceSiteConfigFormContainer').classList.add('hidden');
|
||
}
|
||
renderSourceSitesList();
|
||
}
|
||
});
|
||
|
||
buttonsDiv.appendChild(editButton);
|
||
buttonsDiv.appendChild(deleteButton);
|
||
|
||
li.appendChild(displayNameSpan);
|
||
li.appendChild(buttonsDiv);
|
||
ul.appendChild(li);
|
||
});
|
||
sitesListContainer.appendChild(ul);
|
||
|
||
if (!currentSelectedSourceSiteId && siteIds.length > 0) {
|
||
selectSourceSite(siteIds[0]);
|
||
} else if (currentSelectedSourceSiteId && sites[currentSelectedSourceSiteId]) {
|
||
renderKeyManagerForModel(`custom_source_${currentSelectedSourceSiteId}`);
|
||
} else if (siteIds.length > 0) { // Has sites, but nothing selected (e.g. after a delete)
|
||
keyManagerColumn.innerHTML = '<p class="text-sm text-gray-500">请选择一个源站以管理其 API Keys。</p>';
|
||
document.getElementById('sourceSiteConfigFormContainer').classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function selectSourceSite(siteId) {
|
||
currentSelectedSourceSiteId = siteId;
|
||
const sites = loadAllCustomSourceSites();
|
||
const site = sites[siteId];
|
||
|
||
if (site) {
|
||
renderKeyManagerForModel(`custom_source_${siteId}`);
|
||
const formContainer = document.getElementById('sourceSiteConfigFormContainer');
|
||
if (formContainer) {
|
||
formContainer.classList.add('hidden');
|
||
formContainer.innerHTML = '';
|
||
}
|
||
}
|
||
renderSourceSitesList();
|
||
}
|
||
|
||
function renderSourceSiteForm(siteData) {
|
||
const formContainer = document.getElementById('sourceSiteConfigFormContainer');
|
||
if (!formContainer) return;
|
||
formContainer.innerHTML = '';
|
||
formContainer.classList.remove('hidden');
|
||
|
||
const isEditing = siteData !== null;
|
||
const formTitleText = isEditing ? `编辑源站: ${siteData.displayName || '未命名'}` : '添加新源站';
|
||
|
||
const formTitle = document.createElement('h4');
|
||
formTitle.textContent = formTitleText;
|
||
formTitle.className = 'text-md font-semibold mb-3 text-gray-700';
|
||
formContainer.appendChild(formTitle);
|
||
|
||
const form = document.createElement('form');
|
||
form.className = 'space-y-3';
|
||
|
||
const siteIdForForm = isEditing ? siteData.id : _generateUUID_ui();
|
||
|
||
form.appendChild(createConfigInput(`sourceDisplayName_${siteIdForForm}`, '显示名称 *', isEditing ? siteData.displayName : '', 'text', '例如: 我的备用 OpenAI', () => {}));
|
||
form.appendChild(createConfigInput(`sourceApiBaseUrl_${siteIdForForm}`, 'API Base URL *', isEditing ? siteData.apiBaseUrl : '', 'url', '例如: https://api.openai.com', () => {}));
|
||
|
||
const endpointModeOptions = [
|
||
{ value: 'auto', text: '自动补全(必要时追加 /v1/...)' },
|
||
{ value: 'chat', text: '仅追加 /chat/completions' },
|
||
{ value: 'manual', text: '已是完整端点(不追加)' }
|
||
];
|
||
const endpointModeField = createConfigSelect(
|
||
`sourceEndpointMode_${siteIdForForm}`,
|
||
'端点补全方式',
|
||
isEditing ? (siteData.endpointMode || 'auto') : 'auto',
|
||
endpointModeOptions,
|
||
() => {}
|
||
);
|
||
const endpointModeHint = document.createElement('p');
|
||
endpointModeHint.className = 'mt-1 text-[11px] text-gray-500 leading-4';
|
||
endpointModeHint.textContent = '若第三方已提供完整的 /chat/completions 或 /messages 地址,请选择“已是完整端点”。';
|
||
endpointModeField.appendChild(endpointModeHint);
|
||
form.appendChild(endpointModeField);
|
||
|
||
// --- Enhanced Model ID Input with Detection ---
|
||
const modelIdGroup = document.createElement('div');
|
||
modelIdGroup.className = 'mb-3';
|
||
|
||
const modelIdLabel = document.createElement('label');
|
||
modelIdLabel.htmlFor = `sourceModelId_${siteIdForForm}`;
|
||
modelIdLabel.className = 'block text-xs font-medium text-gray-600 mb-1';
|
||
modelIdLabel.textContent = '默认模型 ID *';
|
||
modelIdGroup.appendChild(modelIdLabel);
|
||
|
||
const modelIdInputContainer = document.createElement('div');
|
||
modelIdInputContainer.id = `sourceModelIdInputContainer_${siteIdForForm}`; // Container to hold input/select
|
||
modelIdInputContainer.className = 'flex flex-col sm:flex-row sm:items-center sm:space-x-2 space-y-2 sm:space-y-0';
|
||
|
||
let modelIdEditableElement = document.createElement('input');
|
||
modelIdEditableElement.type = 'text';
|
||
modelIdEditableElement.id = `sourceModelId_${siteIdForForm}`;
|
||
modelIdEditableElement.name = `sourceModelId_${siteIdForForm}`;
|
||
modelIdEditableElement.value = isEditing ? siteData.modelId : '';
|
||
modelIdEditableElement.placeholder = '例如: gpt-4-turbo';
|
||
modelIdEditableElement.className = 'w-full sm:flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-colors';
|
||
modelIdInputContainer.appendChild(modelIdEditableElement);
|
||
|
||
const detectModelsButton = document.createElement('button');
|
||
detectModelsButton.type = 'button';
|
||
detectModelsButton.innerHTML = '<iconify-icon icon="carbon:search-locate" class="mr-1"></iconify-icon>检测';
|
||
detectModelsButton.title = '从此 Base URL 检测可用模型';
|
||
detectModelsButton.className = 'px-3 py-1.5 text-xs bg-blue-500 hover:bg-blue-600 text-white rounded transition-colors flex items-center justify-center w-full sm:w-auto';
|
||
modelIdInputContainer.appendChild(detectModelsButton);
|
||
|
||
const searchModelsButton = document.createElement('button');
|
||
searchModelsButton.type = 'button';
|
||
searchModelsButton.id = `sourceModelSearchBtn_${siteIdForForm}`;
|
||
searchModelsButton.innerHTML = '<iconify-icon icon="carbon:search" class="mr-1"></iconify-icon>搜索模型';
|
||
searchModelsButton.className = 'px-3 py-1.5 text-xs border border-gray-300 rounded text-gray-600 hover:text-blue-600 hover:border-blue-400 transition-colors flex-shrink-0 flex items-center disabled:opacity-60 disabled:cursor-not-allowed';
|
||
searchModelsButton.disabled = true;
|
||
modelIdInputContainer.appendChild(searchModelsButton);
|
||
modelIdGroup.appendChild(modelIdInputContainer);
|
||
|
||
// Temporary API Key for detection
|
||
const tempApiKeyInput = createConfigInput(`sourceTempApiKey_${siteIdForForm}`, 'API Key (检测时使用,可留空)', '', 'password', '如需临时检测可填写 Key', null, {autocomplete: 'new-password'});
|
||
tempApiKeyInput.classList.add('text-xs'); // Smaller label
|
||
tempApiKeyInput.querySelector('label').classList.add('text-gray-500');
|
||
tempApiKeyInput.querySelector('input').classList.add('text-xs', 'py-1');
|
||
const tempHint = document.createElement('p');
|
||
tempHint.className = 'mt-1 text-[11px] text-slate-400';
|
||
tempHint.textContent = '如已在下方“API Key”列表中添加 Key,可留空自动使用。';
|
||
tempApiKeyInput.appendChild(tempHint);
|
||
modelIdGroup.appendChild(tempApiKeyInput); // Add it below the model ID input group
|
||
|
||
form.appendChild(modelIdGroup);
|
||
// Event listener for detectModelsButton
|
||
detectModelsButton.addEventListener('click', async () => {
|
||
const baseUrl = document.getElementById(`sourceApiBaseUrl_${siteIdForForm}`).value.trim();
|
||
let tempApiKey = document.getElementById(`sourceTempApiKey_${siteIdForForm}`).value.trim();
|
||
let usedStoredKey = false;
|
||
|
||
if (!baseUrl) {
|
||
showNotification('请输入 API Base URL 以检测模型。', 'warning');
|
||
return;
|
||
}
|
||
if (!tempApiKey && typeof loadModelKeys === 'function') {
|
||
const storedKeys = (loadModelKeys(`custom_source_${siteIdForForm}`) || [])
|
||
.filter(k => k && k.value && k.value.trim() && k.status !== 'invalid');
|
||
if (storedKeys.length > 0) {
|
||
tempApiKey = storedKeys[0].value.trim();
|
||
usedStoredKey = true;
|
||
}
|
||
}
|
||
if (!tempApiKey) {
|
||
showNotification('未找到可用的 API Key,请在下方添加或临时输入再检测。', 'warning');
|
||
return;
|
||
}
|
||
|
||
detectModelsButton.disabled = true;
|
||
detectModelsButton.innerHTML = '<iconify-icon icon="carbon:circle-dash" class="animate-spin mr-1"></iconify-icon>检测中...';
|
||
|
||
const endpointModeSelect = document.getElementById(`sourceEndpointMode_${siteIdForForm}`);
|
||
const requestFormatSelect = document.getElementById(`sourceRequestFormat_${siteIdForForm}`);
|
||
const endpointModeValue = endpointModeSelect ? endpointModeSelect.value : 'auto';
|
||
const requestFormatValue = requestFormatSelect ? requestFormatSelect.value : 'openai';
|
||
|
||
try {
|
||
const detectedModels = await window.modelDetector.detectModelsForModal(baseUrl, tempApiKey, requestFormatValue, endpointModeValue);
|
||
if (usedStoredKey) {
|
||
showNotification && showNotification('已使用已保存的 Key 进行模型检测。', 'info');
|
||
}
|
||
showNotification(`检测到 ${detectedModels.length} 个模型。`, 'success');
|
||
|
||
const cacheKey = `custom_source_${siteIdForForm}`;
|
||
if (!detectedModels || detectedModels.length === 0) {
|
||
setModelSearchCache(cacheKey, []);
|
||
searchModelsButton.disabled = true;
|
||
if (typeof showNotification === 'function') {
|
||
showNotification('未返回模型列表,请检查 Base URL 或 API Key。', 'info');
|
||
}
|
||
return;
|
||
}
|
||
|
||
const currentModelIdValue = document.getElementById(`sourceModelId_${siteIdForForm}`).value;
|
||
const newSelect = document.createElement('select');
|
||
newSelect.id = `sourceModelId_${siteIdForForm}`; // Keep the same ID for form submission
|
||
newSelect.name = `sourceModelId_${siteIdForForm}`;
|
||
newSelect.className = 'w-full sm:flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-colors';
|
||
|
||
// Option for manual input
|
||
const manualOption = document.createElement('option');
|
||
manualOption.value = "__manual_input__"; // Special value
|
||
manualOption.textContent = "-- 手动输入其他模型 --";
|
||
newSelect.appendChild(manualOption);
|
||
|
||
const normalized = [];
|
||
detectedModels.forEach(model => {
|
||
const option = document.createElement('option');
|
||
option.value = model.id;
|
||
option.textContent = model.name || model.id;
|
||
newSelect.appendChild(option);
|
||
normalized.push({
|
||
value: model.id,
|
||
label: model.name || model.id,
|
||
description: model.rawName || ''
|
||
});
|
||
});
|
||
|
||
// Replace the input with the select
|
||
const inputContainer = document.getElementById(`sourceModelIdInputContainer_${siteIdForForm}`);
|
||
const oldInput = document.getElementById(`sourceModelId_${siteIdForForm}`);
|
||
inputContainer.insertBefore(newSelect, oldInput); // Insert select before old input
|
||
if(oldInput) oldInput.remove(); // Remove the old text input
|
||
modelIdEditableElement = newSelect; // Update reference
|
||
|
||
setModelSearchCache(cacheKey, normalized);
|
||
registerModelSearchIntegration({
|
||
key: cacheKey,
|
||
selectEl: newSelect,
|
||
buttonEl: searchModelsButton,
|
||
title: `选择模型(${document.getElementById(`sourceDisplayName_${siteIdForForm}`).value || '自定义源'})`,
|
||
placeholder: '搜索模型 ID...',
|
||
emptyMessage: '未找到匹配的模型',
|
||
onEmpty: () => {
|
||
if (!detectModelsButton.disabled) detectModelsButton.click();
|
||
return true;
|
||
}
|
||
});
|
||
searchModelsButton.disabled = false;
|
||
|
||
// Try to set the value
|
||
let modelFoundInSelect = false;
|
||
if (currentModelIdValue) {
|
||
const existingOption = Array.from(newSelect.options).find(opt => opt.value === currentModelIdValue);
|
||
if (existingOption) {
|
||
newSelect.value = currentModelIdValue;
|
||
modelFoundInSelect = true;
|
||
}
|
||
}
|
||
if (!modelFoundInSelect && detectedModels.length > 0 && !currentModelIdValue) {
|
||
newSelect.value = detectedModels[0].id; // Default to first detected if no prior value
|
||
} else if (!modelFoundInSelect && currentModelIdValue) {
|
||
newSelect.value = "__manual_input__"; // Fallback to manual if current value not in list
|
||
// We might need to re-create a text input here if manual is selected and there was a value
|
||
// For now, this just selects "manual input" in the dropdown.
|
||
}
|
||
|
||
// If manual input is selected, and there was a value, we might want to show a text input again.
|
||
// This part can be enhanced later for a smoother UX when switching back to manual from select.
|
||
|
||
} catch (error) {
|
||
showNotification(`模型检测失败: ${error.message}`, 'error');
|
||
console.error("Model detection error in form:", error);
|
||
setModelSearchCache(`custom_source_${siteIdForForm}`, []);
|
||
searchModelsButton.disabled = true;
|
||
} finally {
|
||
detectModelsButton.disabled = false;
|
||
detectModelsButton.innerHTML = '<iconify-icon icon="carbon:search-locate" class="mr-1"></iconify-icon>检测';
|
||
}
|
||
});
|
||
// --- End of Enhanced Model ID Input ---
|
||
|
||
const requestFormatOptions = [
|
||
{ value: 'openai', text: 'OpenAI 格式' }, { value: 'anthropic', text: 'Anthropic 格式' }, { value: 'gemini', text: 'Google Gemini 格式' }
|
||
];
|
||
form.appendChild(createConfigSelect(`sourceRequestFormat_${siteIdForForm}`, '请求格式', isEditing ? siteData.requestFormat : 'openai', requestFormatOptions, () => {}));
|
||
|
||
form.appendChild(createConfigInput(`sourceTemperature_${siteIdForForm}`, '温度 (0-2)', isEditing ? siteData.temperature : 0.5, 'number', '0.5', () => {}, {min:0, max:2, step:0.01}));
|
||
form.appendChild(createConfigInput(`sourceMaxTokens_${siteIdForForm}`, '最大 Tokens', isEditing ? siteData.max_tokens : 8000, 'number', '8000', () => {}, {min:1, step:1}));
|
||
|
||
const buttonsDiv = document.createElement('div');
|
||
buttonsDiv.className = 'flex space-x-2 pt-3 border-t mt-2';
|
||
|
||
const saveButton = document.createElement('button');
|
||
saveButton.type = 'submit';
|
||
saveButton.innerHTML = '<iconify-icon icon="carbon:save" class="mr-1"></iconify-icon>保存';
|
||
saveButton.className = 'px-3 py-1.5 text-sm bg-blue-500 hover:bg-blue-600 text-white rounded transition-colors flex items-center';
|
||
|
||
const cancelButton = document.createElement('button');
|
||
cancelButton.type = 'button';
|
||
cancelButton.textContent = '取消';
|
||
cancelButton.className = 'px-3 py-1.5 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 rounded transition-colors';
|
||
cancelButton.addEventListener('click', () => {
|
||
formContainer.classList.add('hidden');
|
||
formContainer.innerHTML = '';
|
||
if (currentSelectedSourceSiteId) {
|
||
selectSourceSite(currentSelectedSourceSiteId);
|
||
} else if (Object.keys(loadAllCustomSourceSites()).length > 0){
|
||
selectSourceSite(Object.keys(loadAllCustomSourceSites())[0]);
|
||
} else {
|
||
keyManagerColumn.innerHTML = '<p class="text-sm text-gray-500">请选择或添加一个源站以管理其 API Keys。</p>';
|
||
}
|
||
});
|
||
|
||
buttonsDiv.appendChild(saveButton);
|
||
buttonsDiv.appendChild(cancelButton);
|
||
form.appendChild(buttonsDiv);
|
||
|
||
form.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
const newSiteData = {
|
||
id: siteIdForForm,
|
||
displayName: document.getElementById(`sourceDisplayName_${siteIdForForm}`).value.trim(),
|
||
apiBaseUrl: document.getElementById(`sourceApiBaseUrl_${siteIdForForm}`).value.trim(),
|
||
// Get modelId from the input/select, which now shares the same ID
|
||
modelId: document.getElementById(`sourceModelId_${siteIdForForm}`).value === '__manual_input__' ? '' : document.getElementById(`sourceModelId_${siteIdForForm}`).value.trim(),
|
||
requestFormat: document.getElementById(`sourceRequestFormat_${siteIdForForm}`).value,
|
||
temperature: parseFloat(document.getElementById(`sourceTemperature_${siteIdForForm}`).value),
|
||
max_tokens: parseInt(document.getElementById(`sourceMaxTokens_${siteIdForForm}`).value),
|
||
availableModels: isEditing && siteData.availableModels ? siteData.availableModels : [],
|
||
endpointMode: document.getElementById(`sourceEndpointMode_${siteIdForForm}`).value
|
||
};
|
||
|
||
if (!newSiteData.displayName || !newSiteData.apiBaseUrl || !newSiteData.modelId) {
|
||
if (typeof showNotification === 'function') showNotification('显示名称、API Base URL 和模型 ID 不能为空!', 'error');
|
||
return;
|
||
}
|
||
|
||
saveCustomSourceSite(newSiteData);
|
||
if (typeof showNotification === 'function') showNotification(`源站 "${newSiteData.displayName}" 已${isEditing ? '更新' : '添加'}。`, 'success');
|
||
formContainer.classList.add('hidden');
|
||
formContainer.innerHTML = '';
|
||
currentSelectedSourceSiteId = siteIdForForm;
|
||
renderSourceSitesList();
|
||
selectSourceSite(siteIdForForm);
|
||
});
|
||
formContainer.appendChild(form);
|
||
document.getElementById(`sourceDisplayName_${siteIdForForm}`).focus();
|
||
}
|
||
|
||
function renderKeyManagerForModel(modelKeyOrSourceSiteModelName) {
|
||
keyManagerColumn.innerHTML = '';
|
||
if (currentManagerUI && typeof currentManagerUI.destroy === 'function') {
|
||
currentManagerUI.destroy();
|
||
}
|
||
currentManagerUI = new KeyManagerUI(
|
||
modelKeyOrSourceSiteModelName,
|
||
keyManagerColumn,
|
||
handleTestKey,
|
||
handleTestAllKeys,
|
||
loadModelKeys,
|
||
saveModelKeys
|
||
);
|
||
|
||
// 追加:对于 Gemini 提供“检测可用模型并设为默认”的小面板
|
||
if (modelKeyOrSourceSiteModelName === 'gemini') {
|
||
const panel = document.createElement('div');
|
||
panel.className = 'mt-4 p-3 border rounded-md bg-blue-50';
|
||
panel.innerHTML = `
|
||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||
<div class="text-sm text-blue-800 font-medium">Gemini 可用模型检测</div>
|
||
<button id="detectGeminiModelsBtn" class="px-2 py-1 text-xs border rounded hover:bg-white w-full sm:w-auto">检测</button>
|
||
</div>
|
||
<div id="geminiModelsArea" class="mt-2 text-sm text-gray-700 space-y-2">
|
||
<span class="text-gray-500">点击“检测”从 Google API 拉取模型列表</span>
|
||
</div>
|
||
`;
|
||
keyManagerColumn.appendChild(panel);
|
||
|
||
const detectBtn = panel.querySelector('#detectGeminiModelsBtn');
|
||
const area = panel.querySelector('#geminiModelsArea');
|
||
let searchBtn;
|
||
detectBtn.onclick = async () => {
|
||
const keys = (loadModelKeys('gemini') || []).filter(k => k.status !== 'invalid' && k.value);
|
||
if (keys.length === 0) { area.innerHTML = '<span class="text-red-600">无可用 Gemini API Key</span>'; return; }
|
||
const apiKey = keys[0].value.trim();
|
||
detectBtn.disabled = true; detectBtn.textContent = '检测中...';
|
||
let searchBtn;
|
||
try {
|
||
const resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`);
|
||
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
|
||
const data = await resp.json();
|
||
const items = Array.isArray(data.models || data.data) ? (data.models || data.data) : [];
|
||
if (items.length === 0) { area.innerHTML = '<span class="text-gray-500">未返回模型列表</span>'; return; }
|
||
const select = document.createElement('select');
|
||
select.className = 'mt-2 w-full sm:flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-colors';
|
||
const normalized = [];
|
||
items.forEach(m => {
|
||
const id = m.name ? String(m.name).split('/').pop() : (m.id || '');
|
||
if (!id || normalized.some(n => n.value === id)) return;
|
||
const opt = document.createElement('option');
|
||
opt.value = id; opt.textContent = id;
|
||
select.appendChild(opt);
|
||
const display = m.displayName || m.description || '';
|
||
normalized.push({ value: id, label: id, description: display });
|
||
});
|
||
const cacheKey = 'gemini_key_manager_detect_list';
|
||
setModelSearchCache(cacheKey, normalized);
|
||
searchBtn = document.createElement('button');
|
||
searchBtn.className = 'mt-2 px-3 py-1.5 text-xs border border-gray-300 rounded text-gray-600 hover:text-blue-600 hover:border-blue-400 transition-colors flex items-center justify-center w-full sm:w-auto';
|
||
searchBtn.innerHTML = '<iconify-icon icon="carbon:search" class="mr-1" width="14"></iconify-icon>搜索模型';
|
||
registerModelSearchIntegration({
|
||
key: cacheKey,
|
||
selectEl: select,
|
||
buttonEl: searchBtn,
|
||
title: '选择 Gemini 模型',
|
||
placeholder: '搜索模型 ID 或名称...',
|
||
emptyMessage: '未找到匹配的模型',
|
||
onEmpty: () => {
|
||
detectBtn.click();
|
||
return true;
|
||
},
|
||
onSelect: (value) => {
|
||
if (select.value !== value) {
|
||
select.value = value;
|
||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
}
|
||
});
|
||
searchBtn.disabled = normalized.length === 0;
|
||
|
||
const saveBtn = document.createElement('button');
|
||
saveBtn.className = 'mt-2 px-3 py-1.5 text-xs bg-blue-500 hover:bg-blue-600 text-white rounded w-full sm:w-auto';
|
||
saveBtn.textContent = '设为默认模型';
|
||
saveBtn.onclick = () => {
|
||
saveModelConfig('gemini', { preferredModelId: select.value });
|
||
if (typeof showNotification === 'function') showNotification(`Gemini 默认模型已设为 ${select.value}`, 'success');
|
||
};
|
||
area.innerHTML = '';
|
||
const controls = document.createElement('div');
|
||
controls.className = 'mt-2 grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-center gap-2';
|
||
controls.appendChild(select);
|
||
controls.appendChild(searchBtn);
|
||
controls.appendChild(saveBtn);
|
||
area.appendChild(controls);
|
||
} catch (e) {
|
||
console.error(e);
|
||
area.innerHTML = `<span class="text-red-600">检测失败: ${e.message}</span>`;
|
||
setModelSearchCache('gemini_key_manager_detect_list', []);
|
||
if (searchBtn) searchBtn.disabled = true;
|
||
} finally {
|
||
detectBtn.disabled = false; detectBtn.textContent = '检测';
|
||
}
|
||
};
|
||
} // 追加:DeepSeek 检测面板
|
||
if (modelKeyOrSourceSiteModelName === 'deepseek') {
|
||
const panel = document.createElement('div');
|
||
panel.className = 'mt-4 p-3 border rounded-md bg-blue-50';
|
||
panel.innerHTML = `
|
||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||
<div class="text-sm text-blue-800 font-medium">DeepSeek 可用模型检测</div>
|
||
<button id="detectDeepseekModelsBtn" class="px-2 py-1 text-xs border rounded hover:bg-white w-full sm:w-auto">检测</button>
|
||
</div>
|
||
<div id="deepseekModelsArea" class="mt-2 text-sm text-gray-700 space-y-2">
|
||
<span class="text-gray-500">点击“检测”从 DeepSeek API 拉取模型列表</span>
|
||
</div>
|
||
`;
|
||
keyManagerColumn.appendChild(panel);
|
||
|
||
const detectBtn = panel.querySelector('#detectDeepseekModelsBtn');
|
||
const area = panel.querySelector('#deepseekModelsArea');
|
||
|
||
detectBtn.onclick = async () => {
|
||
const keys = (loadModelKeys('deepseek') || []).filter(k => k.status !== 'invalid' && k.value);
|
||
if (keys.length === 0) {
|
||
area.innerHTML = '<span class="text-red-600">无可用 DeepSeek API Key</span>';
|
||
return;
|
||
}
|
||
|
||
const apiKey = keys[0].value.trim();
|
||
detectBtn.disabled = true;
|
||
detectBtn.textContent = '检测中...';
|
||
|
||
let searchBtn;
|
||
|
||
try {
|
||
const resp = await fetch('https://api.deepseek.com/v1/models', {
|
||
headers: { 'Authorization': `Bearer ${apiKey}` }
|
||
});
|
||
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
|
||
|
||
const data = await resp.json();
|
||
const items = Array.isArray(data.data) ? data.data : [];
|
||
|
||
if (items.length === 0) {
|
||
area.innerHTML = '<span class="text-gray-500">未返回模型列表</span>';
|
||
setModelSearchCache('deepseek_key_manager_detect_list', []);
|
||
return;
|
||
}
|
||
|
||
const select = document.createElement('select');
|
||
select.className = 'mt-2 w-full sm:flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-colors';
|
||
|
||
const normalized = [];
|
||
items.forEach(m => {
|
||
const id = m.id;
|
||
if (!id || normalized.some(n => n.value === id)) return;
|
||
const opt = document.createElement('option');
|
||
opt.value = id;
|
||
opt.textContent = id;
|
||
select.appendChild(opt);
|
||
normalized.push({ value: id, label: id, description: '' });
|
||
});
|
||
|
||
const cacheKey = 'deepseek_key_manager_detect_list';
|
||
setModelSearchCache(cacheKey, normalized);
|
||
|
||
searchBtn = document.createElement('button');
|
||
searchBtn.className = 'mt-2 px-3 py-1.5 text-xs border border-gray-300 rounded text-gray-600 hover:text-blue-600 hover:border-blue-400 transition-colors flex items-center justify-center w-full sm:w-auto';
|
||
searchBtn.innerHTML = '<iconify-icon icon="carbon:search" class="mr-1" width="14"></iconify-icon>搜索模型';
|
||
|
||
registerModelSearchIntegration({
|
||
key: cacheKey,
|
||
selectEl: select,
|
||
buttonEl: searchBtn,
|
||
title: '选择 DeepSeek 模型',
|
||
placeholder: '搜索模型 ID...',
|
||
emptyMessage: '未找到匹配的模型',
|
||
onEmpty: () => { detectBtn.click(); return true; },
|
||
onSelect: value => {
|
||
if (select.value !== value) {
|
||
select.value = value;
|
||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
}
|
||
});
|
||
searchBtn.disabled = normalized.length === 0;
|
||
|
||
const saveBtn = document.createElement('button');
|
||
saveBtn.className = 'mt-2 px-3 py-1.5 text-xs bg-blue-500 hover:bg-blue-600 text-white rounded w-full sm:w-auto';
|
||
saveBtn.textContent = '设为默认模型';
|
||
saveBtn.onclick = () => {
|
||
saveModelConfig('deepseek', { preferredModelId: select.value });
|
||
showNotification && showNotification(`DeepSeek 默认模型已设为 ${select.value}`, 'success');
|
||
};
|
||
|
||
area.innerHTML = '';
|
||
const controls = document.createElement('div');
|
||
controls.className = 'mt-2 grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-center gap-2';
|
||
controls.appendChild(select);
|
||
controls.appendChild(searchBtn);
|
||
controls.appendChild(saveBtn);
|
||
area.appendChild(controls);
|
||
} catch (error) {
|
||
console.error(error);
|
||
area.innerHTML = `<span class="text-red-600">检测失败: ${error.message}</span>`;
|
||
setModelSearchCache('deepseek_key_manager_detect_list', []);
|
||
if (searchBtn) searchBtn.disabled = true;
|
||
} finally {
|
||
detectBtn.disabled = false;
|
||
detectBtn.textContent = '检测';
|
||
}
|
||
};
|
||
}
|
||
|
||
if (modelKeyOrSourceSiteModelName === 'tongyi') {
|
||
const panel = document.createElement('div');
|
||
panel.className = 'mt-4 p-3 border rounded-md bg-blue-50';
|
||
panel.innerHTML = `
|
||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||
<div class="text-sm text-blue-800 font-medium">通义 可用模型检测</div>
|
||
<button id="detectTongyiModelsBtn" class="px-2 py-1 text-xs border rounded hover:bg-white w-full sm:w-auto">检测</button>
|
||
</div>
|
||
<div id="tongyiModelsArea" class="mt-2 text-sm text-gray-700 space-y-2">
|
||
<span class="text-gray-500">点击“检测”从 DashScope API 拉取模型列表</span>
|
||
</div>
|
||
`;
|
||
keyManagerColumn.appendChild(panel);
|
||
const detectBtn = panel.querySelector('#detectTongyiModelsBtn');
|
||
const area = panel.querySelector('#tongyiModelsArea');
|
||
detectBtn.onclick = async () => {
|
||
let keys = (loadModelKeys('tongyi') || []);
|
||
keys = keys.filter(k => k.status !== 'invalid' && k.value);
|
||
if (keys.length === 0) {
|
||
area.innerHTML = '<span class="text-red-600">无可用 通义 API Key</span>';
|
||
return;
|
||
}
|
||
const apiKey = keys[0].value.trim();
|
||
detectBtn.disabled = true;
|
||
detectBtn.textContent = '检测中...';
|
||
let searchBtn;
|
||
try {
|
||
const resp = await fetch('https://dashscope.aliyuncs.com/compatible-mode/v1/models', {
|
||
headers: { 'Authorization': `Bearer ${apiKey}` }
|
||
});
|
||
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
|
||
const data = await resp.json();
|
||
const items = Array.isArray(data.data) ? data.data : (Array.isArray(data.models) ? data.models : (Array.isArray(data?.data?.models) ? data.data.models : []));
|
||
if (!items || items.length === 0) {
|
||
area.innerHTML = '<span class="text-gray-500">未返回模型列表</span>';
|
||
setModelSearchCache('tongyi_key_manager_detect_list', []);
|
||
return;
|
||
}
|
||
const select = document.createElement('select');
|
||
select.className = 'mt-2 w-full sm:flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-colors';
|
||
const normalized = [];
|
||
items.forEach(m => {
|
||
const id = m.model || m.id || m.name;
|
||
if (!id || normalized.some(n => n.value === id)) return;
|
||
const opt = document.createElement('option');
|
||
opt.value = id;
|
||
opt.textContent = id;
|
||
select.appendChild(opt);
|
||
normalized.push({ value: id, label: id, description: '' });
|
||
});
|
||
const cacheKey = 'tongyi_key_manager_detect_list';
|
||
setModelSearchCache(cacheKey, normalized);
|
||
searchBtn = document.createElement('button');
|
||
searchBtn.className = 'mt-2 px-3 py-1.5 text-xs border border-gray-300 rounded text-gray-600 hover:text-blue-600 hover:border-blue-400 transition-colors flex items-center justify-center w-full sm:w-auto';
|
||
searchBtn.innerHTML = '<iconify-icon icon="carbon:search" class="mr-1" width="14"></iconify-icon>搜索模型';
|
||
registerModelSearchIntegration({
|
||
key: cacheKey,
|
||
selectEl: select,
|
||
buttonEl: searchBtn,
|
||
title: '选择通义模型',
|
||
placeholder: '搜索模型 ID...',
|
||
emptyMessage: '未找到匹配的模型',
|
||
onEmpty: () => { detectBtn.click(); return true; },
|
||
onSelect: value => {
|
||
if (select.value !== value) {
|
||
select.value = value;
|
||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
}
|
||
});
|
||
searchBtn.disabled = normalized.length === 0;
|
||
const saveBtn = document.createElement('button');
|
||
saveBtn.className = 'mt-2 px-3 py-1.5 text-xs bg-blue-500 hover:bg-blue-600 text-white rounded w-full sm:w-auto';
|
||
saveBtn.textContent = '设为默认模型';
|
||
saveBtn.onclick = () => {
|
||
saveModelConfig('tongyi', { preferredModelId: select.value });
|
||
showNotification && showNotification(`通义 默认模型已设为 ${select.value}`, 'success');
|
||
};
|
||
area.innerHTML = '';
|
||
const controls = document.createElement('div');
|
||
controls.className = 'mt-2 grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-center gap-2';
|
||
controls.appendChild(select);
|
||
controls.appendChild(searchBtn);
|
||
controls.appendChild(saveBtn);
|
||
area.appendChild(controls);
|
||
} catch (e) {
|
||
if (typeof console !== 'undefined') console.error(e);
|
||
area.innerHTML = `<span class="text-red-600">检测失败: ${e.message}</span>`;
|
||
setModelSearchCache('tongyi_key_manager_detect_list', []);
|
||
if (searchBtn) searchBtn.disabled = true;
|
||
} finally {
|
||
detectBtn.disabled = false;
|
||
detectBtn.textContent = '检测';
|
||
}
|
||
};
|
||
}
|
||
|
||
// 追加:火山 检测面板(两个火山条目使用 'volcano')
|
||
if (modelKeyOrSourceSiteModelName === 'volcano') {
|
||
const panel = document.createElement('div');
|
||
panel.className = 'mt-4 p-3 border rounded-md bg-blue-50';
|
||
panel.innerHTML = `
|
||
<div class="flex items-center justify之间">
|
||
<div class="text-sm text-blue-800 font-medium">火山 可用模型检测</div>
|
||
<button id="detectVolcanoModelsBtn" class="px-2 py-1 text-xs border rounded hover:bg白">检测</button>
|
||
</div>
|
||
<div id="volcanoModelsArea" class="mt-2 text-sm text-gray-700">
|
||
<span class="text-gray-500">点击“检测”从 Ark API 拉取模型列表</span>
|
||
</div>
|
||
`;
|
||
// 修正误植
|
||
panel.innerHTML = panel.innerHTML.replace('之间', 'between').replace('白', 'white');
|
||
keyManagerColumn.appendChild(panel);
|
||
const detectBtn = panel.querySelector('#detectVolcanoModelsBtn');
|
||
const area = panel.querySelector('#volcanoModelsArea');
|
||
// 按用户要求:不提供在线检测,改为手动输入并保存
|
||
if (detectBtn && area) {
|
||
detectBtn.style.display = 'none';
|
||
area.innerHTML = `
|
||
<div class="flex items-center gap-2">
|
||
<input id="volcanoKMManualInput" type="text" class="w-full sm:flex-grow px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-1 focus:ring-blue-500 focus:border-blue-500" placeholder="例如:doubao-1-5-pro-32k-250115">
|
||
<button id="volcanoKMSaveBtn" class="px-4 py-1.5 text-xs bg-blue-500 hover:bg-blue-600 text-white rounded whitespace-nowrap">设为默认</button>
|
||
</div>
|
||
<div class="mt-1 text-xs text-gray-600">不提供在线检测;请手动输入模型ID。</div>
|
||
`;
|
||
try { const cfg = loadModelConfig && loadModelConfig('volcano'); if (cfg && (cfg.preferredModelId || cfg.modelId)) area.querySelector('#volcanoKMManualInput').value = cfg.preferredModelId || cfg.modelId; } catch {}
|
||
const saveBtn = area.querySelector('#volcanoKMSaveBtn');
|
||
saveBtn.onclick = () => {
|
||
const val = (area.querySelector('#volcanoKMManualInput').value || '').trim();
|
||
if (!val) { showNotification && showNotification('请输入模型ID', 'warning'); return; }
|
||
saveModelConfig && saveModelConfig('volcano', { preferredModelId: val });
|
||
showNotification && showNotification(`火山 默认模型已设为 ${val}`, 'success');
|
||
};
|
||
// 不再绑定在线检测
|
||
return;
|
||
}
|
||
if (detectBtn) detectBtn.style.display = 'none';
|
||
if (area) area.innerHTML = '<span class="text-gray-600">请手动输入模型ID,或在设置中选择。示例:<code>doubao-1-5-pro-32k-250115</code> / <code>deepseek-v3-250324</code></span>';
|
||
}
|
||
|
||
if (modelKeyOrSourceSiteModelName === 'deeplx') {
|
||
const panel = document.createElement('div');
|
||
panel.className = 'mt-4 p-3 border rounded-md bg-blue-50';
|
||
const placeholderHtml = DEEPLX_DEFAULT_ENDPOINT_TEMPLATE.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
panel.innerHTML = `
|
||
<div class="text-sm text-blue-800 font-medium mb-2">DeepLX 接口模板</div>
|
||
<div class="flex items-center gap-2">
|
||
<input id="deeplxEndpointTemplateInput-manager" type="text" class="flex-1 px-3 py-1.5 border border-blue-200 rounded-md text-sm focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-colors" placeholder="${placeholderHtml}">
|
||
<button id="deeplxEndpointResetBtn-manager" type="button" class="px-2 py-1 text-xs border border-blue-300 rounded hover:bg-white">恢复默认</button>
|
||
</div>
|
||
<p class="mt-2 text-xs text-blue-900 leading-5">模板中的 <code><api-key></code> 或 {API_KEY} 会自动替换为当前使用的 Key,可用于自建代理地址。</p>
|
||
`;
|
||
keyManagerColumn.appendChild(panel);
|
||
const inputEl = panel.querySelector('#deeplxEndpointTemplateInput-manager');
|
||
const resetBtn = panel.querySelector('#deeplxEndpointResetBtn-manager');
|
||
setupDeeplxEndpointInput(inputEl, resetBtn);
|
||
}
|
||
}
|
||
|
||
// 导出到全局,供模块使用
|
||
window.renderKeyManagerForModel = renderKeyManagerForModel;
|
||
|
||
async function handleTestKey(modelName, keyObject) {
|
||
if (!currentManagerUI) return;
|
||
currentManagerUI.updateKeyStatus(keyObject.id, 'testing');
|
||
|
||
let modelConfigForTest = {};
|
||
let apiEndpointForTest = null;
|
||
// 获取友好显示名
|
||
let modelDisplayNameForNotification = modelName;
|
||
if (modelName.startsWith('custom_source_')) {
|
||
const sourceSiteId = modelName.replace('custom_source_', '');
|
||
if (typeof loadAllCustomSourceSites === 'function') {
|
||
const sites = loadAllCustomSourceSites();
|
||
const site = sites[sourceSiteId];
|
||
if (site && site.displayName) {
|
||
modelDisplayNameForNotification = `"${site.displayName}"`;
|
||
} else {
|
||
modelDisplayNameForNotification = `源站点 (ID: ...${sourceSiteId.slice(-8)})`;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (modelName.startsWith('custom_source_')) {
|
||
const sourceSiteId = modelName.replace('custom_source_', '');
|
||
const allSites = loadAllCustomSourceSites();
|
||
const siteConfig = allSites[sourceSiteId];
|
||
if (siteConfig && siteConfig.apiBaseUrl && siteConfig.modelId) {
|
||
apiEndpointForTest = siteConfig.apiBaseUrl;
|
||
modelConfigForTest = {
|
||
...siteConfig,
|
||
apiEndpoint: siteConfig.apiBaseUrl
|
||
};
|
||
} else {
|
||
currentManagerUI.updateKeyStatus(keyObject.id, 'untested');
|
||
showNotification(`源站配置不完整 (ID: ${sourceSiteId}),缺少 API Base URL 或模型 ID。请在配置区完善。`, 'error');
|
||
return;
|
||
}
|
||
} else {
|
||
modelConfigForTest = loadModelConfig(modelName) || {};
|
||
apiEndpointForTest = modelConfigForTest.apiEndpoint;
|
||
}
|
||
|
||
try {
|
||
let isValid = false;
|
||
if (modelName === 'mistral') {
|
||
// 特殊处理:使用 Mistral 的 /v1/models 端点快速测活
|
||
try {
|
||
const resp = await fetch('https://api.mistral.ai/v1/models', {
|
||
headers: { 'Authorization': `Bearer ${keyObject.value}` }
|
||
});
|
||
isValid = resp.ok;
|
||
} catch (e) {
|
||
isValid = false;
|
||
}
|
||
} else {
|
||
const r = await testModelKey(modelName, keyObject.value, modelConfigForTest, apiEndpointForTest);
|
||
isValid = !!r;
|
||
}
|
||
currentManagerUI.updateKeyStatus(keyObject.id, isValid ? 'valid' : 'invalid');
|
||
showNotification(`Key (${keyObject.value.substring(0,4)}...) for ${modelDisplayNameForNotification} test: ${isValid ? '有效' : '无效'}`, isValid ? 'success' : 'error');
|
||
} catch (error) {
|
||
console.error("Key test error:", error);
|
||
currentManagerUI.updateKeyStatus(keyObject.id, 'invalid');
|
||
showNotification(`Key test for ${modelDisplayNameForNotification} failed: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function handleTestAllKeys(modelName, keysArray) {
|
||
// 获取友好显示名
|
||
let modelDisplayNameForNotification = modelName;
|
||
if (modelName.startsWith('custom_source_')) {
|
||
const sourceSiteId = modelName.replace('custom_source_', '');
|
||
if (typeof loadAllCustomSourceSites === 'function') {
|
||
const sites = loadAllCustomSourceSites();
|
||
const site = sites[sourceSiteId];
|
||
if (site && site.displayName) {
|
||
modelDisplayNameForNotification = `"${site.displayName}"`;
|
||
} else {
|
||
modelDisplayNameForNotification = `源站点 (ID: ...${sourceSiteId.slice(-8)})`;
|
||
}
|
||
}
|
||
}
|
||
showNotification(`开始批量测试 ${modelDisplayNameForNotification} 的 ${keysArray.length} 个Key...`, 'info');
|
||
for (const keyObj of keysArray) {
|
||
await handleTestKey(modelName, keyObj);
|
||
}
|
||
showNotification(`${modelDisplayNameForNotification} 的所有 Key 测试完毕。`, 'info');
|
||
}
|
||
|
||
// 旧的 updateCustomModelConfig, handleDetectModelsInModal might need to be adapted or removed
|
||
// if their functionality is now part of the source site form.
|
||
// The functions createConfigInput and createConfigSelect are still useful for the new form.
|
||
|
||
window.refreshKeyManagerForModel = (modelName, keyId, newStatus) => {
|
||
if (modelKeyManagerModal && !modelKeyManagerModal.classList.contains('hidden') &&
|
||
currentManagerUI && currentManagerUI.modelName === modelName) {
|
||
currentManagerUI.updateKeyStatus(keyId, newStatus);
|
||
}
|
||
};
|
||
|
||
// 新增: Event-Listener für das customSourceSiteSelect Dropdown-Menü
|
||
if (customSourceSiteSelect) {
|
||
customSourceSiteSelect.addEventListener('change', () => {
|
||
// 保存当前选中的源站点ID到设置
|
||
let settings = typeof loadSettings === 'function' ? loadSettings() : {};
|
||
settings.selectedCustomSourceSiteId = customSourceSiteSelect.value;
|
||
if (typeof saveSettings === 'function') {
|
||
saveSettings(settings);
|
||
} else {
|
||
localStorage.setItem('paperBurnerSettings', JSON.stringify(settings));
|
||
}
|
||
// 原有逻辑
|
||
saveCurrentSettings && saveCurrentSettings();
|
||
// 新增:切换后立即刷新信息面板
|
||
if (typeof updateCustomSourceSiteInfo === 'function') {
|
||
updateCustomSourceSiteInfo(customSourceSiteSelect.value);
|
||
}
|
||
if (typeof window.refreshCustomSourceSiteInfo === 'function') {
|
||
window.refreshCustomSourceSiteInfo({ autoSelect: false });
|
||
}
|
||
});
|
||
}
|
||
|
||
// 新增:更新自定义源站点信息函数
|
||
function updateCustomSourceSiteInfo(siteId) {
|
||
const infoContainer = document.getElementById('customSourceSiteInfo');
|
||
const manageKeyBtn = document.getElementById('manageSourceSiteKeyBtn');
|
||
|
||
if (!infoContainer || !manageKeyBtn) return;
|
||
|
||
if (!siteId) {
|
||
infoContainer.classList.add('hidden');
|
||
manageKeyBtn.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const sites = typeof loadAllCustomSourceSites === 'function' ? loadAllCustomSourceSites() : {};
|
||
const site = sites[siteId];
|
||
|
||
if (site) {
|
||
// 显示信息面板和按钮
|
||
infoContainer.classList.remove('hidden');
|
||
manageKeyBtn.classList.remove('hidden');
|
||
|
||
// 获取可用API Key数量 - 移到前面以便模板使用
|
||
const customSourceKeysCount = typeof loadModelKeys === 'function' ?
|
||
(loadModelKeys(`custom_source_${siteId}`) || []).filter(k => k.status !== 'invalid').length : 0;
|
||
|
||
const endpointModeLabels = {
|
||
auto: '自动补全 /v1/... (默认)',
|
||
chat: '仅追加 /chat/completions',
|
||
manual: '完整端点(不自动追加)'
|
||
};
|
||
const endpointModeLabel = endpointModeLabels[site.endpointMode] || endpointModeLabels.auto;
|
||
|
||
// 构建HTML以展示站点信息
|
||
let infoHtml = `
|
||
<div class="p-3">
|
||
<h3 class="font-bold text-gray-800 text-xl mt-1 mb-2">${site.displayName || '未命名源站点'}</h3>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
|
||
<div><span class="font-medium">API Base URL:</span> <span class="text-gray-600">${site.apiBaseUrl || '未设置'}</span></div>
|
||
<div><span class="font-medium">端点补全:</span> <span class="text-gray-600">${endpointModeLabel}</span></div>
|
||
<div><span class="font-medium">当前模型:</span> <span id="currentModelPreview_${siteId}" class="text-gray-600">${site.modelId || '未设置'}</span></div>
|
||
<div><span class="font-medium">请求格式:</span> <span class="text-gray-600">${site.requestFormat || 'openai'}</span></div>
|
||
<div><span class="font-medium">温度:</span> <span class="text-gray-600">${site.temperature || '0.5'}</span></div>
|
||
</div>`;
|
||
|
||
// 如果有可用模型列表,则展示为可选择的下拉框
|
||
if (site.availableModels && site.availableModels.length > 0) {
|
||
infoHtml += `
|
||
<div class="mt-2 border-t border-dashed pt-2">
|
||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||
<div class="font-medium mb-1">选择模型:</div>
|
||
<div class="flex flex-col sm:flex-row sm:items-center sm:space-x-2 gap-2">
|
||
<span class="text-xs text-green-600 flex items-center">
|
||
<iconify-icon icon="carbon:checkmark-filled" class="mr-1" width="14"></iconify-icon>
|
||
检测到 ${site.availableModels.length} 个可用模型
|
||
</span>
|
||
<button id="reDetectModelsBtn_${siteId}" class="ml-1 px-1.5 py-0.5 bg-gray-100 hover:bg-blue-100 text-blue-600 rounded flex items-center" title="重新检测模型">
|
||
<iconify-icon icon="carbon:renew" class="animate-spin-slow" width="16"></iconify-icon>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-col sm:flex-row sm:items-center sm:space-x-2 space-y-2 sm:space-y-0 mt-2">
|
||
<select id="sourceSiteModelSelect_${siteId}" class="w-full sm:flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-colors">`;
|
||
|
||
site.availableModels.forEach(model => {
|
||
const modelName = model.name || model.id;
|
||
const modelId = model.id;
|
||
const isSelected = modelId === site.modelId;
|
||
infoHtml += `<option value="${modelId}" ${isSelected ? 'selected' : ''}>${modelName}</option>`;
|
||
});
|
||
|
||
// 添加当前使用的模型(如果不在列表中)
|
||
if (site.modelId && !site.availableModels.some(m => m.id === site.modelId)) {
|
||
infoHtml += `<option value="${site.modelId}" selected>${site.modelId} (当前使用)</option>`;
|
||
}
|
||
|
||
infoHtml += `</select>
|
||
<button id="sourceSiteModelSearchBtn_${siteId}" class="px-3 py-1.5 border border-gray-300 rounded text-xs text-gray-600 hover:text-blue-600 hover:border-blue-400 transition-colors flex items-center whitespace-nowrap">
|
||
<iconify-icon icon="carbon:search" class="mr-1" width="14"></iconify-icon>
|
||
搜索模型
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
// 没有可用模型列表时,显示更明确的提示和手动输入选项
|
||
infoHtml += `<div class="mt-2 pt-2 border-t">
|
||
<div class="flex justify-between items-center">
|
||
<div class="font-medium mb-1">模型ID:</div>
|
||
<div class="text-xs text-gray-500">
|
||
<iconify-icon icon="carbon:information" class="mr-1" width="14"></iconify-icon>
|
||
<span>还未检测模型</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 w-full">
|
||
<input type="text" id="manualModelId_${siteId}" class="w-full sm:flex-1 px-3 py-1.5 border border-gray-300 rounded-l-md text-sm" value="${site.modelId || ''}" placeholder="例如: gpt-4-turbo">
|
||
<button id="saveManualModelBtn_${siteId}" class="px-2 py-1.5 bg-blue-500 hover:bg-blue-600 text-white rounded sm:rounded-r-md text-xs flex items-center justify-center w-full sm:w-auto">
|
||
<iconify-icon icon="carbon:save" class="mr-1" width="14"></iconify-icon>
|
||
保存
|
||
</button>
|
||
</div>
|
||
<div class="mt-2 text-xs flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||
<span class="text-blue-600 inline-flex items-center">
|
||
<iconify-icon icon="carbon:arrow-right" class="mr-1" width="14"></iconify-icon>
|
||
点击
|
||
<button id="infoDetectModelsBtn_${siteId}" class="mx-1 px-1.5 py-0.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs flex items-center">
|
||
<iconify-icon icon="carbon:model-alt" class="mr-1" width="12"></iconify-icon>
|
||
检测可用模型
|
||
</button>
|
||
</span>
|
||
|
||
<!-- 检查是否有可用的API Key -->
|
||
<span class="${customSourceKeysCount > 0 ? 'text-green-600' : 'text-red-600'} inline-flex items-center">
|
||
${customSourceKeysCount > 0 ?
|
||
`<iconify-icon icon="carbon:checkmark" class="mr-1" width="14"></iconify-icon>${customSourceKeysCount}个可用Key` :
|
||
`<iconify-icon icon="carbon:warning" class="mr-1" width="14"></iconify-icon>请先添加API Key`}
|
||
</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// 添加键检查信息和API Key管理按钮
|
||
infoHtml += `
|
||
<div class="mt-6 pt-3 border-t border-dashed ">
|
||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||
<div class="flex flex-col sm:flex-row sm:items-center gap-3 h-full">
|
||
<span class="font-medium">API Keys:</span>
|
||
<span class="text-sm ${customSourceKeysCount > 0 ? 'text-green-600' : 'text-red-600'} flex items-center">
|
||
${customSourceKeysCount > 0 ?
|
||
`<iconify-icon icon="carbon:checkmark-filled" class="mr-1" width="14"></iconify-icon>${customSourceKeysCount}个可用Key` :
|
||
`<iconify-icon icon="carbon:warning-filled" class="mr-1" width="14"></iconify-icon>无可用Key`}
|
||
</span>
|
||
</div>
|
||
<button id="infoManageKeyBtn_${siteId}" class="px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded text-xs flex items-center justify-center w-full sm:w-auto" style="min-height:2.4em;">
|
||
<iconify-icon icon="carbon:api" class="mr-1" width="14"></iconify-icon>
|
||
管理API Key
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
infoHtml += `</div>`;
|
||
infoContainer.innerHTML = infoHtml;
|
||
|
||
const cacheKey = `custom_source_${siteId}`;
|
||
const modelSelectEl = document.getElementById(`sourceSiteModelSelect_${siteId}`);
|
||
const searchBtnEl = document.getElementById(`sourceSiteModelSearchBtn_${siteId}`);
|
||
if (modelSelectEl) {
|
||
const normalizedModels = [];
|
||
const seenModelIds = new Set();
|
||
(site.availableModels || []).forEach(model => {
|
||
const modelId = model && (model.id || model.name);
|
||
if (!modelId || seenModelIds.has(modelId)) return;
|
||
seenModelIds.add(modelId);
|
||
normalizedModels.push({
|
||
value: modelId,
|
||
label: model.name || modelId,
|
||
description: model.rawName || model.description || ''
|
||
});
|
||
});
|
||
setModelSearchCache(cacheKey, normalizedModels);
|
||
|
||
if (searchBtnEl) {
|
||
registerModelSearchIntegration({
|
||
key: cacheKey,
|
||
selectEl: modelSelectEl,
|
||
buttonEl: searchBtnEl,
|
||
title: `选择模型(${site.displayName || '自定义源'})`,
|
||
placeholder: '搜索模型 ID...',
|
||
emptyMessage: '未找到匹配的模型',
|
||
onEmpty: () => {
|
||
const reDetectBtn = document.getElementById(`reDetectModelsBtn_${siteId}`) || document.getElementById(`infoDetectModelsBtn_${siteId}`);
|
||
if (reDetectBtn && !reDetectBtn.disabled) {
|
||
reDetectBtn.click();
|
||
}
|
||
return true;
|
||
},
|
||
onSelect: (value) => {
|
||
if (!modelSelectEl || !value) return;
|
||
if (modelSelectEl.value !== value) {
|
||
modelSelectEl.value = value;
|
||
modelSelectEl.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
} else if (searchBtnEl) {
|
||
searchBtnEl.disabled = true;
|
||
}
|
||
|
||
// 隐藏底部的管理按钮 - 因为我们有了内联的按钮
|
||
manageKeyBtn.classList.add('hidden');
|
||
|
||
// 为内联的管理API Key按钮添加点击事件
|
||
const infoManageKeyBtn = document.getElementById(`infoManageKeyBtn_${siteId}`);
|
||
if (infoManageKeyBtn) {
|
||
infoManageKeyBtn.onclick = function() {
|
||
// 打开模型Key管理弹窗
|
||
document.getElementById('modelKeyManagerBtn').click();
|
||
|
||
// 等待弹窗打开,然后设置正确的模型和源站点
|
||
setTimeout(() => {
|
||
if (typeof selectModelForManager === 'function') {
|
||
// 选择custom模型
|
||
selectModelForManager('custom');
|
||
|
||
// 再选择特定源站点
|
||
if (typeof selectSourceSite === 'function') {
|
||
selectSourceSite(siteId);
|
||
}
|
||
}
|
||
}, 100);
|
||
};
|
||
}
|
||
|
||
// 为内联的检测模型按钮添加点击事件
|
||
const infoDetectModelsBtn = document.getElementById(`infoDetectModelsBtn_${siteId}`);
|
||
if (infoDetectModelsBtn) {
|
||
infoDetectModelsBtn.onclick = function() {
|
||
// 直接调用外部检测按钮的点击事件
|
||
const mainDetectBtn = document.getElementById('detectModelsBtn');
|
||
if (mainDetectBtn) {
|
||
mainDetectBtn.click();
|
||
}
|
||
};
|
||
}
|
||
// 为重新检测按钮添加事件
|
||
const reDetectBtn = document.getElementById(`reDetectModelsBtn_${siteId}`);
|
||
if (reDetectBtn) {
|
||
reDetectBtn.onclick = function() {
|
||
const mainDetectBtn = document.getElementById('detectModelsBtn');
|
||
if (mainDetectBtn) {
|
||
mainDetectBtn.click();
|
||
}
|
||
};
|
||
}
|
||
|
||
// 添加新功能:绑定模型选择/保存事件
|
||
setTimeout(() => {
|
||
// 1. 如果有可用模型下拉框,绑定保存事件
|
||
const modelSelect = document.getElementById(`sourceSiteModelSelect_${siteId}`);
|
||
|
||
// 新增:如果 site.modelId 为空,自动选中第一个并保存
|
||
if (modelSelect && (!site.modelId || !site.availableModels.some(m => m.id === site.modelId))) {
|
||
if (modelSelect.options.length > 0) {
|
||
const firstModelId = modelSelect.options[0].value;
|
||
if (!site.modelId || site.modelId !== firstModelId) {
|
||
site.modelId = firstModelId;
|
||
if (typeof saveCustomSourceSite === 'function') {
|
||
saveCustomSourceSite(site);
|
||
}
|
||
modelSelect.value = firstModelId;
|
||
// 同步预览
|
||
const previewText = document.getElementById(`currentModelPreview_${siteId}`);
|
||
if (previewText) {
|
||
previewText.textContent = modelSelect.options[0].text || firstModelId;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (modelSelect) {
|
||
// 下拉框change事件 - 实现即时预览并保存
|
||
modelSelect.addEventListener('change', () => {
|
||
const selectedOption = modelSelect.options[modelSelect.selectedIndex];
|
||
const previewText = document.getElementById(`currentModelPreview_${siteId}`);
|
||
if (previewText) {
|
||
previewText.textContent = selectedOption.text || selectedOption.value;
|
||
previewText.classList.add('font-semibold', 'text-blue-600');
|
||
setTimeout(() => {
|
||
previewText.classList.remove('font-semibold', 'text-blue-600');
|
||
}, 1500);
|
||
}
|
||
// 新增:切换时立即保存
|
||
site.modelId = modelSelect.value;
|
||
// 新增:同步写入 lastSelectedCustomModel
|
||
localStorage.setItem('lastSelectedCustomModel', modelSelect.value);
|
||
if (typeof saveCustomSourceSite === 'function') {
|
||
saveCustomSourceSite(site);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 2. 如果有手动输入模型ID,绑定保存事件
|
||
const saveManualModelBtn = document.getElementById(`saveManualModelBtn_${siteId}`);
|
||
const manualModelInput = document.getElementById(`manualModelId_${siteId}`);
|
||
|
||
if (saveManualModelBtn && manualModelInput) {
|
||
saveManualModelBtn.addEventListener('click', () => {
|
||
if (manualModelInput && manualModelInput.value.trim()) {
|
||
saveSelectedModelForSite(siteId, manualModelInput.value.trim());
|
||
|
||
// 更新当前模型显示
|
||
const previewText = document.getElementById(`currentModelPreview_${siteId}`);
|
||
if (previewText) {
|
||
previewText.textContent = manualModelInput.value.trim();
|
||
previewText.classList.add('font-semibold', 'text-blue-600');
|
||
setTimeout(() => {
|
||
previewText.classList.remove('font-semibold', 'text-blue-600');
|
||
}, 1500);
|
||
}
|
||
} else {
|
||
showNotification('请输入有效的模型ID', 'warning');
|
||
}
|
||
});
|
||
|
||
// 添加Enter键保存功能
|
||
manualModelInput.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter' && manualModelInput.value.trim()) {
|
||
saveManualModelBtn.click();
|
||
}
|
||
});
|
||
}
|
||
}, 100);
|
||
} else {
|
||
infoContainer.classList.add('hidden');
|
||
manageKeyBtn.classList.add('hidden');
|
||
}
|
||
} catch (e) {
|
||
console.error("Error updating custom source site info:", e);
|
||
infoContainer.classList.add('hidden');
|
||
manageKeyBtn.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 保存选定的模型ID到源站点配置
|
||
* @param {string} siteId - 源站点ID
|
||
* @param {string} modelId - 要保存的模型ID
|
||
*/
|
||
function saveSelectedModelForSite(siteId, modelId) {
|
||
if (!siteId || !modelId) return;
|
||
|
||
try {
|
||
const sites = typeof loadAllCustomSourceSites === 'function' ? loadAllCustomSourceSites() : {};
|
||
const site = sites[siteId];
|
||
|
||
if (site) {
|
||
// 更新模型ID
|
||
site.modelId = modelId;
|
||
|
||
// 保存更新后的配置
|
||
if (typeof saveCustomSourceSite === 'function') {
|
||
saveCustomSourceSite(site);
|
||
showNotification(`已将模型 "${modelId}" 设为源站 "${site.displayName || siteId}" 的默认模型`, 'success');
|
||
|
||
// 刷新信息显示
|
||
updateCustomSourceSiteInfo(siteId);
|
||
} else {
|
||
showNotification('保存失败:saveCustomSourceSite 函数不可用', 'error');
|
||
}
|
||
} else {
|
||
showNotification(`保存失败:未找到ID为 "${siteId}" 的源站点配置`, 'error');
|
||
}
|
||
} catch (e) {
|
||
console.error('Error saving selected model for site:', e);
|
||
showNotification('保存模型ID时发生错误', 'error');
|
||
}
|
||
}
|
||
|
||
// 新增:自定义事件监听,用于外部调用源站点选择
|
||
window.addEventListener('selectCustomSourceSiteForKeyManager', function(e) {
|
||
if (e.detail && typeof e.detail === 'string') {
|
||
if (typeof selectModelForManager === 'function') {
|
||
selectModelForManager('custom');
|
||
if (typeof selectSourceSite === 'function') {
|
||
selectSourceSite(e.detail);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 新增:选择源站点完毕后首次加载信息的钩子
|
||
// 新增:把选择源站和显示信息函数暴露给全局
|
||
window.updateCustomSourceSiteInfo = updateCustomSourceSiteInfo;
|
||
|
||
// 新增:使管理函数可全局访问
|
||
window.selectModelForManager = selectModelForManager;
|
||
window.selectSourceSite = selectSourceSite;
|
||
|
||
// 新增:检测可用模型按钮事件
|
||
if (detectModelsBtn) {
|
||
detectModelsBtn.addEventListener('click', function() {
|
||
const selectedSiteId = customSourceSiteSelect.value;
|
||
if (!selectedSiteId) {
|
||
showNotification('请先选择一个源站点', 'warning');
|
||
return;
|
||
}
|
||
|
||
// 先检查该源站点是否已有API Key
|
||
const keysForSite = typeof loadModelKeys === 'function' ?
|
||
loadModelKeys(`custom_source_${selectedSiteId}`) : [];
|
||
|
||
const validKeys = keysForSite.filter(key => key.status === 'valid' || key.status === 'untested');
|
||
|
||
if (validKeys.length === 0) {
|
||
// 没有可用的Key,提示用户先添加Key
|
||
if (confirm(`源站点没有可用的API Key。是否立即添加Key?`)) {
|
||
// 打开模型管理器并直接跳到Key管理界面
|
||
document.getElementById('modelKeyManagerBtn').click();
|
||
|
||
setTimeout(() => {
|
||
if (typeof selectModelForManager === 'function') {
|
||
selectModelForManager('custom');
|
||
if (typeof selectSourceSite === 'function') {
|
||
selectSourceSite(selectedSiteId);
|
||
}
|
||
}
|
||
}, 100);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 有可用的Key,则直接开始检测模型
|
||
const sites = typeof loadAllCustomSourceSites === 'function' ? loadAllCustomSourceSites() : {};
|
||
const site = sites[selectedSiteId];
|
||
|
||
if (!site || !site.apiBaseUrl) {
|
||
showNotification('源站点配置不完整,缺少API Base URL', 'error');
|
||
return;
|
||
}
|
||
|
||
// 这里使用已有的Key进行检测,而不是要求用户重新输入
|
||
showNotification('开始使用现有API Key检测可用模型,请稍候...', 'info');
|
||
|
||
// 修改为直接使用现有Key检测
|
||
if (typeof window.modelDetector === 'object' && typeof window.modelDetector.detectModelsForSite === 'function') {
|
||
detectModelsWithExistingKeys(selectedSiteId, site, validKeys);
|
||
} else {
|
||
showNotification('模型检测器不可用,请刷新页面重试', 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 使用现有API Key检测源站点的可用模型
|
||
* @param {string} siteId - 源站点ID
|
||
* @param {object} site - 源站点配置对象
|
||
* @param {array} validKeys - 可用的API Key列表
|
||
*/
|
||
async function detectModelsWithExistingKeys(siteId, site, validKeys) {
|
||
let detectBtn = document.getElementById('detectModelsBtn');
|
||
let originalBtnText = detectBtn.innerHTML;
|
||
|
||
try {
|
||
// 修改按钮状态
|
||
detectBtn.disabled = true;
|
||
detectBtn.innerHTML = '<iconify-icon icon="carbon:circle-dash" class="animate-spin mr-1"></iconify-icon>检测中...';
|
||
|
||
// 尝试每个Key,直到成功检测到模型
|
||
let modelsDetected = [];
|
||
let successfulKey = null;
|
||
|
||
const requestFormat = site.requestFormat || 'openai';
|
||
const endpointMode = site.endpointMode || 'auto';
|
||
|
||
for (const key of validKeys) {
|
||
try {
|
||
showNotification(`正在尝试使用Key (${key.value.substring(0, 4)}...) 检测模型`, 'info');
|
||
modelsDetected = await window.modelDetector.detectModelsForSite(
|
||
site.apiBaseUrl,
|
||
key.value,
|
||
requestFormat,
|
||
endpointMode
|
||
);
|
||
|
||
if (modelsDetected && modelsDetected.length > 0) {
|
||
successfulKey = key;
|
||
break; // 成功检测到模型,跳出循环
|
||
}
|
||
} catch (keyError) {
|
||
console.warn(`Key (${key.value.substring(0, 4)}...) 检测模型失败:`, keyError);
|
||
// 继续尝试下一个Key
|
||
}
|
||
}
|
||
|
||
if (modelsDetected.length === 0) {
|
||
throw new Error('所有Key都无法成功检测到模型');
|
||
}
|
||
|
||
// 更新源站点的可用模型列表
|
||
site.availableModels = modelsDetected;
|
||
|
||
// 如果源站点还没有设置默认模型,则设置为第一个检测到的模型
|
||
if (!site.modelId && modelsDetected.length > 0) {
|
||
site.modelId = modelsDetected[0].id;
|
||
}
|
||
|
||
// 保存更新后的源站点配置
|
||
if (typeof saveCustomSourceSite === 'function') {
|
||
saveCustomSourceSite(site);
|
||
showNotification(`已检测到 ${modelsDetected.length} 个可用模型,并已保存到源站点配置`, 'success');
|
||
|
||
// 刷新源站点信息显示
|
||
updateCustomSourceSiteInfo(siteId);
|
||
} else {
|
||
throw new Error('保存配置失败:saveCustomSourceSite 函数不可用');
|
||
}
|
||
|
||
// 更新使用成功的Key状态
|
||
if (successfulKey && typeof window.refreshKeyManagerForModel === 'function') {
|
||
// 标记为有效状态
|
||
window.refreshKeyManagerForModel(`custom_source_${siteId}`, successfulKey.id, 'valid');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('检测模型失败:', error);
|
||
showNotification(`检测模型失败: ${error.message}`, 'error');
|
||
} finally {
|
||
// 恢复按钮状态
|
||
detectBtn.disabled = false;
|
||
detectBtn.innerHTML = originalBtnText;
|
||
}
|
||
}
|
||
});
|