933 lines
49 KiB
JavaScript
933 lines
49 KiB
JavaScript
/**
|
||
* KeyManagerUI 类负责渲染和管理单个模型的 API Key 池。
|
||
*
|
||
* 主要功能:
|
||
* - 渲染 API Key 的增删改查界面。
|
||
* - 支持批量导入/导出特定模型或所有模型的 Key 配置。
|
||
* - 支持 Key 的优先级调整(上移/下移)、状态标记(untested, testing, valid, invalid)、备注编辑。
|
||
* - 提供"全部测试"、"单个测试"的 UI 入口,通过回调与外部测试逻辑交互。
|
||
* - 支持自定义源站点模型的友好显示名称。
|
||
* - 从 localStorage 加载和保存 Key 数据,并能响应外部对上次成功使用 Key 的记录。
|
||
*
|
||
* 设计说明:
|
||
* - 该类主要关注 UI 的构建和用户交互,实际的 Key 存储、加载和测试逻辑通过构造函数中传递的回调函数与外部模块(如 app.js, storage.js, api.js)解耦。
|
||
* - 每个 KeyManagerUI 实例管理一个特定模型(`modelName`)的 Key 池。
|
||
* - UI 元素动态创建,支持响应式更新(例如,Key 状态变化后仅更新对应条目)。
|
||
*
|
||
* @param {string} modelName - 当前 KeyManagerUI 实例管理的模型名称 (例如 'mistral', 'deepseek', 'custom_source_abcdef123')。
|
||
* @param {HTMLElement} containerElement - Key 池 UI 将被渲染到的父级 DOM 容器元素。
|
||
* @param {function(string, Object): Promise<void>} onTestKey - 测试单个 Key 的异步回调函数。
|
||
* 接收 `modelName` (string) 和 `keyObject` (Object) 作为参数。
|
||
* @param {function(string, Array<Object>): Promise<void>} onTestAllKeys - 测试当前模型所有 Key 的异步回调函数。
|
||
* 接收 `modelName` (string) 和 `keysArray` (Array<Object>) 作为参数。
|
||
* @param {function(string): Array<Object>} loadKeysFunction - 加载指定模型 Key 列表的函数。
|
||
* 接收 `modelName` (string) 作为参数,应返回一个 Key 对象数组。
|
||
* @param {function(string, Array<Object>): void} saveKeysFunction - 保存指定模型 Key 列表的函数。
|
||
* 接收 `modelName` (string) 和 `keysArray` (Array<Object>) 作为参数。
|
||
*/
|
||
const LAST_SUCCESSFUL_KEYS_LS_KEY_FOR_UI = 'paperBurnerLastSuccessfulKeys'; // 与 app.js 中保持一致
|
||
|
||
class KeyManagerUI {
|
||
/**
|
||
* 构造函数:初始化 KeyManagerUI 实例。
|
||
*
|
||
* 主要步骤:
|
||
* 1. 保存传入的参数(模型名、容器元素、回调函数等)到实例属性。
|
||
* 2. 调用 `loadKeysFunction` 加载当前模型的 Key 数据,如果不存在则初始化为空数组。
|
||
* 3. 从 localStorage 读取当前模型上次成功使用的 Key ID (如果有记录的话),用于 UI 高亮显示。
|
||
* 4. 调用 `render` 方法,首次渲染 Key 管理界面。
|
||
*
|
||
* @param {string} modelName - 当前管理的模型名称 (e.g., 'mistral', 'custom')。
|
||
* @param {HTMLElement} containerElement - Key 池 UI 将被渲染到的 DOM 容器元素。
|
||
* @param {function(string, Object)} onTestKey - 测试单个 Key 的回调函数 (参数: modelName, keyObject)。
|
||
* @param {function(string, Array<Object>)} onTestAllKeys - 测试当前模型所有 Key 的回调 (参数: modelName, keysArray)。
|
||
* @param {function(string): Array<Object>} loadKeysFunction - 加载指定模型 Key 的函数。
|
||
* @param {function(string, Array<Object>)} saveKeysFunction - 保存指定模型 Key 的函数。
|
||
*/
|
||
constructor(modelName, containerElement, onTestKey, onTestAllKeys, loadKeysFunction, saveKeysFunction) {
|
||
this.modelName = modelName;
|
||
this.containerElement = containerElement;
|
||
this.onTestKey = onTestKey; // Callback to initiate testing for a single key
|
||
this.onTestAllKeys = onTestAllKeys; // Callback to initiate testing for all keys
|
||
this.loadKeys = loadKeysFunction;
|
||
this.saveKeys = saveKeysFunction;
|
||
|
||
this.keys = this.loadKeys(this.modelName) || [];
|
||
// 读取当前模型上次成功使用的 Key ID
|
||
try {
|
||
const records = JSON.parse(localStorage.getItem(LAST_SUCCESSFUL_KEYS_LS_KEY_FOR_UI) || '{}');
|
||
this.lastSuccessfulKeyId = records[this.modelName] || null;
|
||
} catch (e) {
|
||
console.error('KeyManagerUI: Failed to get last successful key ID for model ' + this.modelName, e);
|
||
this.lastSuccessfulKeyId = null;
|
||
}
|
||
this.render();
|
||
}
|
||
|
||
/**
|
||
* 重新加载指定模型的 Key 数据并完全重新渲染 Key 列表 UI。
|
||
* 当外部数据源(例如 localStorage 中的 Key 列表)发生变化,且需要 KeyManagerUI
|
||
* 实例更新其显示时,可以调用此方法。
|
||
*
|
||
* 主要步骤:
|
||
* 1. 调用 `this.loadKeys` (即构造时传入的 `loadKeysFunction`) 重新获取当前模型的 Key 列表。
|
||
* 2. 调用 `this.render()` 方法,用最新的 Key 数据彻底重建 UI。
|
||
*/
|
||
refreshKeys() {
|
||
this.keys = this.loadKeys(this.modelName) || [];
|
||
this.render();
|
||
}
|
||
|
||
/**
|
||
* 渲染整个 Key 池 UI 到指定的容器元素中。
|
||
* 此方法会先清空容器,然后逐步构建并添加以下区域:
|
||
* - 按钮操作区:包含"全部测试"、"导出配置"、"导入配置"以及"添加新 Key"的触发按钮。
|
||
* - 添加新 Key 输入区(初始隐藏):提供文本域批量输入新 Key 及备注。
|
||
* - Key 列表区:如果存在 Key,则遍历 `this.keys` 数组,为每个 Key 对象调用 `_createKeyItemElement` 生成对应的 UI 条目并添加到列表中;如果不存在 Key,则显示提示信息。
|
||
*
|
||
* 此方法是 UI 更新的核心,当 Key 列表发生较大变化(如增删、导入)或需要强制刷新时被调用。
|
||
*/
|
||
render() {
|
||
this.containerElement.innerHTML = ''; // 清空容器
|
||
|
||
// Create a header for buttons
|
||
const buttonHeader = document.createElement('div');
|
||
buttonHeader.className = 'flex items-center justify-between mb-3'; // Use flex to align items
|
||
|
||
const leftButtons = document.createElement('div');
|
||
leftButtons.className = 'flex items-center space-x-2';
|
||
|
||
// 2. "全部测试"按钮 (如果存在Key)
|
||
if (this.keys.length > 0) {
|
||
const testAllButton = document.createElement('button');
|
||
testAllButton.innerHTML = '<iconify-icon icon="carbon:chemistry" class="mr-1"></iconify-icon>全部测试';
|
||
testAllButton.className = 'px-2.5 py-1 text-xs rounded-md border border-slate-200 hover:border-blue-300 text-slate-600 transition-colors flex items-center';
|
||
testAllButton.addEventListener('click', () => {
|
||
if (this.onTestAllKeys) {
|
||
this.onTestAllKeys(this.modelName, this.keys);
|
||
}
|
||
});
|
||
leftButtons.appendChild(testAllButton);
|
||
}
|
||
|
||
// 导出配置按钮
|
||
const exportButton = document.createElement('button');
|
||
exportButton.innerHTML = '<iconify-icon icon="carbon:export" class="mr-1"></iconify-icon>导出配置';
|
||
exportButton.className = 'px-2.5 py-1 text-xs rounded-md border border-slate-200 hover:border-blue-300 text-slate-600 transition-colors flex items-center';
|
||
exportButton.addEventListener('click', () => {
|
||
this._exportKeys();
|
||
});
|
||
leftButtons.appendChild(exportButton);
|
||
|
||
// 导入配置按钮
|
||
const importButton = document.createElement('button');
|
||
importButton.innerHTML = '<iconify-icon icon="carbon:import" class="mr-1"></iconify-icon>导入配置';
|
||
importButton.className = 'px-2.5 py-1 text-xs rounded-md border border-slate-200 hover:border-blue-300 text-slate-600 transition-colors flex items-center';
|
||
importButton.addEventListener('click', () => {
|
||
this._importKeys();
|
||
});
|
||
leftButtons.appendChild(importButton);
|
||
|
||
buttonHeader.appendChild(leftButtons);
|
||
|
||
// "Add New Key" button (plus icon)
|
||
const addNewKeyButton = document.createElement('button');
|
||
addNewKeyButton.innerHTML = '<iconify-icon icon="carbon:add" width="14"></iconify-icon><span class="ml-1">添加新 Key</span>';
|
||
addNewKeyButton.title = '添加新的 API Key';
|
||
addNewKeyButton.className = 'px-2.5 py-1 text-xs rounded-md border border-slate-200 hover:border-green-400 text-green-600 transition-colors flex items-center';
|
||
buttonHeader.appendChild(addNewKeyButton); // Add to the right part of the header
|
||
|
||
this.containerElement.appendChild(buttonHeader);
|
||
|
||
const importExportHint = document.createElement('p');
|
||
importExportHint.className = 'text-xs text-slate-500 bg-slate-50 border border-slate-200 rounded px-3 py-2 mb-3 flex items-center gap-2';
|
||
importExportHint.innerHTML = '<iconify-icon icon="carbon:information" width="14"></iconify-icon><span>导入/导出的配置文件采用 Paper Burner X 固定格式 JSON,请勿修改字段结构。</span>';
|
||
this.containerElement.appendChild(importExportHint);
|
||
|
||
// 1. "添加新 Key"区域 (initially hidden)
|
||
const addKeySection = this._createAddKeySection();
|
||
addKeySection.style.display = 'none'; // Initially hidden
|
||
this.containerElement.appendChild(addKeySection);
|
||
|
||
addNewKeyButton.addEventListener('click', () => {
|
||
addKeySection.style.display = addKeySection.style.display === 'none' ? 'block' : 'none';
|
||
});
|
||
|
||
// 3. Key 列表区域
|
||
const keyListContainer = document.createElement('div');
|
||
keyListContainer.className = 'space-y-3';
|
||
|
||
if (this.keys.length === 0) {
|
||
const noKeysMessage = document.createElement('p');
|
||
noKeysMessage.textContent = `${this.modelName} 当前没有已保存的 API Key。`;
|
||
noKeysMessage.className = 'text-sm text-gray-500';
|
||
keyListContainer.appendChild(noKeysMessage);
|
||
} else {
|
||
this.keys.forEach((keyObj, index) => {
|
||
const keyItemElement = this._createKeyItemElement(keyObj, index);
|
||
keyListContainer.appendChild(keyItemElement);
|
||
});
|
||
}
|
||
this.containerElement.appendChild(keyListContainer);
|
||
}
|
||
|
||
/**
|
||
* 创建"添加新 Key"区域的 DOM 结构。
|
||
* 该区域允许用户输入一个或多个 API Key(通过文本域,每行一个 Key 被视为一个独立的 Key),
|
||
* 并为这些 Key 添加一个统一的备注。
|
||
*
|
||
* DOM 结构包括:
|
||
* - 区域标题 (根据 `this.modelName` 动态生成,对自定义源站点有特殊显示)。
|
||
* - 一个 `textarea` 用于输入 Key 值(支持批量)。
|
||
* - 一个 `input[type=text]` 用于输入备注。
|
||
* - 一个"添加 Key(s)"按钮,点击后会处理输入、调用 `_addKey` 方法,并清空输入框。
|
||
*
|
||
* @returns {HTMLElement} 包含添加新 Key 表单元素的 `div` 容器。
|
||
* @private
|
||
*/
|
||
_createAddKeySection() {
|
||
const section = document.createElement('div');
|
||
section.className = 'mb-4 p-3 border rounded-md bg-gray-50';
|
||
|
||
const title = document.createElement('h4');
|
||
let titleText = `为 ${this.modelName} 添加新的 API Key`;
|
||
if (this.modelName && this.modelName.startsWith('custom_source_')) {
|
||
try {
|
||
const sourceSiteId = this.modelName.replace('custom_source_', '');
|
||
if (typeof window.loadAllCustomSourceSites === 'function') {
|
||
const sites = window.loadAllCustomSourceSites();
|
||
const site = sites[sourceSiteId];
|
||
if (site && site.displayName) {
|
||
titleText = `为 "${site.displayName}" 添加新的 API Key`;
|
||
} else {
|
||
titleText = `为源站点 (ID: ...${sourceSiteId.slice(-8)}) 添加新的 API Key`;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("Error getting display name for custom source in KeyManagerUI:", e);
|
||
}
|
||
}
|
||
title.textContent = titleText;
|
||
title.className = 'text-md font-semibold mb-2 text-gray-700';
|
||
section.appendChild(title);
|
||
|
||
// 将 valueInput 从 input 改为 textarea 以支持批量输入
|
||
const valueTextarea = document.createElement('textarea');
|
||
valueTextarea.rows = 3;
|
||
valueTextarea.placeholder = 'API Key 值 (可每行输入一个实现批量添加)';
|
||
valueTextarea.className = 'w-full px-3 py-2 border border-gray-300 rounded-md mb-2 text-sm focus:ring-blue-500 focus:border-blue-500';
|
||
|
||
const remarkInput = document.createElement('input');
|
||
remarkInput.type = 'text';
|
||
remarkInput.placeholder = '备注 (可选, 应用于本批次所有Key)';
|
||
remarkInput.className = 'w-full px-3 py-2 border border-gray-300 rounded-md mb-2 text-sm focus:ring-blue-500 focus:border-blue-500';
|
||
|
||
const addButton = document.createElement('button');
|
||
addButton.innerHTML = '<iconify-icon icon="carbon:add" class="mr-1"></iconify-icon>添加 Key(s)';
|
||
addButton.className = 'px-4 py-2 bg-green-500 hover:bg-green-600 text-white rounded-md text-sm transition-colors flex items-center';
|
||
|
||
addButton.addEventListener('click', () => {
|
||
const rawValues = valueTextarea.value.trim();
|
||
const remark = remarkInput.value.trim();
|
||
|
||
if (rawValues) {
|
||
const individualKeyValues = rawValues.replace(/,/g, '\n').split('\n').map(k => k.trim()).filter(k => k !== '');
|
||
if (individualKeyValues.length > 0) {
|
||
individualKeyValues.forEach(keyValue => {
|
||
this._addKey(keyValue, remark);
|
||
});
|
||
valueTextarea.value = ''; // 清空文本域
|
||
remarkInput.value = ''; // 清空备注
|
||
|
||
// 获取模型显示名称用于通知
|
||
let modelDisplayNameForNotification = this.modelName;
|
||
if (this.modelName && this.modelName.startsWith('custom_source_')) {
|
||
try {
|
||
const sourceSiteId = this.modelName.replace('custom_source_', '');
|
||
if (typeof window.loadAllCustomSourceSites === 'function') {
|
||
const sites = window.loadAllCustomSourceSites();
|
||
const site = sites[sourceSiteId];
|
||
if (site && site.displayName) {
|
||
modelDisplayNameForNotification = `"${site.displayName}"`;
|
||
} else {
|
||
modelDisplayNameForNotification = `源站点 (ID: ...${sourceSiteId.slice(-8)})`;
|
||
}
|
||
}
|
||
} catch (e) { /* 保持 this.modelName 作为 fallback */ }
|
||
}
|
||
|
||
if (typeof showNotification === 'function') {
|
||
showNotification(`${individualKeyValues.length} 个 Key 已为 ${modelDisplayNameForNotification} 添加`, 'success', 3000);
|
||
}
|
||
} else {
|
||
alert('请输入至少一个有效的 API Key 值!');
|
||
}
|
||
} else {
|
||
alert('API Key 值不能为空!');
|
||
}
|
||
});
|
||
|
||
section.appendChild(valueTextarea); // 修改为 textarea
|
||
section.appendChild(remarkInput);
|
||
section.appendChild(addButton);
|
||
return section;
|
||
}
|
||
|
||
/**
|
||
* 为单个 Key 对象创建并返回其在列表中的 DOM 元素表示。
|
||
* 每个 Key 条目 UI 包含以下部分:
|
||
* - Key 值显示:默认部分隐藏,点击可切换完整显示/隐藏。旁边可能会有"上次成功使用"的星形图标。
|
||
* - 状态指示器:显示 Key 的当前状态 (untested, testing, valid, invalid),并根据状态应用不同样式。
|
||
* - 备注输入框:允许用户编辑和查看 Key 的备注。
|
||
* - 操作按钮区:
|
||
* - 上移/下移按钮:调整 Key 在列表中的顺序(优先级)。
|
||
* - 测试按钮:触发 `onTestKey` 回调以测试当前 Key。
|
||
* - 删除按钮:调用 `_deleteKey` 方法删除当前 Key。
|
||
*
|
||
* @param {Object} keyObj - 要渲染的 Key 对象。应包含 `id`, `value`, `remark`, `status`, `order` 属性。
|
||
* @param {number} index - Key 对象在 `this.keys` 数组中的当前索引,用于判断是否禁用上移/下移按钮。
|
||
* @returns {HTMLElement} 代表单个 Key 条目的 `div` 元素。
|
||
* @private
|
||
*/
|
||
_createKeyItemElement(keyObj, index) {
|
||
const item = document.createElement('div');
|
||
item.className = 'p-3 border rounded-md shadow-sm bg-white flex flex-col space-y-2';
|
||
item.dataset.keyId = keyObj.id;
|
||
|
||
// Key 值显示与操作
|
||
const valueContainer = document.createElement('div');
|
||
valueContainer.className = 'flex items-center justify-between';
|
||
|
||
const valueDisplayGroup = document.createElement('div'); // 新增: 用于组合 key value 和 last used 图标
|
||
valueDisplayGroup.className = 'flex items-center flex-grow mr-2';
|
||
|
||
const valueDisplay = document.createElement('span');
|
||
valueDisplay.className = 'text-sm font-mono cursor-pointer text-gray-700 hover:underline';
|
||
const maskedValue = keyObj.value.length > 8 ? `${keyObj.value.substring(0, 4)}...${keyObj.value.substring(keyObj.value.length - 4)}` : keyObj.value;
|
||
valueDisplay.textContent = maskedValue;
|
||
valueDisplay.title = '点击显示/隐藏完整 Key';
|
||
let isValueVisible = false;
|
||
valueDisplay.addEventListener('click', () => {
|
||
isValueVisible = !isValueVisible;
|
||
valueDisplay.textContent = isValueVisible ? keyObj.value : maskedValue;
|
||
});
|
||
valueDisplayGroup.appendChild(valueDisplay);
|
||
|
||
// 检查并添加"上次成功使用"图标
|
||
if (keyObj.id === this.lastSuccessfulKeyId) {
|
||
const lastUsedIcon = document.createElement('iconify-icon');
|
||
lastUsedIcon.setAttribute('icon', 'carbon:star-filled');
|
||
lastUsedIcon.className = 'text-yellow-500 ml-1.5 flex-shrink-0'; // 调整了ml-1到ml-1.5
|
||
lastUsedIcon.title = '此 Key 上次成功使用';
|
||
lastUsedIcon.setAttribute('width', '14'); // 稍微小一点
|
||
lastUsedIcon.setAttribute('height', '14');
|
||
valueDisplayGroup.appendChild(lastUsedIcon);
|
||
}
|
||
|
||
// 状态指示器
|
||
const statusIndicator = document.createElement('span');
|
||
statusIndicator.className = 'text-xs px-2 py-0.5 rounded-full ml-2 font-medium key-status-indicator flex-shrink-0'; // 添加 flex-shrink-0
|
||
this._updateKeyStatusIndicator(statusIndicator, keyObj.status);
|
||
|
||
valueContainer.appendChild(valueDisplayGroup); // 添加组合元素
|
||
valueContainer.appendChild(statusIndicator);
|
||
item.appendChild(valueContainer);
|
||
|
||
// 备注输入框
|
||
const remarkInput = document.createElement('input');
|
||
remarkInput.type = 'text';
|
||
remarkInput.value = keyObj.remark || '';
|
||
remarkInput.placeholder = '添加备注...';
|
||
remarkInput.className = 'w-full px-2 py-1 border border-gray-300 rounded text-xs focus:ring-blue-500 focus:border-blue-500';
|
||
remarkInput.addEventListener('change', (e) => {
|
||
this._updateRemark(keyObj.id, e.target.value);
|
||
});
|
||
item.appendChild(remarkInput);
|
||
|
||
|
||
// 操作按钮区域
|
||
const actionsContainer = document.createElement('div');
|
||
actionsContainer.className = 'flex items-center space-x-2 mt-1 pt-2 border-t border-gray-200';
|
||
|
||
const upButton = document.createElement('button');
|
||
upButton.innerHTML = '<iconify-icon icon="carbon:arrow-up" width="16"></iconify-icon>';
|
||
upButton.title = '上移 (提高优先级)';
|
||
upButton.className = 'p-1 text-gray-500 hover:text-blue-600 disabled:opacity-50';
|
||
upButton.disabled = index === 0;
|
||
upButton.addEventListener('click', () => this._moveKey(index, -1));
|
||
|
||
const downButton = document.createElement('button');
|
||
downButton.innerHTML = '<iconify-icon icon="carbon:arrow-down" width="16"></iconify-icon>';
|
||
downButton.title = '下移 (降低优先级)';
|
||
downButton.className = 'p-1 text-gray-500 hover:text-blue-600 disabled:opacity-50';
|
||
downButton.disabled = index === this.keys.length - 1;
|
||
downButton.addEventListener('click', () => this._moveKey(index, 1));
|
||
|
||
const testButton = document.createElement('button');
|
||
testButton.innerHTML = '<iconify-icon icon="carbon:play-outline" width="16"></iconify-icon>';
|
||
testButton.title = '测试此 Key';
|
||
testButton.className = 'p-1 text-gray-500 hover:text-green-600';
|
||
testButton.addEventListener('click', () => {
|
||
if (this.onTestKey) {
|
||
this.onTestKey(this.modelName, keyObj);
|
||
}
|
||
});
|
||
|
||
const deleteButton = document.createElement('button');
|
||
deleteButton.innerHTML = '<iconify-icon icon="carbon:trash-can" width="16"></iconify-icon>';
|
||
deleteButton.title = '删除此 Key';
|
||
deleteButton.className = 'p-1 text-gray-500 hover:text-red-600';
|
||
deleteButton.addEventListener('click', () => this._deleteKey(keyObj.id));
|
||
|
||
actionsContainer.appendChild(upButton);
|
||
actionsContainer.appendChild(downButton);
|
||
actionsContainer.appendChild(document.createTextNode(' ')); // 小间隔
|
||
actionsContainer.appendChild(testButton);
|
||
actionsContainer.appendChild(deleteButton);
|
||
item.appendChild(actionsContainer);
|
||
|
||
return item;
|
||
}
|
||
|
||
/**
|
||
* 更新 Key 状态的视觉指示器(DOM 元素)的样式和内容。
|
||
* 根据传入的 `status`,此方法会修改 `indicatorElement` 的文本内容和 CSS 类名,
|
||
* 以便直观地展示 Key 的当前状态。
|
||
*
|
||
* 支持的状态及其对应样式:
|
||
* - `valid`: 绿色背景,表示 Key 有效。
|
||
* - `invalid`: 红色背景,表示 Key 无效。
|
||
* - `testing`: 黄色背景,并显示一个旋转图标,表示 Key 正在测试中。
|
||
* - `untested` (或任何其他未知状态): 灰色背景,表示 Key 尚未测试。
|
||
*
|
||
* @param {HTMLElement} indicatorElement - 要更新的状态指示器 DOM 元素 (通常是一个 `<span>`)。
|
||
* @param {string} status - Key 的当前状态字符串 (e.g., 'valid', 'invalid', 'testing', 'untested')。
|
||
* @private
|
||
*/
|
||
_updateKeyStatusIndicator(indicatorElement, status) {
|
||
const labelMap = {
|
||
valid: '可用',
|
||
invalid: '不可用',
|
||
testing: '检测中',
|
||
untested: '未测试'
|
||
};
|
||
const normalized = (status || 'untested').toLowerCase();
|
||
const label = labelMap[normalized] || labelMap.untested;
|
||
indicatorElement.textContent = label;
|
||
switch (status) {
|
||
case 'valid':
|
||
indicatorElement.className = 'text-xs px-2 py-0.5 rounded-full font-medium bg-green-100 text-green-700';
|
||
break;
|
||
case 'invalid':
|
||
indicatorElement.className = 'text-xs px-2 py-0.5 rounded-full font-medium bg-red-100 text-red-700';
|
||
break;
|
||
case 'testing':
|
||
indicatorElement.className = 'text-xs px-2 py-0.5 rounded-full font-medium bg-yellow-100 text-yellow-700 flex items-center gap-1';
|
||
indicatorElement.innerHTML = '<iconify-icon icon="carbon:circle-dash" class="animate-spin" ></iconify-icon><span>检测中</span>';
|
||
break;
|
||
case 'untested':
|
||
default:
|
||
indicatorElement.className = 'text-xs px-2 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600';
|
||
indicatorElement.textContent = label;
|
||
break;
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* 向当前模型的 Key 池中添加一个新的 Key 对象。
|
||
* 新 Key 将具有一个唯一生成的 ID (`_generateUUID`),初始状态为 'untested',
|
||
* `order` 属性根据其在数组中的位置(末尾)设定。
|
||
* 添加后,会调用 `saveKeys` 保存更新后的 Key 列表,并调用 `render` 刷新整个 UI。
|
||
*
|
||
* @param {string} value - 新 Key 的 API Key 字符串值。
|
||
* @param {string} remark - 新 Key 的备注信息(可选)。
|
||
* @private
|
||
*/
|
||
_addKey(value, remark) {
|
||
const newKey = {
|
||
id: this._generateUUID(), // 需要一个 UUID 生成器
|
||
value: value,
|
||
remark: remark,
|
||
status: 'untested',
|
||
order: this.keys.length // 添加到末尾
|
||
};
|
||
this.keys.push(newKey);
|
||
this.saveKeys(this.modelName, this.keys);
|
||
this.render(); // 重新渲染以显示新 Key
|
||
}
|
||
|
||
/**
|
||
* 从当前模型的 Key 池中删除具有指定 ID 的 Key。
|
||
* 删除后,会重新计算剩余 Key 的 `order` 属性以保持连续性,
|
||
* 然后调用 `saveKeys` 保存更改,并调用 `render` 刷新 UI。
|
||
*
|
||
* @param {string} keyId - 要删除的 Key 对象的 `id` 属性。
|
||
* @private
|
||
*/
|
||
_deleteKey(keyId) {
|
||
this.keys = this.keys.filter(key => key.id !== keyId);
|
||
// 重新计算 order
|
||
this.keys.forEach((key, index) => key.order = index);
|
||
this.saveKeys(this.modelName, this.keys);
|
||
this.render();
|
||
}
|
||
|
||
/**
|
||
* 更新具有指定 ID 的 Key 对象的备注信息。
|
||
* 找到对应的 Key 对象后,修改其 `remark` 属性,并调用 `saveKeys` 保存更改。
|
||
* 此操作通常不会触发完整的 UI `render`,除非备注的显示非常复杂。
|
||
* (当前实现中,由于输入框直接绑定,可能不需要显式 UI 更新,但保存是必要的。)
|
||
*
|
||
* @param {string} keyId - 要更新备注的 Key 对象的 `id` 属性。
|
||
* @param {string} newRemark - 新的备注文本。
|
||
* @private
|
||
*/
|
||
_updateRemark(keyId, newRemark) {
|
||
const key = this.keys.find(k => k.id === keyId);
|
||
if (key) {
|
||
key.remark = newRemark;
|
||
this.saveKeys(this.modelName, this.keys);
|
||
// 不需要完全重新渲染,但如果备注显示区域复杂则可能需要
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 调整指定索引处的 Key 在列表中的顺序(即优先级)。
|
||
* 根据 `direction` 参数,将 Key 向上或向下移动一位。
|
||
* 实现方式是通过交换目标 Key 与相邻 Key 的 `order` 属性值,
|
||
* 然后对整个 `this.keys` 数组按 `order` 重新排序。
|
||
* 操作完成后,调用 `saveKeys` 保存更改,并调用 `render` 刷新 UI。
|
||
*
|
||
* @param {number} index - 要移动的 Key 在 `this.keys` 数组中的当前索引。
|
||
* @param {number} direction - 移动方向:-1 表示上移(提高优先级),1 表示下移(降低优先级)。
|
||
* @private
|
||
*/
|
||
_moveKey(index, direction) {
|
||
if (direction === -1 && index === 0) return; // 不能将第一个元素上移
|
||
if (direction === 1 && index === this.keys.length - 1) return; // 不能将最后一个元素下移
|
||
|
||
const targetIndex = index + direction;
|
||
const keyToMove = this.keys[index];
|
||
|
||
// 交换 order 值
|
||
const tempOrder = this.keys[targetIndex].order;
|
||
this.keys[targetIndex].order = keyToMove.order;
|
||
keyToMove.order = tempOrder;
|
||
|
||
// 根据 order 重新排序数组
|
||
this.keys.sort((a, b) => a.order - b.order);
|
||
|
||
this.saveKeys(this.modelName, this.keys);
|
||
this.render();
|
||
}
|
||
|
||
/**
|
||
* 更新具有指定 ID 的 Key 对象的状态,并相应地刷新其在 UI 中的状态指示器。
|
||
* 此方法旨在实现更细粒度的 UI 更新:当 Key 状态改变时,
|
||
* 它会尝试只更新该 Key 对应条目中的状态指示器部分,而不是重新渲染整个列表,以提高性能。
|
||
* 如果找不到对应的 DOM 元素,则会回退到完整的 `render` 调用。
|
||
* 状态更改后会调用 `saveKeys` 保存。
|
||
*
|
||
* @param {string} keyId - 要更新状态的 Key 对象的 `id` 属性。
|
||
* @param {string} newStatus - Key 的新状态 (e.g., 'untested', 'valid', 'invalid', 'testing')。
|
||
*/
|
||
updateKeyStatus(keyId, newStatus) {
|
||
const key = this.keys.find(k => k.id === keyId);
|
||
if (key) {
|
||
key.status = newStatus;
|
||
this.saveKeys(this.modelName, this.keys); // 保存状态变更
|
||
|
||
// 优化:只更新特定 key item 的状态显示,而不是整个列表
|
||
const keyItemElement = this.containerElement.querySelector(`div[data-key-id="${keyId}"]`);
|
||
if (keyItemElement) {
|
||
const statusIndicator = keyItemElement.querySelector('.key-status-indicator');
|
||
if (statusIndicator) {
|
||
this._updateKeyStatusIndicator(statusIndicator, newStatus);
|
||
}
|
||
} else {
|
||
this.render(); // 如果找不到元素,回退到完整渲染
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* 生成一个符合 RFC4122 version 4 的 UUID (Universally Unique Identifier)。
|
||
* 优先尝试使用全局作用域下可能已定义的 `generateUUID` 函数 (例如由 `storage.js` 提供)。
|
||
* 如果全局函数不可用,则使用一个内置的回退算法生成一个随机的 UUID 字符串。
|
||
*
|
||
* @returns {string} 生成的 UUID 字符串,格式如 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'。
|
||
* @private
|
||
*/
|
||
_generateUUID() {
|
||
// 尝试使用全局的 generateUUID (如果已在 storage.js 中定义并挂载到 window 或通过模块导入)
|
||
if (typeof generateUUID === 'function') {
|
||
return generateUUID();
|
||
}
|
||
// Fallback UUID generator
|
||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||
return v.toString(16);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 将当前模型 (`this.modelName`) 的所有 Key 配置导出为一个 JSON 文件。
|
||
* JSON 文件内容是 `this.keys` 数组的字符串表示。
|
||
* 文件名格式为 `<modelName>-keys.json`。
|
||
* 通过动态创建 `<a>` 标签并模拟点击来实现浏览器下载。
|
||
* 导出成功后会尝试显示一个通知(如果 `showNotification` 函数可用)。
|
||
*
|
||
* @private
|
||
*/
|
||
_exportKeys() {
|
||
const dataStr = JSON.stringify(this.keys, null, 2);
|
||
const blob = new Blob([dataStr], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${this.modelName}-keys.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
setTimeout(() => {
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}, 0);
|
||
if (typeof showNotification === 'function') {
|
||
showNotification('Key 配置已导出', 'success', 2000);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 导入 Key 配置(覆盖当前模型的 Key)。
|
||
* 支持格式校验与错误提示。
|
||
*
|
||
* 文件内容需为 Key 对象数组。
|
||
*/
|
||
_importKeys() {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = '.json,application/json';
|
||
input.addEventListener('change', (event) => {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
try {
|
||
const importedKeys = JSON.parse(e.target.result);
|
||
if (!Array.isArray(importedKeys)) throw new Error('格式错误');
|
||
// 简单校验每个 key
|
||
for (const k of importedKeys) {
|
||
if (typeof k.id !== 'string' || typeof k.value !== 'string') {
|
||
throw new Error('Key 数据缺失 id 或 value 字段');
|
||
}
|
||
}
|
||
this.keys = importedKeys;
|
||
this.saveKeys(this.modelName, this.keys);
|
||
this.render();
|
||
if (typeof showNotification === 'function') {
|
||
showNotification('Key 配置已导入并覆盖', 'success', 2000);
|
||
}
|
||
} catch (err) {
|
||
alert('导入失败:' + err.message);
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
});
|
||
input.click();
|
||
}
|
||
}
|
||
|
||
// 如果希望在全局访问(例如直接在 <script> 标签中使用),可以取消下一行的注释
|
||
window.KeyManagerUI = KeyManagerUI;
|
||
|
||
KeyManagerUI.exportAllModelKeys = function(loadKeysFunc) {
|
||
// 获取所有模型名
|
||
let allModelNames = [];
|
||
try {
|
||
const raw = localStorage.getItem('translationModelKeys');
|
||
if (raw) allModelNames = Object.keys(JSON.parse(raw));
|
||
} catch {}
|
||
// 组装导出对象
|
||
const allKeys = {};
|
||
allModelNames.forEach(model => {
|
||
allKeys[model] = (typeof loadKeysFunc === 'function' ? loadKeysFunc(model) : []);
|
||
});
|
||
// 导出为 JSON
|
||
const dataStr = JSON.stringify(allKeys, null, 2);
|
||
const blob = new Blob([dataStr], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'all-model-keys.json';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
setTimeout(() => {
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}, 0);
|
||
if (typeof showNotification === 'function') {
|
||
showNotification('所有模型 Key 配置已导出', 'success', 2000);
|
||
}
|
||
};
|
||
|
||
KeyManagerUI.importAllModelKeys = function(saveKeysFunc, refreshUIFunc) {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = '.json,application/json';
|
||
input.addEventListener('change', (event) => {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
try {
|
||
const imported = JSON.parse(e.target.result);
|
||
if (typeof imported !== 'object' || Array.isArray(imported)) throw new Error('格式错误');
|
||
for (const model in imported) {
|
||
if (!Array.isArray(imported[model])) throw new Error(`模型 ${model} 的 Key 列表格式错误`);
|
||
if (typeof saveKeysFunc === 'function') saveKeysFunc(model, imported[model]);
|
||
}
|
||
if (typeof refreshUIFunc === 'function') refreshUIFunc();
|
||
if (typeof showNotification === 'function') {
|
||
showNotification('所有模型 Key 配置已导入并覆盖', 'success', 2000);
|
||
}
|
||
} catch (err) {
|
||
alert('导入失败:' + err.message);
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
});
|
||
input.click();
|
||
};
|
||
|
||
KeyManagerUI.exportAllModelData = function() {
|
||
// 仅导出新版规范字段,保持"干净"
|
||
const translationModelKeys = JSON.parse(localStorage.getItem('translationModelKeys') || '{}');
|
||
const translationModelConfigs = JSON.parse(localStorage.getItem('translationModelConfigs') || '{}');
|
||
const paperBurnerCustomSourceSites = JSON.parse(localStorage.getItem('paperBurnerCustomSourceSites') || '{}');
|
||
const embeddingConfig = JSON.parse(localStorage.getItem('embeddingConfig') || 'null');
|
||
const rerankConfig = JSON.parse(localStorage.getItem('rerankConfig') || 'null');
|
||
|
||
// 添加 OCR 配置导出
|
||
const ocrConfig = {
|
||
engine: localStorage.getItem('ocrEngine') || 'mistral',
|
||
mistralKeys: localStorage.getItem('ocrMistralKeys') || '',
|
||
workerAuthKey: localStorage.getItem('ocrWorkerAuthKey') || '',
|
||
mineruToken: localStorage.getItem('ocrMinerUToken') || '',
|
||
mineruWorkerUrl: localStorage.getItem('ocrMinerUWorkerUrl') || '',
|
||
mineruTokenMode: localStorage.getItem('ocrMinerUTokenMode') || 'frontend',
|
||
mineruEnableOcr: localStorage.getItem('ocrMinerUEnableOcr') || 'true',
|
||
mineruEnableFormula: localStorage.getItem('ocrMinerUEnableFormula') || 'true',
|
||
mineruEnableTable: localStorage.getItem('ocrMinerUEnableTable') || 'true',
|
||
doc2xToken: localStorage.getItem('ocrDoc2XToken') || '',
|
||
doc2xWorkerUrl: localStorage.getItem('ocrDoc2XWorkerUrl') || '',
|
||
doc2xTokenMode: localStorage.getItem('ocrDoc2XTokenMode') || 'frontend'
|
||
};
|
||
|
||
// 添加学术搜索与代理配置导出
|
||
const academicSearchConfig = JSON.parse(localStorage.getItem('academicSearchProxyConfig') || 'null');
|
||
const academicSearchSourcesConfig = JSON.parse(localStorage.getItem('academicSearchSourcesConfig') || 'null');
|
||
|
||
const data = {
|
||
translationModelKeys,
|
||
translationModelConfigs,
|
||
paperBurnerCustomSourceSites,
|
||
embeddingConfig,
|
||
rerankConfig,
|
||
ocrConfig, // OCR 配置
|
||
academicSearchConfig, // 学术搜索与代理配置
|
||
academicSearchSourcesConfig // 学术搜索源配置
|
||
};
|
||
const dataStr = JSON.stringify(data, null, 2);
|
||
const blob = new Blob([dataStr], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'all-model-data.json';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
setTimeout(() => {
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}, 0);
|
||
if (typeof showNotification === 'function') {
|
||
showNotification('所有模型配置和Key已导出', 'success', 2000);
|
||
}
|
||
};
|
||
|
||
KeyManagerUI.importAllModelData = function(refreshUIFunc) {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = '.json,application/json';
|
||
input.addEventListener('change', (event) => {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
try {
|
||
const imported = JSON.parse(e.target.result);
|
||
|
||
// 1) 容错提取不同历史字段名
|
||
let importedModelKeys = imported.modelKeys
|
||
|| imported.translationModelKeys
|
||
|| imported.keys
|
||
|| imported.keyStore
|
||
|| {};
|
||
let importedModelConfigs = imported.modelConfigs
|
||
|| imported.translationModelConfigs
|
||
|| imported.configs
|
||
|| imported.modelConfig
|
||
|| {};
|
||
let importedCustomSourceSites = imported.customSourceSites
|
||
|| imported.paperBurnerCustomSourceSites
|
||
|| imported.customSites
|
||
|| imported.sourceSites
|
||
|| {};
|
||
let importedEmbeddingConfig = imported.embeddingConfig || null;
|
||
let importedRerankConfig = imported.rerankConfig || imported.rerank || null;
|
||
|
||
// 2) 归一化 modelKeys(数组项可为字符串或对象)
|
||
const genUUID = (function(){
|
||
if (typeof generateUUID === 'function') return generateUUID;
|
||
return function(){
|
||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||
return v.toString(16);
|
||
});
|
||
}
|
||
})();
|
||
|
||
const normalizedModelKeys = {};
|
||
if (typeof importedModelKeys === 'object' && importedModelKeys) {
|
||
Object.keys(importedModelKeys).forEach(modelName => {
|
||
const arr = Array.isArray(importedModelKeys[modelName]) ? importedModelKeys[modelName] : [];
|
||
const normArr = arr.map((item, idx) => {
|
||
if (typeof item === 'string') {
|
||
return { id: genUUID(), value: item, remark: '', status: 'untested', order: idx };
|
||
} else if (item && typeof item === 'object') {
|
||
const value = (typeof item.value === 'string' && item.value) ? item.value
|
||
: (typeof item.key === 'string' ? item.key : '');
|
||
return {
|
||
id: typeof item.id === 'string' && item.id ? item.id : genUUID(),
|
||
value,
|
||
remark: typeof item.remark === 'string' ? item.remark : (item.note || ''),
|
||
status: typeof item.status === 'string' ? item.status : 'untested',
|
||
order: typeof item.order === 'number' ? item.order : idx
|
||
};
|
||
} else {
|
||
return null;
|
||
}
|
||
}).filter(Boolean);
|
||
normalizedModelKeys[modelName] = normArr;
|
||
});
|
||
}
|
||
|
||
// 3) 归一化 modelConfigs(确保为对象)
|
||
const normalizedModelConfigs = (typeof importedModelConfigs === 'object' && importedModelConfigs) ? importedModelConfigs : {};
|
||
|
||
// 4) 归一化 customSourceSites:支持对象或数组
|
||
let normalizedCustomSourceSites = {};
|
||
if (Array.isArray(importedCustomSourceSites)) {
|
||
importedCustomSourceSites.forEach((site, idx) => {
|
||
if (!site || typeof site !== 'object') return;
|
||
const id = (typeof site.id === 'string' && site.id) ? site.id : genUUID();
|
||
normalizedCustomSourceSites[id] = {
|
||
id,
|
||
displayName: site.displayName || site.name || `自定义源站 ${idx+1}`,
|
||
apiBaseUrl: site.apiBaseUrl || site.baseUrl || site.apiBase || '',
|
||
modelId: site.modelId || site.defaultModel || '',
|
||
availableModels: Array.isArray(site.availableModels) ? site.availableModels : [],
|
||
requestFormat: site.requestFormat || 'openai',
|
||
temperature: (typeof site.temperature === 'number') ? site.temperature : 0.5,
|
||
max_tokens: (typeof site.max_tokens === 'number') ? site.max_tokens : 8000
|
||
};
|
||
});
|
||
} else if (typeof importedCustomSourceSites === 'object' && importedCustomSourceSites) {
|
||
// 已是对象结构,尽量保留
|
||
normalizedCustomSourceSites = importedCustomSourceSites;
|
||
} else {
|
||
normalizedCustomSourceSites = {};
|
||
}
|
||
|
||
// 5) 可选导入 lastSuccessfulKeys(若存在)
|
||
const lastSuccessful = imported.lastSuccessfulKeys
|
||
|| imported.paperBurnerLastSuccessfulKeys
|
||
|| imported.lastSuccessful
|
||
|| null;
|
||
|
||
// 6) 写入 localStorage(允许任意子集存在,不再强制三者齐备)
|
||
localStorage.setItem('translationModelKeys', JSON.stringify(normalizedModelKeys));
|
||
localStorage.setItem('translationModelConfigs', JSON.stringify(normalizedModelConfigs));
|
||
localStorage.setItem('paperBurnerCustomSourceSites', JSON.stringify(normalizedCustomSourceSites));
|
||
if (lastSuccessful && typeof lastSuccessful === 'object') {
|
||
localStorage.setItem('paperBurnerLastSuccessfulKeys', JSON.stringify(lastSuccessful));
|
||
}
|
||
// 导入向量搜索配置(如果存在)
|
||
if (importedEmbeddingConfig && typeof importedEmbeddingConfig === 'object') {
|
||
localStorage.setItem('embeddingConfig', JSON.stringify(importedEmbeddingConfig));
|
||
// 更新 EmbeddingClient 配置
|
||
if (window.EmbeddingClient && typeof window.EmbeddingClient.saveConfig === 'function') {
|
||
window.EmbeddingClient.saveConfig(importedEmbeddingConfig);
|
||
}
|
||
}
|
||
|
||
// 导入重排配置(如果存在)
|
||
if (importedRerankConfig && typeof importedRerankConfig === 'object') {
|
||
localStorage.setItem('rerankConfig', JSON.stringify(importedRerankConfig));
|
||
if (window.RerankClient && typeof window.RerankClient.saveConfig === 'function') {
|
||
window.RerankClient.saveConfig(importedRerankConfig);
|
||
}
|
||
}
|
||
|
||
// 导入 OCR 配置(如果存在)
|
||
const importedOcrConfig = imported.ocrConfig || {};
|
||
if (importedOcrConfig && typeof importedOcrConfig === 'object') {
|
||
if (importedOcrConfig.engine) localStorage.setItem('ocrEngine', importedOcrConfig.engine);
|
||
if (importedOcrConfig.mistralKeys !== undefined) localStorage.setItem('ocrMistralKeys', importedOcrConfig.mistralKeys);
|
||
if (importedOcrConfig.workerAuthKey !== undefined) localStorage.setItem('ocrWorkerAuthKey', importedOcrConfig.workerAuthKey);
|
||
if (importedOcrConfig.mineruToken !== undefined) localStorage.setItem('ocrMinerUToken', importedOcrConfig.mineruToken);
|
||
if (importedOcrConfig.mineruWorkerUrl !== undefined) localStorage.setItem('ocrMinerUWorkerUrl', importedOcrConfig.mineruWorkerUrl);
|
||
if (importedOcrConfig.mineruTokenMode !== undefined) localStorage.setItem('ocrMinerUTokenMode', importedOcrConfig.mineruTokenMode);
|
||
if (importedOcrConfig.mineruEnableOcr !== undefined) localStorage.setItem('ocrMinerUEnableOcr', importedOcrConfig.mineruEnableOcr);
|
||
if (importedOcrConfig.mineruEnableFormula !== undefined) localStorage.setItem('ocrMinerUEnableFormula', importedOcrConfig.mineruEnableFormula);
|
||
if (importedOcrConfig.mineruEnableTable !== undefined) localStorage.setItem('ocrMinerUEnableTable', importedOcrConfig.mineruEnableTable);
|
||
if (importedOcrConfig.doc2xToken !== undefined) localStorage.setItem('ocrDoc2XToken', importedOcrConfig.doc2xToken);
|
||
if (importedOcrConfig.doc2xWorkerUrl !== undefined) localStorage.setItem('ocrDoc2XWorkerUrl', importedOcrConfig.doc2xWorkerUrl);
|
||
if (importedOcrConfig.doc2xTokenMode !== undefined) localStorage.setItem('ocrDoc2XTokenMode', importedOcrConfig.doc2xTokenMode);
|
||
|
||
// 更新 OCR 设置管理器
|
||
if (window.ocrSettingsManager && typeof window.ocrSettingsManager.loadSettings === 'function') {
|
||
window.ocrSettingsManager.loadSettings();
|
||
}
|
||
}
|
||
|
||
// 导入学术搜索配置(如果存在)
|
||
const importedAcademicSearchConfig = imported.academicSearchConfig || null;
|
||
if (importedAcademicSearchConfig && typeof importedAcademicSearchConfig === 'object') {
|
||
localStorage.setItem('academicSearchProxyConfig', JSON.stringify(importedAcademicSearchConfig));
|
||
}
|
||
|
||
const importedAcademicSearchSourcesConfig = imported.academicSearchSourcesConfig || null;
|
||
if (importedAcademicSearchSourcesConfig && typeof importedAcademicSearchSourcesConfig === 'object') {
|
||
localStorage.setItem('academicSearchSourcesConfig', JSON.stringify(importedAcademicSearchSourcesConfig));
|
||
}
|
||
|
||
// 更新学术搜索设置管理器(如果存在)
|
||
if (window.academicSearchSettingsManager && typeof window.academicSearchSettingsManager.loadSettings === 'function') {
|
||
window.academicSearchSettingsManager.loadSettings();
|
||
}
|
||
|
||
if (typeof refreshUIFunc === 'function') refreshUIFunc();
|
||
if (typeof showNotification === 'function') {
|
||
showNotification('所有模型配置和Key已导入并覆盖', 'success', 2000);
|
||
}
|
||
} catch (err) {
|
||
alert('导入失败:' + err.message);
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
});
|
||
input.click();
|
||
};
|