2377 lines
109 KiB
JavaScript
2377 lines
109 KiB
JavaScript
// app.js - 主入口点和事件协调器
|
||
|
||
// =====================
|
||
// 全局状态变量与并发控制
|
||
// =====================
|
||
/**
|
||
* @file app.js
|
||
* @description
|
||
* 该文件是应用程序的主入口点和事件协调器。它负责管理用户界面交互、
|
||
* 文件处理流程(包括 OCR 和翻译)、API 密钥管理、设置加载与保存、
|
||
* 以及整体应用程序状态。
|
||
*
|
||
* 主要功能包括:
|
||
* - **初始化**: DOMContentLoaded后加载设置、处理记录,并绑定事件监听器。
|
||
* - **UI交互**: 管理文件列表、处理按钮状态、进度显示、翻译设置等UI元素的更新。
|
||
* - **文件处理**:
|
||
* - 协调PDF、MD、TXT文件的读取、分块、OCR(使用Mistral API)。
|
||
* - 调用翻译模块对提取的文本进行翻译(支持多种预设及自定义模型)。
|
||
* - **API密钥管理**:
|
||
* - 通过 `KeyProvider` 类管理不同模型(Mistral、翻译模型)的API密钥。
|
||
* - 支持密钥的轮询使用、状态标记(有效/无效)、以及从localStorage加载和保存。
|
||
* - **并发控制**:
|
||
* - 管理文件处理的并发数量。
|
||
* - 通过信号量 (`translationSemaphore`) 控制翻译任务的并发。
|
||
* - **错误处理与重试**: 实现文件处理失败时的重试机制。
|
||
* - **结果处理**: 收集处理结果,并提供下载功能。
|
||
* - **设置管理**: 加载和保存用户设置(如分块大小、并发数、所选模型等)。
|
||
* - **提示词管理**: 根据用户选择的目标语言或自定义设置,生成或加载相应的翻译提示词。
|
||
*/
|
||
|
||
// =====================
|
||
// XSS 防护工具函数
|
||
// =====================
|
||
/**
|
||
* 转义 HTML 特殊字符,防止 XSS 攻击
|
||
* @param {string} str - 需要转义的字符串
|
||
* @returns {string} 转义后的安全字符串
|
||
*/
|
||
function escapeHtml(str) {
|
||
if (typeof str !== 'string') return '';
|
||
return str.replace(/[&<>"']/g, function (c) {
|
||
return {'&':'&','<':'<','>':'>','"':'"','\'':'''}[c];
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 将 ArrayBuffer 转换为 Base64 字符串
|
||
* @param {ArrayBuffer} buffer - 需要转换的 ArrayBuffer
|
||
* @returns {string} Base64 编码的字符串
|
||
*/
|
||
function arrayBufferToBase64(buffer) {
|
||
if (!buffer) return null;
|
||
const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : new Uint8Array(buffer.buffer || []);
|
||
if (!bytes.length) return null;
|
||
let binary = '';
|
||
const chunkSize = 0x8000;
|
||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||
const chunk = bytes.subarray(i, i + chunkSize);
|
||
binary += String.fromCharCode.apply(null, chunk);
|
||
}
|
||
return btoa(binary);
|
||
}
|
||
|
||
// =====================
|
||
// 全局状态变量
|
||
// =====================
|
||
/**
|
||
* @type {File[]}
|
||
* @description 存储用户选择的待处理文件列表。
|
||
*/
|
||
let pdfFiles = [];
|
||
/**
|
||
* @type {Array<Object>}
|
||
* @description 存储所有文件处理后的结果对象。每个对象通常包含文件名、OCR文本、翻译文本等。
|
||
*/
|
||
let allResults = [];
|
||
/**
|
||
* @type {Object}
|
||
* @description 从 localStorage 加载的已处理文件记录,用于跳过已处理的文件。键为文件标识符,值为 true。
|
||
*/
|
||
let processedFilesRecord = {};
|
||
/**
|
||
* @type {boolean}
|
||
* @description 标记当前是否正在进行文件处理流程。
|
||
*/
|
||
let isProcessing = false;
|
||
/**
|
||
* @type {number}
|
||
* @description 当前活动的(正在处理中的)文件数量。
|
||
*/
|
||
let activeProcessingCount = 0;
|
||
/**
|
||
* @type {Map<string, number>}
|
||
* @description 记录每个文件(以其标识符为键)当前的重试次数。
|
||
*/
|
||
let retryAttempts = new Map();
|
||
/**
|
||
* @const {number} MAX_RETRIES
|
||
* @description 单个文件处理失败时的最大重试次数。
|
||
*/
|
||
const MAX_RETRIES = 3;
|
||
/**
|
||
* @const {string} LAST_SUCCESSFUL_KEYS_LS_KEY
|
||
* @description 用于在 localStorage 中存储各模型最后一次成功使用的 API Key ID 的键名。
|
||
*/
|
||
const LAST_SUCCESSFUL_KEYS_LS_KEY = 'paperBurnerLastSuccessfulKeys';
|
||
|
||
/**
|
||
* @typedef {Object} Semaphore
|
||
* @property {number} limit - 信号量允许的最大并发数。
|
||
* @property {number} count - 当前已占用的并发数。
|
||
* @property {Array<Function>} queue - 等待获取信号量的任务队列。
|
||
*/
|
||
|
||
/**
|
||
* @type {Semaphore}
|
||
* @description 用于控制翻译任务并发的信号量。
|
||
*/
|
||
let translationSemaphore = {
|
||
limit: 2, // 默认翻译并发数,可由设置覆盖
|
||
count: 0,
|
||
queue: []
|
||
};
|
||
|
||
/**
|
||
* @const {string}
|
||
* @description 批量导出的默认命名模板。
|
||
*/
|
||
const DEFAULT_BATCH_TEMPLATE = '{original_name}_{output_language}_{processing_time:YYYYMMDD-HHmmss}.{original_type}';
|
||
|
||
const SUPPORTED_FILE_EXTENSIONS = ['pdf', 'md', 'txt', 'docx', 'pptx', 'html', 'htm', 'epub', 'yaml', 'yml', 'json', 'csv', 'ini', 'cfg', 'log', 'tex'];
|
||
const SUPPORTED_ARCHIVE_EXTENSIONS = ['zip'];
|
||
|
||
/**
|
||
* @type {boolean}
|
||
* @description 用户是否开启批量模式的偏好设置。
|
||
*/
|
||
let batchModeEnabled = false;
|
||
/**
|
||
* @type {string}
|
||
* @description 批量导出使用的命名模板。
|
||
*/
|
||
let batchModeTemplate = DEFAULT_BATCH_TEMPLATE;
|
||
/**
|
||
* @type {string[]}
|
||
* @description 批量导出需要生成的格式集合。
|
||
*/
|
||
let batchModeFormats = ['original', 'markdown'];
|
||
/**
|
||
* @type {boolean}
|
||
* @description 批量导出时是否强制打包为 ZIP。
|
||
*/
|
||
let batchModeZipEnabled = false;
|
||
|
||
/**
|
||
* @type {boolean}
|
||
* @description 批量配置面板是否折叠。
|
||
*/
|
||
let batchConfigCollapsed = true;
|
||
|
||
/**
|
||
* @type {Set<string>}
|
||
* @description 被排除的文件扩展名集合。
|
||
*/
|
||
const excludedExtensions = new Set();
|
||
/**
|
||
* @type {{id:string,total:number,template:string,formats:string[],outputLanguage:string,startedAt:string,counter:number}|null}
|
||
* @description 当前批量处理的上下文信息,在一次处理流程内存在。
|
||
*/
|
||
let activeBatchSession = null;
|
||
|
||
/**
|
||
* @class KeyProvider
|
||
* @description 负责加载、筛选、排序和轮询特定模型的API Keys。
|
||
* 它从 localStorage 读取保存的密钥,管理其状态(如'valid', 'untested', 'invalid'),
|
||
* 并在处理过程中提供下一个可用的密钥。
|
||
*/
|
||
class KeyProvider {
|
||
/**
|
||
* KeyProvider 构造函数。
|
||
* @param {string} modelName - 需要管理 API Keys 的模型名称 (例如 'mistral', 'gemini', 或自定义源站点 'custom_source_xxx')。
|
||
*/
|
||
constructor(modelName) {
|
||
/** @type {string} */
|
||
this.modelName = modelName;
|
||
/**
|
||
* @type {Array<Object>}
|
||
* @description 存储从localStorage加载的原始key对象数组。
|
||
* 每个对象结构: {id: string, value: string, remark: string, status: string, order: number}
|
||
*/
|
||
this.keys = [];
|
||
/**
|
||
* @type {Array<Object>}
|
||
* @description 存储经过筛选和排序的、当前轮次可用的key对象数组 (status为 'valid' 或 'untested')。
|
||
*/
|
||
this.availableKeys = [];
|
||
/**
|
||
* @type {number}
|
||
* @description 当前轮询可用密钥列表的索引。
|
||
*/
|
||
this.currentIndex = 0;
|
||
this.loadAndPrepareKeys();
|
||
}
|
||
|
||
/**
|
||
* 加载并准备指定模型的API Keys。
|
||
* 它会调用 `loadModelKeys` 从 localStorage 获取密钥,
|
||
* 然后筛选出状态为 'valid' 或 'untested' 的密钥,并按 `order` 排序。
|
||
*/
|
||
loadAndPrepareKeys() {
|
||
this.keys = typeof loadModelKeys === 'function' ? loadModelKeys(this.modelName) : [];
|
||
// 筛选出 'valid' 或 'untested' 的 keys,并按 order 排序 (loadModelKeys 内部已排序)
|
||
this.availableKeys = this.keys.filter(key => key.status === 'valid' || key.status === 'untested');
|
||
this.currentIndex = 0;
|
||
if (this.availableKeys.length === 0) {
|
||
console.warn(`KeyProvider: No 'valid' or 'untested' keys found for model ${this.modelName}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取下一个可用的API Key对象。
|
||
* 实现轮询机制,循环使用 `availableKeys` 列表中的密钥。
|
||
* @returns {Object|null} 返回一个密钥对象 {id, value, status, remark, order},如果没有可用密钥则返回 null。
|
||
*/
|
||
getNextKey() {
|
||
if (this.availableKeys.length === 0) {
|
||
return null; // 没有可用的key
|
||
}
|
||
const keyObject = this.availableKeys[this.currentIndex];
|
||
this.currentIndex = (this.currentIndex + 1) % this.availableKeys.length;
|
||
return keyObject; // 返回整个key对象,包含 {id, value, status, remark, order}
|
||
}
|
||
|
||
/**
|
||
* 将指定的API Key标记为无效。
|
||
* 这会更新该密钥在 `this.keys` 中的状态,并将其从 `this.availableKeys` 中移除。
|
||
* 同时,会尝试异步保存更新后的密钥列表到 localStorage,并刷新Key管理界面的UI(如果存在)。
|
||
* @param {string} keyId - 要标记为无效的密钥的ID。
|
||
* @async
|
||
*/
|
||
async markKeyAsInvalid(keyId) {
|
||
const keyIndexInAll = this.keys.findIndex(k => k.id === keyId);
|
||
if (keyIndexInAll !== -1) {
|
||
this.keys[keyIndexInAll].status = 'invalid';
|
||
if (typeof saveModelKeys === 'function') {
|
||
await saveModelKeys(this.modelName, this.keys); // 异步保存
|
||
}
|
||
}
|
||
// 从当前可用列表中移除,并重置索引以确保正确轮询剩余的key
|
||
this.availableKeys = this.availableKeys.filter(k => k.id !== keyId);
|
||
this.currentIndex = this.availableKeys.length > 0 ? this.currentIndex % this.availableKeys.length : 0;
|
||
|
||
// 如果Key管理弹窗正好显示这个模型, 更新其UI
|
||
if (typeof window.refreshKeyManagerForModel === 'function') {
|
||
window.refreshKeyManagerForModel(this.modelName, keyId, 'invalid');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查是否有可用的 API Keys。
|
||
* @returns {boolean} 如果 `availableKeys` 列表不为空,则返回 true,否则返回 false。
|
||
*/
|
||
hasAvailableKeys() {
|
||
return this.availableKeys.length > 0;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取一个翻译并发槽(基于信号量实现)。
|
||
* 如果当前并发数未达到上限,则立即获取槽位。
|
||
* 否则,将请求加入等待队列,直到有槽位释放。
|
||
* @returns {Promise<void>} 当成功获取槽位时 resolve 的 Promise。
|
||
* @async
|
||
*/
|
||
async function acquireTranslationSlot() {
|
||
if (translationSemaphore.count < translationSemaphore.limit) {
|
||
translationSemaphore.count++;
|
||
return Promise.resolve();
|
||
} else {
|
||
return new Promise(resolve => {
|
||
translationSemaphore.queue.push(resolve);
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 释放一个翻译并发槽。
|
||
* 减少当前并发数,并检查等待队列中是否有任务,如果有,则唤醒队列中的下一个任务。
|
||
*/
|
||
function releaseTranslationSlot() {
|
||
translationSemaphore.count--;
|
||
if (translationSemaphore.queue.length > 0) {
|
||
const nextResolve = translationSemaphore.queue.shift();
|
||
acquireTranslationSlot().then(nextResolve);
|
||
}
|
||
}
|
||
|
||
// =====================
|
||
// DOMContentLoaded 入口初始化
|
||
// =====================
|
||
/**
|
||
* 当DOM完全加载并解析后执行的初始化函数。
|
||
* 主要任务包括:
|
||
* 1. 加载用户设置和已处理文件记录。
|
||
* 2. 将加载的设置应用到UI元素上。
|
||
* 3. 初始化UI组件状态(如文件列表、处理按钮等)。
|
||
* 4. 绑定所有必要的事件监听器。
|
||
* 5. 初始化自定义模型检测UI(如果相关功能已定义)。
|
||
*/
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// 1. 加载设置和已处理文件记录
|
||
const settings = loadSettings();
|
||
processedFilesRecord = loadProcessedFilesRecord();
|
||
|
||
// 2. 应用设置到 UI
|
||
applySettingsToUI(settings);
|
||
|
||
// 3. 加载 API Keys(如有记住) - 此功能已通过KeyProvider实现,且UI元素已移除
|
||
// loadApiKeysFromStorage(); // 删除此行
|
||
|
||
// 4. 初始化 UI 状态
|
||
updateFileListUI(pdfFiles, isProcessing, handleRemoveFile);
|
||
updateProcessButtonState(pdfFiles, isProcessing);
|
||
updateTranslationUIVisibility(isProcessing);
|
||
refreshFormatFilters();
|
||
refreshFormatFilters();
|
||
|
||
// 暴露刷新验证状态的全局函数
|
||
window.refreshValidationState = function() {
|
||
console.log('[Validation] Refreshing validation state');
|
||
updateProcessButtonState(pdfFiles, isProcessing);
|
||
};
|
||
|
||
// 5. 绑定所有事件
|
||
setupEventListeners();
|
||
|
||
// 初始化自定义模型检测UI
|
||
if (typeof initModelDetectorUI === 'function') {
|
||
initModelDetectorUI();
|
||
}
|
||
});
|
||
|
||
// =====================
|
||
// UI 设置应用
|
||
// =====================
|
||
/**
|
||
* 将加载的设置对象应用到各个UI元素上。
|
||
* 例如,设置滑块的值、复选框的选中状态、下拉菜单的选定项等。
|
||
* @param {Object} settings - 从 `loadSettings` 加载的设置对象。
|
||
*/
|
||
function applySettingsToUI(settings) {
|
||
// 解构所有设置项
|
||
const {
|
||
maxTokensPerChunk: maxTokensVal,
|
||
skipProcessedFiles,
|
||
selectedTranslationModel: modelVal,
|
||
selectedCustomSourceSiteId, // 新增:加载选定的自定义源站点ID
|
||
concurrencyLevel: concurrencyVal,
|
||
translationConcurrencyLevel: translationConcurrencyVal,
|
||
targetLanguage: targetLangVal,
|
||
customTargetLanguageName: customLangNameVal,
|
||
defaultSystemPrompt: defaultSysPromptVal,
|
||
defaultUserPromptTemplate: defaultUserPromptVal,
|
||
useCustomPrompts: useCustomPromptsVal,
|
||
enableGlossary: enableGlossaryVal,
|
||
batchModeEnabled: batchEnabledVal = false,
|
||
batchModeTemplate: batchTemplateVal = DEFAULT_BATCH_TEMPLATE,
|
||
batchModeFormats: batchFormatsVal = ['original', 'markdown'],
|
||
batchModeZipEnabled: batchZipVal = false
|
||
} = settings;
|
||
|
||
batchModeEnabled = !!batchEnabledVal;
|
||
batchModeTemplate = typeof batchTemplateVal === 'string' && batchTemplateVal.trim()
|
||
? batchTemplateVal
|
||
: DEFAULT_BATCH_TEMPLATE;
|
||
batchModeFormats = Array.isArray(batchFormatsVal) && batchFormatsVal.length > 0
|
||
? Array.from(new Set(['original', ...batchFormatsVal]))
|
||
: ['original', 'markdown'];
|
||
batchModeZipEnabled = !!batchZipVal;
|
||
batchConfigCollapsed = true;
|
||
|
||
// 应用到各 DOM 元素
|
||
const maxTokensSlider = document.getElementById('maxTokensPerChunk');
|
||
if (maxTokensSlider) {
|
||
maxTokensSlider.value = maxTokensVal;
|
||
document.getElementById('maxTokensPerChunkValue').textContent = maxTokensVal;
|
||
}
|
||
document.getElementById('skipProcessedFiles').checked = skipProcessedFiles;
|
||
const translationModelSelect = document.getElementById('translationModel');
|
||
if (translationModelSelect) {
|
||
const normalizedModel = (modelVal === 'gemini-preview') ? 'gemini' : modelVal;
|
||
translationModelSelect.value = normalizedModel;
|
||
}
|
||
|
||
// ----- 新增:处理自定义源站点下拉列表的逻辑 -----
|
||
const customSourceSiteDropdown = document.getElementById('customSourceSiteSelect');
|
||
if (modelVal === 'custom' && customSourceSiteDropdown) {
|
||
customSourceSiteDropdown.classList.remove('hidden');
|
||
if (typeof window.populateCustomSourceSitesDropdown_ui === 'function') {
|
||
// 假设 ui.js 中有这个函数来填充下拉列表
|
||
window.populateCustomSourceSitesDropdown_ui(selectedCustomSourceSiteId);
|
||
} else {
|
||
console.warn('populateCustomSourceSitesDropdown_ui function not found on window.');
|
||
// 可选:如果函数不存在,至少清空并禁用它
|
||
customSourceSiteDropdown.innerHTML = '<option value="">未找到源站点加载函数</option>';
|
||
customSourceSiteDropdown.disabled = true;
|
||
}
|
||
} else if (customSourceSiteDropdown) {
|
||
customSourceSiteDropdown.classList.add('hidden');
|
||
customSourceSiteDropdown.innerHTML = ''; // 清空选项
|
||
customSourceSiteDropdown.disabled = true;
|
||
}
|
||
// ----- 结束新增 -----
|
||
|
||
const concurrencyInput = document.getElementById('concurrencyLevel');
|
||
if (concurrencyInput) concurrencyInput.value = concurrencyVal;
|
||
const translationConcurrencyInput = document.getElementById('translationConcurrencyLevel');
|
||
if (translationConcurrencyInput) translationConcurrencyInput.value = translationConcurrencyVal;
|
||
const targetLanguageSelect = document.getElementById('targetLanguage');
|
||
if (targetLanguageSelect) targetLanguageSelect.value = targetLangVal || 'chinese';
|
||
const customTargetLanguageInput = document.getElementById('customTargetLanguageInput');
|
||
if (customTargetLanguageInput) customTargetLanguageInput.value = customLangNameVal || '';
|
||
|
||
const batchTemplateInput = document.getElementById('batchModeTemplate');
|
||
if (batchTemplateInput) {
|
||
batchTemplateInput.value = batchModeTemplate;
|
||
}
|
||
const batchFormatCheckboxes = document.querySelectorAll('[data-batch-format]');
|
||
const batchZipCheckbox = document.querySelector('[data-batch-zip]');
|
||
if (batchFormatCheckboxes.length > 0) {
|
||
let matched = false;
|
||
batchFormatCheckboxes.forEach(cb => {
|
||
const fmt = cb.getAttribute('data-batch-format');
|
||
const isChecked = batchModeFormats.includes(fmt);
|
||
cb.checked = isChecked;
|
||
if (isChecked) matched = true;
|
||
});
|
||
const originalCheckbox = document.querySelector('[data-batch-format="original"]');
|
||
if (originalCheckbox && !originalCheckbox.checked) {
|
||
originalCheckbox.checked = true;
|
||
if (!batchModeFormats.includes('original')) batchModeFormats.unshift('original');
|
||
}
|
||
if (!matched) {
|
||
batchModeFormats = ['original', 'markdown'];
|
||
batchFormatCheckboxes.forEach(cb => {
|
||
if (['original', 'markdown'].includes(cb.getAttribute('data-batch-format'))) {
|
||
cb.checked = true;
|
||
} else {
|
||
cb.checked = false;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
if (batchZipCheckbox) {
|
||
batchZipCheckbox.checked = batchModeZipEnabled;
|
||
}
|
||
|
||
if (typeof updateCustomLanguageInputVisibility === 'function') {
|
||
updateCustomLanguageInputVisibility();
|
||
}
|
||
|
||
// 单个自定义提示词:填充默认或用户上次修改
|
||
const defaultSystemPromptTextarea = document.getElementById('defaultSystemPrompt');
|
||
const defaultUserPromptTemplateTextarea = document.getElementById('defaultUserPromptTemplate');
|
||
const sysDefault = '你是专业的文档翻译助手。请将用户提供的内容精准翻译为指定语言,严格保留 Markdown 结构与标记,不添加任何说明性文字。';
|
||
const userDefault = '请将以下内容翻译为${targetLangName}:\n\n${content}';
|
||
if (defaultSystemPromptTextarea) {
|
||
defaultSystemPromptTextarea.value = (defaultSysPromptVal && defaultSysPromptVal.trim()) ? defaultSysPromptVal : sysDefault;
|
||
}
|
||
if (defaultUserPromptTemplateTextarea) {
|
||
defaultUserPromptTemplateTextarea.value = (defaultUserPromptVal && defaultUserPromptVal.trim()) ? defaultUserPromptVal : userDefault;
|
||
}
|
||
|
||
// 设置提示词模式
|
||
const promptMode = settings.promptMode || 'builtin';
|
||
const promptModeRadio = document.querySelector(`input[name="promptMode"][value="${promptMode}"]`);
|
||
if (promptModeRadio) promptModeRadio.checked = true;
|
||
|
||
// 备择库开关
|
||
const enableGlossaryToggle = document.getElementById('enableGlossaryToggle');
|
||
if (enableGlossaryToggle) enableGlossaryToggle.checked = !!enableGlossaryVal;
|
||
|
||
// 自定义模型设置 (旧版逻辑,现在主要由源站点管理)
|
||
// 这里不再直接从 settings.customModelSettings 读取并填充旧的自定义模型输入框
|
||
// 因为这些设置现在应该通过 key-manager-ui.js 中的源站点表单进行管理。
|
||
// 如果需要,可以在选择特定源站点时,由 ui.js 更新这些显示(如果这些输入框还保留用于显示目的)。
|
||
|
||
// 触发 UI 相关联动
|
||
updateTranslationUIVisibility(isProcessing);
|
||
updateCustomLanguageInputVisibility();
|
||
|
||
if (typeof syncBatchModeControls === 'function') {
|
||
syncBatchModeControls(pdfFiles.length);
|
||
}
|
||
updateBatchConfigCollapse();
|
||
}
|
||
|
||
// =====================
|
||
// API Key 加载 (旧版UI的,现在已不需要)
|
||
// =====================
|
||
/* // 函数整体注释掉或删除
|
||
function loadApiKeysFromStorage() {
|
||
const mistralKeysText = localStorage.getItem('mistralApiKeys');
|
||
const translationKeysText = localStorage.getItem('translationApiKeys');
|
||
|
||
const mistralTextArea = document.getElementById('mistralApiKeys'); // 这些元素已不存在
|
||
const translationTextArea = document.getElementById('translationApiKeys'); // 这些元素已不存在
|
||
|
||
if (mistralKeysText && mistralTextArea) {
|
||
mistralTextArea.value = mistralKeysText;
|
||
}
|
||
if (translationKeysText && translationTextArea) {
|
||
translationTextArea.value = translationKeysText;
|
||
}
|
||
}
|
||
*/
|
||
|
||
// =====================
|
||
// 事件监听器绑定
|
||
// =====================
|
||
/**
|
||
* 绑定应用程序中所有主要的DOM事件监听器。
|
||
* 包括API Key输入、模型选择、高级设置切换、文件上传、处理按钮点击等。
|
||
*/
|
||
function setupEventListeners() {
|
||
// (需要从 ui.js 获取 DOM 元素引用)
|
||
// const mistralTextArea = document.getElementById('mistralApiKeys'); // 已移除
|
||
// const translationTextArea = document.getElementById('translationApiKeys'); // 已移除
|
||
const translationModelSelect = document.getElementById('translationModel');
|
||
const advancedSettingsToggle = document.getElementById('advancedSettingsToggle');
|
||
const maxTokensSlider = document.getElementById('maxTokensPerChunk');
|
||
const skipFilesCheckbox = document.getElementById('skipProcessedFiles');
|
||
const concurrencyInput = document.getElementById('concurrencyLevel');
|
||
const translationConcurrencyInput = document.getElementById('translationConcurrencyLevel'); // Get ref to new input
|
||
const dropZone = document.getElementById('dropZone');
|
||
const fileInput = document.getElementById('pdfFileInput');
|
||
const folderInput = document.getElementById('folderInput');
|
||
const browseBtn = document.getElementById('browseFilesBtn');
|
||
const browseFolderBtn = document.getElementById('browseFolderBtn');
|
||
const urlImportBtn = document.getElementById('urlImportBtn');
|
||
const githubImportBtn = document.getElementById('githubImportBtn');
|
||
const clearBtn = document.getElementById('clearFilesBtn');
|
||
const processBtn = document.getElementById('processBtn');
|
||
const onlyReadBtn = document.getElementById('onlyReadBtn');
|
||
const downloadBtn = document.getElementById('downloadAllBtn');
|
||
const formatFilterContainer = document.getElementById('fileFormatFilters');
|
||
const batchToggle = document.getElementById('batchModeToggle');
|
||
const batchTemplateInput = document.getElementById('batchModeTemplate');
|
||
const batchFormatInputs = document.querySelectorAll('[data-batch-format]');
|
||
const batchZipCheckbox = document.querySelector('[data-batch-zip]');
|
||
const batchConfigToggle = document.getElementById('batchModeConfigToggle');
|
||
const targetLanguageSelect = document.getElementById('targetLanguage');
|
||
const customTargetLanguageInput = document.getElementById('customTargetLanguageInput');
|
||
const defaultSystemPromptTextarea = document.getElementById('defaultSystemPrompt');
|
||
const defaultUserPromptTemplateTextarea = document.getElementById('defaultUserPromptTemplate');
|
||
const customModelInputs = [
|
||
document.getElementById('customApiEndpoint'),
|
||
document.getElementById('customModelId'),
|
||
document.getElementById('customRequestFormat'),
|
||
document.getElementById('customTemperature'),
|
||
document.getElementById('customMaxTokens')
|
||
];
|
||
const customSourceSiteDropdown = document.getElementById('customSourceSiteSelect');
|
||
const customSourceSiteToggleBtn = document.getElementById('customSourceSiteToggle');
|
||
const customSourceSiteDiv = document.getElementById('customSourceSite');
|
||
const customSourceSiteToggleIconEl = document.getElementById('customSourceSiteToggleIcon');
|
||
const enableGlossaryToggle = document.getElementById('enableGlossaryToggle');
|
||
|
||
// API Key 存储 - 相关逻辑已移除,因为输入框已移除
|
||
/* // mistralTextArea 的监听器已无意义
|
||
mistralTextArea.addEventListener('input', () => {
|
||
localStorage.setItem('mistralApiKeys', mistralTextArea.value); // 直接保存
|
||
updateProcessButtonState(pdfFiles, isProcessing);
|
||
});
|
||
*/
|
||
/* // translationTextArea 的监听器已无意义
|
||
translationTextArea.addEventListener('input', () => {
|
||
localStorage.setItem('translationApiKeys', translationTextArea.value); // 直接保存
|
||
updateTranslationUIVisibility(isProcessing);
|
||
});
|
||
*/
|
||
|
||
// 翻译模型和自定义设置
|
||
translationModelSelect.addEventListener('change', () => {
|
||
updateTranslationUIVisibility(isProcessing);
|
||
if (typeof window.updateDeeplxTargetLangHint === 'function') {
|
||
window.updateDeeplxTargetLangHint();
|
||
}
|
||
saveCurrentSettings(); // 保存包括模型选择在内的所有设置
|
||
});
|
||
|
||
// 新增: Event-Listener für das customSourceSiteSelect Dropdown-Menü
|
||
if (customSourceSiteDropdown) {
|
||
customSourceSiteDropdown.addEventListener('change', () => {
|
||
saveCurrentSettings(); // Speichere die aktuellen Einstellungen, wenn die Auswahl der benutzerdefinierten Quelle geändert wird
|
||
// Optional: Log or update UI based on the new selection if needed immediately
|
||
const settings = loadSettings();
|
||
console.log("Custom source site selection changed and saved:", settings.selectedCustomSourceSiteId);
|
||
});
|
||
}
|
||
|
||
// 新增:为"自定义源站点设置"的切换按钮添加事件监听器
|
||
if (customSourceSiteToggleBtn && customSourceSiteDiv && customSourceSiteToggleIconEl) {
|
||
customSourceSiteToggleBtn.addEventListener('click', () => {
|
||
customSourceSiteDiv.classList.toggle('hidden');
|
||
if (customSourceSiteDiv.classList.contains('hidden')) {
|
||
customSourceSiteToggleIconEl.setAttribute('icon', 'carbon:chevron-down');
|
||
} else {
|
||
customSourceSiteToggleIconEl.setAttribute('icon', 'carbon:chevron-up');
|
||
}
|
||
});
|
||
}
|
||
|
||
customModelInputs.forEach(input => {
|
||
if (!input) return;
|
||
input.addEventListener('change', saveCurrentSettings);
|
||
input.addEventListener('input', saveCurrentSettings); // 实时保存
|
||
});
|
||
|
||
if (enableGlossaryToggle) {
|
||
enableGlossaryToggle.addEventListener('change', saveCurrentSettings);
|
||
}
|
||
|
||
if (batchToggle) {
|
||
batchToggle.addEventListener('change', () => {
|
||
batchModeEnabled = batchToggle.checked;
|
||
syncBatchModeControls(pdfFiles.length);
|
||
saveCurrentSettings();
|
||
});
|
||
}
|
||
if (batchTemplateInput) {
|
||
const syncTemplateValue = () => {
|
||
const raw = batchTemplateInput.value;
|
||
batchModeTemplate = raw && raw.trim() ? raw.trim() : DEFAULT_BATCH_TEMPLATE;
|
||
};
|
||
batchTemplateInput.addEventListener('input', () => {
|
||
syncTemplateValue();
|
||
saveCurrentSettings();
|
||
});
|
||
batchTemplateInput.addEventListener('blur', () => {
|
||
if (!batchTemplateInput.value.trim()) {
|
||
batchTemplateInput.value = DEFAULT_BATCH_TEMPLATE;
|
||
batchModeTemplate = DEFAULT_BATCH_TEMPLATE;
|
||
saveCurrentSettings();
|
||
}
|
||
});
|
||
}
|
||
if (batchFormatInputs && batchFormatInputs.length > 0) {
|
||
batchFormatInputs.forEach(input => {
|
||
input.addEventListener('change', () => {
|
||
const selected = Array.from(document.querySelectorAll('[data-batch-format]:checked'))
|
||
.map(el => el.getAttribute('data-batch-format'))
|
||
.filter(Boolean);
|
||
if (!selected.includes('original')) {
|
||
const originalCheckbox = document.querySelector('[data-batch-format="original"]');
|
||
if (originalCheckbox) {
|
||
originalCheckbox.checked = true;
|
||
}
|
||
selected.unshift('original');
|
||
if (typeof showNotification === 'function') {
|
||
showNotification('已自动保留“原格式”导出项。', 'info');
|
||
}
|
||
}
|
||
if (selected.length === 0) {
|
||
const fallback = document.querySelector('[data-batch-format="original"]');
|
||
if (fallback) fallback.checked = true;
|
||
selected.push('original');
|
||
}
|
||
batchModeFormats = Array.from(new Set(selected));
|
||
saveCurrentSettings();
|
||
});
|
||
});
|
||
}
|
||
if (batchZipCheckbox) {
|
||
batchZipCheckbox.addEventListener('change', () => {
|
||
batchModeZipEnabled = batchZipCheckbox.checked;
|
||
saveCurrentSettings();
|
||
});
|
||
}
|
||
|
||
if (formatFilterContainer) {
|
||
formatFilterContainer.addEventListener('change', handleFormatFilterChange);
|
||
formatFilterContainer.addEventListener('click', handleFormatFilterClick);
|
||
}
|
||
|
||
if (batchConfigToggle) {
|
||
batchConfigToggle.addEventListener('click', () => {
|
||
batchConfigCollapsed = !batchConfigCollapsed;
|
||
updateBatchConfigCollapse();
|
||
});
|
||
}
|
||
|
||
// 高级设置
|
||
advancedSettingsToggle.addEventListener('click', () => {
|
||
const settingsDiv = document.getElementById('advancedSettings');
|
||
const icon = document.getElementById('advancedSettingsIcon');
|
||
settingsDiv.classList.toggle('hidden');
|
||
icon.setAttribute('icon', settingsDiv.classList.contains('hidden') ? 'carbon:chevron-down' : 'carbon:chevron-up');
|
||
// 不需要单独保存,由内部控件处理
|
||
});
|
||
maxTokensSlider.addEventListener('input', () => {
|
||
document.getElementById('maxTokensPerChunkValue').textContent = maxTokensSlider.value;
|
||
saveCurrentSettings();
|
||
});
|
||
skipFilesCheckbox.addEventListener('change', saveCurrentSettings);
|
||
concurrencyInput.addEventListener('input', () => {
|
||
// 输入验证
|
||
let value = parseInt(concurrencyInput.value);
|
||
if (isNaN(value) || value < 1) value = 1;
|
||
if (value > 50) value = 50; // Allow higher concurrency for file processing
|
||
concurrencyInput.value = value;
|
||
saveCurrentSettings();
|
||
});
|
||
translationConcurrencyInput.addEventListener('input', () => { // Add listener for new input
|
||
// 输入验证
|
||
let value = parseInt(translationConcurrencyInput.value);
|
||
if (isNaN(value) || value < 1) value = 1;
|
||
if (value > 150) value = 150; // Increase limit for translation concurrency
|
||
translationConcurrencyInput.value = value;
|
||
saveCurrentSettings();
|
||
});
|
||
|
||
// 文件上传
|
||
dropZone.addEventListener('dragover', handleDragOver);
|
||
dropZone.addEventListener('dragleave', handleDragLeave);
|
||
dropZone.addEventListener('drop', handleDrop);
|
||
browseBtn.addEventListener('click', () => { if (!isProcessing) fileInput.click(); });
|
||
fileInput.addEventListener('change', handleFileSelect);
|
||
if (browseFolderBtn && folderInput) {
|
||
browseFolderBtn.addEventListener('click', () => { if (!isProcessing) folderInput.click(); });
|
||
}
|
||
if (folderInput) {
|
||
folderInput.addEventListener('change', handleFolderSelect);
|
||
}
|
||
if (urlImportBtn) {
|
||
urlImportBtn.addEventListener('click', async () => {
|
||
if (isProcessing) return;
|
||
await handleUrlImport();
|
||
});
|
||
}
|
||
if (githubImportBtn) {
|
||
githubImportBtn.addEventListener('click', async () => {
|
||
if (isProcessing) return;
|
||
await handleGithubImport();
|
||
});
|
||
}
|
||
clearBtn.addEventListener('click', handleClearFiles);
|
||
|
||
// 目标语言选择
|
||
targetLanguageSelect.addEventListener('change', () => {
|
||
updateCustomLanguageInputVisibility(); // Update visibility based on selection
|
||
if (typeof window.updateDeeplxTargetLangHint === 'function') {
|
||
window.updateDeeplxTargetLangHint();
|
||
}
|
||
saveCurrentSettings(); // Save the new selection
|
||
});
|
||
customTargetLanguageInput.addEventListener('input', () => {
|
||
saveCurrentSettings();
|
||
if (typeof window.updateDeeplxTargetLangHint === 'function') {
|
||
window.updateDeeplxTargetLangHint();
|
||
}
|
||
}); // Save custom language name changes
|
||
|
||
// 默认提示编辑
|
||
if (defaultSystemPromptTextarea) {
|
||
defaultSystemPromptTextarea.addEventListener('input', saveCurrentSettings);
|
||
}
|
||
if (defaultUserPromptTemplateTextarea) {
|
||
defaultUserPromptTemplateTextarea.addEventListener('input', saveCurrentSettings);
|
||
}
|
||
|
||
// 处理和下载
|
||
processBtn.addEventListener('click', handleProcessClick);
|
||
onlyReadBtn.addEventListener('click', handleReadClick);
|
||
downloadBtn.addEventListener('click', handleDownloadClick);
|
||
|
||
if (typeof window.updateDeeplxTargetLangHint === 'function') {
|
||
window.updateDeeplxTargetLangHint();
|
||
}
|
||
}
|
||
|
||
// =====================
|
||
// 事件处理函数
|
||
// =====================
|
||
|
||
/**
|
||
* 处理文件拖拽到上传区域时的 `dragover` 事件。
|
||
* @param {DragEvent} e - 拖拽事件对象。
|
||
*/
|
||
function handleDragOver(e) {
|
||
e.preventDefault();
|
||
if (!isProcessing) {
|
||
e.currentTarget.classList.add('border-blue-500', 'bg-blue-50');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理文件拖拽离开上传区域时的 `dragleave` 事件。
|
||
* @param {DragEvent} e - 拖拽事件对象。
|
||
*/
|
||
function handleDragLeave(e) {
|
||
e.currentTarget.classList.remove('border-blue-500', 'bg-blue-50');
|
||
}
|
||
|
||
/**
|
||
* 处理文件拖放到上传区域时的 `drop` 事件。
|
||
* @param {DragEvent} e - 拖拽事件对象。
|
||
*/
|
||
async function handleDrop(e) {
|
||
e.preventDefault();
|
||
if (isProcessing) return;
|
||
e.currentTarget.classList.remove('border-blue-500', 'bg-blue-50');
|
||
const files = await extractFilesFromDataTransfer(e.dataTransfer);
|
||
await addFilesToList(files);
|
||
}
|
||
|
||
/**
|
||
* 处理通过文件输入框选择文件后的 `change` 事件。
|
||
* @param {Event} e - 事件对象,`e.target` 是文件输入框。
|
||
*/
|
||
async function handleFileSelect(e) {
|
||
if (isProcessing) return;
|
||
await addFilesToList(e.target.files);
|
||
e.target.value = null; // 允许重新选择相同文件
|
||
}
|
||
|
||
async function handleFolderSelect(e) {
|
||
if (isProcessing) return;
|
||
await addFilesToList(e.target.files);
|
||
e.target.value = null;
|
||
}
|
||
|
||
/**
|
||
* 处理点击"清空列表"按钮的事件。
|
||
* 清空 `pdfFiles` 数组和 `allResults` 数组,并更新UI。
|
||
* 同时清空 `window.data` 用于调试或特定UI显示。
|
||
*/
|
||
function handleClearFiles() {
|
||
if (isProcessing) return;
|
||
pdfFiles = [];
|
||
allResults = []; // 清空结果
|
||
// ========== 新增:清空文件时刷新 window.data ==========
|
||
window.data = {};
|
||
updateFileListUI(pdfFiles, isProcessing, handleRemoveFile);
|
||
updateProcessButtonState(pdfFiles, isProcessing);
|
||
refreshFormatFilters();
|
||
}
|
||
|
||
/**
|
||
* 根据当前已选择的文件数量同步批量模式相关控件的状态。
|
||
*
|
||
* @param {number} fileCount - 当前列表中的文件数量。
|
||
*/
|
||
function syncBatchModeControls(fileCount) {
|
||
const wrapper = document.getElementById('batchModeToggleWrapper');
|
||
const toggle = document.getElementById('batchModeToggle');
|
||
const configPanel = document.getElementById('batchModeConfig');
|
||
const configBody = document.getElementById('batchModeConfigBody');
|
||
|
||
const available = fileCount >= 2;
|
||
if (wrapper) {
|
||
wrapper.classList.toggle('hidden', !available);
|
||
}
|
||
if (toggle) {
|
||
toggle.disabled = !available;
|
||
toggle.checked = available && batchModeEnabled;
|
||
}
|
||
if (configPanel) {
|
||
const shouldShowConfig = available && batchModeEnabled;
|
||
configPanel.classList.toggle('hidden', !shouldShowConfig);
|
||
if (!shouldShowConfig) {
|
||
batchConfigCollapsed = true;
|
||
}
|
||
}
|
||
|
||
if (configPanel && configBody) {
|
||
updateBatchConfigCollapse();
|
||
}
|
||
}
|
||
|
||
window.syncBatchModeControls = syncBatchModeControls;
|
||
|
||
/**
|
||
* 处理从文件列表中移除单个文件的操作。
|
||
* @param {number} indexToRemove - 要从 `pdfFiles` 数组中移除的文件的索引。
|
||
*/
|
||
function handleRemoveFile(indexToRemove) {
|
||
pdfFiles.splice(indexToRemove, 1);
|
||
// ========== 新增:移除文件时刷新 window.data ==========
|
||
if (pdfFiles.length === 1) {
|
||
window.data = { name: pdfFiles[0].name, ocr: '', translation: '', images: [], summaries: {} };
|
||
} else if (pdfFiles.length === 0) {
|
||
window.data = {};
|
||
} else {
|
||
window.data = { summaries: {} };
|
||
}
|
||
updateFileListUI(pdfFiles, isProcessing, handleRemoveFile);
|
||
updateProcessButtonState(pdfFiles, isProcessing);
|
||
refreshFormatFilters();
|
||
}
|
||
|
||
/**
|
||
* 将用户选择的文件添加到待处理列表 `pdfFiles` 中。
|
||
* 会进行文件类型检查(支持 PDF / MD / TXT / DOCX / PPTX / HTML / EPUB)和重复文件检查(基于文件名和大小)。
|
||
* 添加文件后会更新UI。
|
||
* @param {FileList} selectedFiles - 用户通过拖拽或文件对话框选择的文件列表。
|
||
*/
|
||
async function addFilesToList(selectedFiles) {
|
||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||
const fileArray = Array.from(selectedFiles);
|
||
const incomingFiles = [];
|
||
|
||
for (const rawFile of fileArray) {
|
||
const ext = deriveExtension(rawFile && rawFile.name ? rawFile.name : '');
|
||
if (SUPPORTED_ARCHIVE_EXTENSIONS.includes(ext)) {
|
||
const extracted = await extractFilesFromZip(rawFile);
|
||
if (extracted.length === 0) {
|
||
showNotification && showNotification(`压缩包 "${rawFile.name}" 中没有可处理的文件`, 'info');
|
||
}
|
||
incomingFiles.push(...extracted);
|
||
continue;
|
||
}
|
||
|
||
if (!isSupportedFileExtension(ext)) {
|
||
const supportedLabel = SUPPORTED_FILE_EXTENSIONS.map(v => v.toUpperCase()).join(' / ');
|
||
showNotification && showNotification(`文件 "${rawFile.name}" 不是支持的文件类型 (${supportedLabel}),已忽略`, 'warning');
|
||
continue;
|
||
}
|
||
|
||
annotateFileMetadata(rawFile);
|
||
incomingFiles.push(rawFile);
|
||
}
|
||
|
||
if (incomingFiles.length === 0) return;
|
||
|
||
let filesAdded = false;
|
||
incomingFiles.forEach(file => {
|
||
const identifier = buildFileIdentifier(file);
|
||
const duplication = pdfFiles.some(existing => buildFileIdentifier(existing) === identifier);
|
||
if (duplication) {
|
||
showNotification && showNotification(`文件 "${getFileDisplayName(file)}" 已在列表中`, 'info');
|
||
return;
|
||
}
|
||
pdfFiles.push(file);
|
||
filesAdded = true;
|
||
});
|
||
|
||
if (filesAdded) {
|
||
if (pdfFiles.length === 1) {
|
||
window.data = { name: pdfFiles[0].name, ocr: '', translation: '', images: [], summaries: {} };
|
||
} else if (pdfFiles.length > 1) {
|
||
window.data = { summaries: {} };
|
||
}
|
||
updateFileListUI(pdfFiles, isProcessing, handleRemoveFile);
|
||
updateProcessButtonState(pdfFiles, isProcessing);
|
||
syncBatchModeControls(pdfFiles.length);
|
||
refreshFormatFilters();
|
||
}
|
||
}
|
||
|
||
function deriveExtension(name) {
|
||
if (!name || typeof name !== 'string') return '';
|
||
const cleaned = name.split('?')[0].split('#')[0];
|
||
const parts = cleaned.split('.');
|
||
if (parts.length <= 1) return '';
|
||
return parts.pop().trim().toLowerCase();
|
||
}
|
||
|
||
function isExtensionExcluded(ext) {
|
||
return excludedExtensions.has((ext || '').toLowerCase());
|
||
}
|
||
|
||
window.isExtensionExcluded = isExtensionExcluded;
|
||
|
||
function isSupportedFileExtension(ext) {
|
||
return SUPPORTED_FILE_EXTENSIONS.includes((ext || '').toLowerCase());
|
||
}
|
||
|
||
function getFileRelativePath(file) {
|
||
if (!file) return '';
|
||
return file.pbxRelativePath || file.webkitRelativePath || file.relativePath || file.fullPath || file.name || '';
|
||
}
|
||
|
||
function getFileDisplayName(file) {
|
||
const rel = getFileRelativePath(file);
|
||
if (!rel) return file && file.name ? file.name : '';
|
||
const normalized = rel.replace(/\\/g, '/');
|
||
const parts = normalized.split('/');
|
||
return parts[parts.length - 1] || rel;
|
||
}
|
||
|
||
function annotateFileMetadata(file, providedPath) {
|
||
if (!file) return;
|
||
const relativePath = providedPath || file.webkitRelativePath || file.relativePath || file.fullPath || file.name || '';
|
||
try {
|
||
file.pbxRelativePath = relativePath;
|
||
file.originalName = file.originalName || file.name;
|
||
} catch (e) {
|
||
// ignore readonly property assignment errors
|
||
}
|
||
}
|
||
|
||
function buildFileIdentifier(file) {
|
||
const rel = getFileRelativePath(file).toLowerCase();
|
||
return `${rel}__${file && typeof file.size === 'number' ? file.size : '0'}`;
|
||
}
|
||
|
||
async function extractFilesFromZip(zipFile, options = {}) {
|
||
if (typeof JSZip === 'undefined') {
|
||
showNotification && showNotification('缺少 JSZip 依赖,无法解压 ZIP', 'error');
|
||
return [];
|
||
}
|
||
try {
|
||
const zip = await JSZip.loadAsync(zipFile);
|
||
const entries = [];
|
||
const zipFiles = Object.keys(zip.files);
|
||
const pathPrefix = options.pathPrefix ? options.pathPrefix.replace(/\\/g, '/') : '';
|
||
const stripRoot = options.stripRoot || false;
|
||
|
||
for (const key of zipFiles) {
|
||
const entry = zip.files[key];
|
||
if (!entry || entry.dir) continue;
|
||
const normalizedPath = key.replace(/\\/g, '/');
|
||
|
||
if (pathPrefix) {
|
||
if (!normalizedPath.startsWith(pathPrefix)) continue;
|
||
}
|
||
|
||
const ext = deriveExtension(normalizedPath);
|
||
if (!isSupportedFileExtension(ext)) continue;
|
||
|
||
const blob = await entry.async('blob');
|
||
const baseNameParts = normalizedPath.split('/');
|
||
let displayName = baseNameParts.pop();
|
||
let relativePath = normalizedPath;
|
||
|
||
if (pathPrefix) {
|
||
relativePath = normalizedPath.substring(pathPrefix.length);
|
||
if (relativePath.startsWith('/')) {
|
||
relativePath = relativePath.slice(1);
|
||
}
|
||
} else if (stripRoot && baseNameParts.length > 0) {
|
||
// remove first segment (top-level directory)
|
||
const segments = normalizedPath.split('/');
|
||
segments.shift();
|
||
relativePath = segments.join('/');
|
||
displayName = segments.pop() || displayName;
|
||
}
|
||
|
||
if (!relativePath) {
|
||
relativePath = displayName;
|
||
}
|
||
|
||
const derivedName = displayName || normalizedPath;
|
||
const newFile = new File([blob], derivedName, {
|
||
type: blob.type || 'application/octet-stream',
|
||
lastModified: zipFile.lastModified || Date.now()
|
||
});
|
||
annotateFileMetadata(newFile, relativePath);
|
||
try {
|
||
newFile.virtualSource = 'zip';
|
||
newFile.sourceArchive = zipFile.name;
|
||
} catch (_) {}
|
||
entries.push(newFile);
|
||
}
|
||
|
||
return entries;
|
||
} catch (error) {
|
||
console.error('解压 ZIP 文件失败:', error);
|
||
showNotification && showNotification(`解压 "${zipFile && zipFile.name ? zipFile.name : 'ZIP'}" 失败: ${error.message || error}`, 'error');
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function extractFilesFromDataTransfer(dataTransfer) {
|
||
if (!dataTransfer) return [];
|
||
const itemsSnapshot = dataTransfer.items ? Array.from(dataTransfer.items) : [];
|
||
const fallbackSnapshot = dataTransfer.files ? Array.from(dataTransfer.files) : [];
|
||
const firstItem = itemsSnapshot.length > 0 ? itemsSnapshot[0] : null;
|
||
const hasEntryApi = firstItem && typeof firstItem.webkitGetAsEntry === 'function';
|
||
|
||
if (hasEntryApi) {
|
||
const entryPromises = [];
|
||
for (let i = 0; i < itemsSnapshot.length; i++) {
|
||
const item = itemsSnapshot[i];
|
||
if (!item || typeof item.webkitGetAsEntry !== 'function') continue;
|
||
const entry = item.webkitGetAsEntry();
|
||
if (!entry) continue; // In insecure contexts Chrome returns null – fall back later
|
||
entryPromises.push(traverseFileSystemEntry(entry));
|
||
}
|
||
|
||
if (entryPromises.length > 0) {
|
||
const results = await Promise.all(entryPromises);
|
||
const flattened = results.flat().filter(Boolean);
|
||
if (flattened.length > 0) {
|
||
return flattened;
|
||
}
|
||
console.warn('extractFilesFromDataTransfer: FileSystemEntry API returned no files, using fallback.');
|
||
}
|
||
}
|
||
|
||
fallbackSnapshot.forEach(file => annotateFileMetadata(file));
|
||
return fallbackSnapshot;
|
||
}
|
||
|
||
function refreshFormatFilters() {
|
||
const container = document.getElementById('fileFormatFilters');
|
||
if (!container) return;
|
||
const counts = new Map();
|
||
pdfFiles.forEach(file => {
|
||
const ext = deriveExtension(file.name || '') || '';
|
||
counts.set(ext, (counts.get(ext) || 0) + 1);
|
||
});
|
||
// 清理不存在的扩展
|
||
Array.from(excludedExtensions).forEach(ext => {
|
||
if (!counts.has(ext)) {
|
||
excludedExtensions.delete(ext);
|
||
}
|
||
});
|
||
|
||
if (counts.size === 0) {
|
||
container.innerHTML = '<span class="text-gray-500">暂无文件</span>';
|
||
return;
|
||
}
|
||
|
||
const fragments = [];
|
||
const entries = Array.from(counts.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
||
entries.forEach(([ext, count]) => {
|
||
const checked = !isExtensionExcluded(ext) ? 'checked' : '';
|
||
const label = ext ? ext.toUpperCase() : '未知';
|
||
// XSS 防护:转义文件扩展名,防止恶意文件名注入
|
||
const safeExt = escapeHtml(ext);
|
||
const safeLabel = escapeHtml(label);
|
||
fragments.push(`
|
||
<label class="flex items-center space-x-1 bg-white border border-gray-200 rounded px-2 py-1 shadow-sm">
|
||
<input type="checkbox" class="format-filter-checkbox" data-ext="${safeExt}" ${checked}>
|
||
<span>${safeLabel} <span class="text-gray-400">(${count})</span></span>
|
||
</label>
|
||
`);
|
||
});
|
||
|
||
// fragments.push('<div class="flex-grow"></div><button type="button" id="resetFormatFilters" class="text-xs text-blue-600">重置</button>');
|
||
container.innerHTML = `<div class="flex flex-wrap gap-2 items-center">${fragments.join('')}</div>`;
|
||
}
|
||
|
||
function getActiveFiles() {
|
||
return pdfFiles.filter(file => {
|
||
const ext = deriveExtension(file.name || '');
|
||
return !isExtensionExcluded(ext);
|
||
});
|
||
}
|
||
|
||
window.getActiveFiles = getActiveFiles;
|
||
|
||
function updateBatchConfigCollapse() {
|
||
const body = document.getElementById('batchModeConfigBody');
|
||
const toggleLabel = document.getElementById('batchModeConfigToggleLabel');
|
||
const toggleIcon = document.getElementById('batchModeConfigToggleIcon');
|
||
if (!body || !toggleLabel || !toggleIcon) return;
|
||
|
||
if (batchConfigCollapsed) {
|
||
body.classList.add('hidden');
|
||
toggleLabel.textContent = '展开设置';
|
||
toggleIcon.setAttribute('icon', 'carbon:chevron-down');
|
||
} else {
|
||
body.classList.remove('hidden');
|
||
toggleLabel.textContent = '收起设置';
|
||
toggleIcon.setAttribute('icon', 'carbon:chevron-up');
|
||
}
|
||
}
|
||
|
||
function handleFormatFilterChange(event) {
|
||
const target = event.target;
|
||
if (!target || !target.classList.contains('format-filter-checkbox')) return;
|
||
const ext = target.getAttribute('data-ext');
|
||
if (!ext) return;
|
||
if (target.checked) {
|
||
excludedExtensions.delete(ext);
|
||
} else {
|
||
excludedExtensions.add(ext);
|
||
}
|
||
updateFileListUI(pdfFiles, isProcessing, handleRemoveFile);
|
||
refreshFormatFilters();
|
||
updateProcessButtonState(pdfFiles, isProcessing);
|
||
syncBatchModeControls(pdfFiles.length);
|
||
}
|
||
|
||
function handleFormatFilterClick(event) {
|
||
const target = event.target;
|
||
if (target && target.id === 'resetFormatFilters') {
|
||
excludedExtensions.clear();
|
||
updateFileListUI(pdfFiles, isProcessing, handleRemoveFile);
|
||
refreshFormatFilters();
|
||
updateProcessButtonState(pdfFiles, isProcessing);
|
||
syncBatchModeControls(pdfFiles.length);
|
||
}
|
||
}
|
||
|
||
function traverseFileSystemEntry(entry, path = '') {
|
||
return new Promise((resolve) => {
|
||
if (!entry) {
|
||
resolve([]);
|
||
return;
|
||
}
|
||
|
||
if (entry.isFile) {
|
||
entry.file(file => {
|
||
const relativePath = path ? `${path}/${file.name}` : file.name;
|
||
annotateFileMetadata(file, relativePath);
|
||
resolve([file]);
|
||
}, () => resolve([]));
|
||
} else if (entry.isDirectory) {
|
||
const directoryReader = entry.createReader();
|
||
const accumulated = [];
|
||
|
||
const readEntries = () => {
|
||
directoryReader.readEntries(async batch => {
|
||
if (!batch.length) {
|
||
const nestedResults = [];
|
||
for (const child of accumulated) {
|
||
const childPath = path ? `${path}/${child.name}` : child.name;
|
||
const childFiles = await traverseFileSystemEntry(child, childPath);
|
||
nestedResults.push(...childFiles);
|
||
}
|
||
resolve(nestedResults);
|
||
} else {
|
||
accumulated.push(...batch);
|
||
readEntries();
|
||
}
|
||
}, () => resolve([]));
|
||
};
|
||
|
||
readEntries();
|
||
} else {
|
||
resolve([]);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleGithubImport() {
|
||
const rawUrl = prompt('请输入 GitHub 仓库或目录链接 (例如 https://github.com/user/repo 或 https://github.com/user/repo/tree/branch/path):');
|
||
if (!rawUrl) return;
|
||
|
||
const parsed = parseGithubUrl(rawUrl.trim());
|
||
if (!parsed) {
|
||
showNotification && showNotification('无法解析 GitHub 链接,请检查格式。', 'error');
|
||
return;
|
||
}
|
||
|
||
const { owner, repo, ref, pathPrefix } = parsed;
|
||
|
||
try {
|
||
showNotification && showNotification('正在从 GitHub 获取文件列表,请稍候...', 'info');
|
||
const treeEntries = await fetchGithubTree(owner, repo, ref, pathPrefix);
|
||
if (!treeEntries.length) {
|
||
showNotification && showNotification('在指定路径中未找到可处理的文件。', 'warning');
|
||
return;
|
||
}
|
||
|
||
const files = await downloadGithubFiles(owner, repo, ref, treeEntries, pathPrefix);
|
||
if (!files.length) {
|
||
showNotification && showNotification('获取 GitHub 文件失败或没有可处理的文件。', 'warning');
|
||
return;
|
||
}
|
||
|
||
await addFilesToList(files);
|
||
showNotification && showNotification(`已从 ${owner}/${repo} 导入 ${files.length} 个文件`, 'success');
|
||
} catch (error) {
|
||
console.error('GitHub 导入失败:', error);
|
||
showNotification && showNotification(`GitHub 导入失败:${error.message || error}`, 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理 URL 导入(支持 arXiv 和任意 PDF 链接)
|
||
*/
|
||
async function handleUrlImport() {
|
||
const rawUrls = prompt('请输入 PDF URL(支持 arXiv 链接),多个链接请用换行分隔:\n\n例如:\nhttps://arxiv.org/abs/2301.12345\nhttps://example.com/paper.pdf');
|
||
if (!rawUrls) return;
|
||
|
||
const urls = rawUrls.trim().split('\n').map(u => u.trim()).filter(Boolean);
|
||
if (urls.length === 0) return;
|
||
|
||
try {
|
||
showNotification && showNotification(`正在下载 ${urls.length} 个 PDF 文件...`, 'info');
|
||
const downloadResults = await Promise.allSettled(
|
||
urls.map(url => downloadPdfFromUrl(url))
|
||
);
|
||
|
||
const successFiles = [];
|
||
const failedUrls = [];
|
||
|
||
downloadResults.forEach((result, index) => {
|
||
if (result.status === 'fulfilled' && result.value) {
|
||
successFiles.push(result.value);
|
||
} else {
|
||
failedUrls.push({ url: urls[index], error: result.reason?.message || '未知错误' });
|
||
}
|
||
});
|
||
|
||
if (successFiles.length > 0) {
|
||
await addFilesToList(successFiles);
|
||
}
|
||
|
||
if (failedUrls.length > 0) {
|
||
console.error('部分 URL 下载失败:', failedUrls);
|
||
const failedList = failedUrls.map(f => `${f.url}: ${f.error}`).join('\n');
|
||
showNotification && showNotification(
|
||
`成功导入 ${successFiles.length} 个文件,失败 ${failedUrls.length} 个\n\n失败列表:\n${failedList}`,
|
||
successFiles.length > 0 ? 'warning' : 'error'
|
||
);
|
||
} else {
|
||
showNotification && showNotification(`成功从 URL 导入 ${successFiles.length} 个文件`, 'success');
|
||
}
|
||
} catch (error) {
|
||
console.error('URL 导入失败:', error);
|
||
showNotification && showNotification(`URL 导入失败:${error.message || error}`, 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从 URL 下载 PDF(支持 arXiv 链接识别和 failback 机制)
|
||
* @param {string} url - PDF URL 或 arXiv 链接
|
||
* @returns {Promise<File>} - 下载的文件对象
|
||
*/
|
||
async function downloadPdfFromUrl(url) {
|
||
// 解析 URL,识别 arXiv 链接
|
||
const parsedUrl = parseArxivUrl(url);
|
||
const pdfUrl = parsedUrl.pdfUrl || url;
|
||
const filename = parsedUrl.filename || extractFilenameFromUrl(url);
|
||
|
||
console.log(`[URL Import] Downloading: ${pdfUrl}`);
|
||
|
||
try {
|
||
// 1. 先尝试直接下载
|
||
const file = await downloadPdfDirect(pdfUrl, filename);
|
||
console.log(`[URL Import] Direct download successful: ${filename}`);
|
||
return file;
|
||
} catch (directError) {
|
||
console.warn(`[URL Import] Direct download failed, trying proxy:`, directError.message);
|
||
|
||
try {
|
||
// 2. 失败后尝试通过代理下载
|
||
const file = await downloadPdfViaProxy(pdfUrl, filename);
|
||
console.log(`[URL Import] Proxy download successful: ${filename}`);
|
||
return file;
|
||
} catch (proxyError) {
|
||
console.error(`[URL Import] Both direct and proxy download failed:`, proxyError.message);
|
||
throw new Error(`下载失败: ${proxyError.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解析 arXiv URL,提取 arXiv ID 并转换为 PDF 下载链接
|
||
* @param {string} url - 输入的 URL
|
||
* @returns {Object} - { pdfUrl, filename, arxivId }
|
||
*/
|
||
function parseArxivUrl(url) {
|
||
// 匹配 arXiv URL 格式
|
||
// 支持: https://arxiv.org/abs/2301.12345, https://arxiv.org/pdf/2301.12345.pdf
|
||
const arxivAbsPattern = /arxiv\.org\/abs\/([0-9.]+)/i;
|
||
const arxivPdfPattern = /arxiv\.org\/pdf\/([0-9.]+)/i;
|
||
|
||
let match = url.match(arxivAbsPattern) || url.match(arxivPdfPattern);
|
||
if (match) {
|
||
const arxivId = match[1];
|
||
return {
|
||
pdfUrl: `https://arxiv.org/pdf/${arxivId}.pdf`,
|
||
filename: `arxiv_${arxivId}.pdf`,
|
||
arxivId: arxivId
|
||
};
|
||
}
|
||
|
||
return { pdfUrl: null, filename: null, arxivId: null };
|
||
}
|
||
|
||
/**
|
||
* 从 URL 提取文件名
|
||
* @param {string} url - URL
|
||
* @returns {string} - 文件名
|
||
*/
|
||
function extractFilenameFromUrl(url) {
|
||
try {
|
||
const urlObj = new URL(url);
|
||
const pathname = urlObj.pathname;
|
||
const parts = pathname.split('/').filter(Boolean);
|
||
const lastPart = parts[parts.length - 1];
|
||
|
||
// 确保文件名有 .pdf 扩展名
|
||
if (lastPart && lastPart.endsWith('.pdf')) {
|
||
return lastPart;
|
||
} else if (lastPart) {
|
||
return `${lastPart}.pdf`;
|
||
}
|
||
|
||
// 使用时间戳作为默认文件名
|
||
return `downloaded_${Date.now()}.pdf`;
|
||
} catch (e) {
|
||
return `downloaded_${Date.now()}.pdf`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 直接下载 PDF(不通过代理)
|
||
* @param {string} url - PDF URL
|
||
* @param {string} filename - 文件名
|
||
* @returns {Promise<File>} - 文件对象
|
||
*/
|
||
async function downloadPdfDirect(url, filename) {
|
||
const response = await fetch(url, {
|
||
method: 'GET',
|
||
headers: {
|
||
'Accept': 'application/pdf'
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const contentType = response.headers.get('Content-Type');
|
||
if (!contentType || !contentType.includes('pdf')) {
|
||
throw new Error(`不是 PDF 文件 (Content-Type: ${contentType})`);
|
||
}
|
||
|
||
const blob = await response.blob();
|
||
return new File([blob], filename, { type: 'application/pdf' });
|
||
}
|
||
|
||
/**
|
||
* 通过 academic-search-proxy 代理下载 PDF(支持分片下载)
|
||
* @param {string} url - PDF URL
|
||
* @param {string} filename - 文件名
|
||
* @returns {Promise<File>} - 文件对象
|
||
*/
|
||
async function downloadPdfViaProxy(url, filename) {
|
||
// 获取代理配置
|
||
const proxyConfig = getAcademicSearchProxyConfig();
|
||
if (!proxyConfig.baseUrl) {
|
||
throw new Error('未配置 Academic Search Proxy');
|
||
}
|
||
|
||
const proxyUrl = `${proxyConfig.baseUrl}/api/pdf/download?url=${encodeURIComponent(url)}`;
|
||
|
||
const headers = {
|
||
'Accept': 'application/pdf'
|
||
};
|
||
|
||
// 添加认证头(如果配置了)
|
||
if (proxyConfig.authKey) {
|
||
headers['X-Auth-Key'] = proxyConfig.authKey;
|
||
}
|
||
|
||
console.log(`[URL Import] Using proxy: ${proxyUrl}`);
|
||
|
||
const response = await fetch(proxyUrl, {
|
||
method: 'GET',
|
||
headers: headers
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text().catch(() => response.statusText);
|
||
throw new Error(`代理下载失败 (HTTP ${response.status}): ${errorText}`);
|
||
}
|
||
|
||
const contentType = response.headers.get('Content-Type');
|
||
console.log(`[URL Import] Proxy response Content-Type: ${contentType}`);
|
||
|
||
// 检查 Content-Type(放宽检查,允许 octet-stream 或 HTML 但检查实际内容)
|
||
if (contentType && (contentType.includes('json') || contentType.includes('xml'))) {
|
||
// 如果是 JSON 或 XML,可能是错误响应
|
||
const text = await response.text();
|
||
console.error(`[URL Import] Proxy returned non-PDF content:`, text.substring(0, 500));
|
||
throw new Error(`代理返回的不是 PDF 文件 (Content-Type: ${contentType})`);
|
||
}
|
||
|
||
const blob = await response.blob();
|
||
|
||
// 验证 blob 大小(PDF 文件至少应该有一些内容)
|
||
if (blob.size < 100) {
|
||
throw new Error(`下载的文件过小 (${blob.size} bytes),可能不是有效的 PDF`);
|
||
}
|
||
|
||
console.log(`[URL Import] Downloaded PDF blob: ${blob.size} bytes`);
|
||
return new File([blob], filename, { type: 'application/pdf' });
|
||
}
|
||
|
||
/**
|
||
* 获取 Academic Search Proxy 配置
|
||
* @returns {Object} - { baseUrl, authKey }
|
||
*/
|
||
function getAcademicSearchProxyConfig() {
|
||
// 从 localStorage 读取配置(与 reference-doi-resolver.js 保持一致)
|
||
const storedConfig = localStorage.getItem('academicSearchProxyConfig');
|
||
if (storedConfig) {
|
||
try {
|
||
const config = JSON.parse(storedConfig);
|
||
return {
|
||
baseUrl: config.baseUrl || '',
|
||
authKey: config.authKey || ''
|
||
};
|
||
} catch (e) {
|
||
console.error('[URL Import] Failed to parse proxy config:', e);
|
||
}
|
||
}
|
||
|
||
return { baseUrl: '', authKey: '' };
|
||
}
|
||
|
||
|
||
function parseGithubUrl(rawUrl) {
|
||
try {
|
||
const url = new URL(rawUrl);
|
||
if (!/github\.com$/i.test(url.hostname)) {
|
||
return null;
|
||
}
|
||
const segments = url.pathname.split('/').filter(Boolean);
|
||
if (segments.length < 2) {
|
||
return null;
|
||
}
|
||
const owner = decodeURIComponent(segments[0]);
|
||
const repo = decodeURIComponent(segments[1].replace(/\.git$/i, ''));
|
||
let ref = 'main';
|
||
let pathPrefix = '';
|
||
|
||
if (segments[2] === 'tree' || segments[2] === 'blob') {
|
||
if (segments.length >= 4) {
|
||
ref = decodeURIComponent(segments[3]);
|
||
if (segments.length > 4) {
|
||
pathPrefix = segments.slice(4).map(decodeURIComponent).join('/');
|
||
}
|
||
}
|
||
}
|
||
|
||
return { owner, repo, ref, pathPrefix };
|
||
} catch (error) {
|
||
console.warn('parseGithubUrl error:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function fetchGithubTree(owner, repo, ref, pathPrefix) {
|
||
const encodedRef = encodeURIComponent(ref);
|
||
const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodedRef}?recursive=1`;
|
||
const response = await fetch(treeUrl, { headers: { 'Accept': 'application/vnd.github+json' } });
|
||
if (response.status === 404) {
|
||
throw new Error('未找到仓库或分支,请检查链接');
|
||
}
|
||
if (response.status === 403) {
|
||
throw new Error('GitHub API 速率限制,请稍后再试');
|
||
}
|
||
if (!response.ok) {
|
||
throw new Error(`GitHub API 返回错误状态 ${response.status}`);
|
||
}
|
||
const data = await response.json();
|
||
if (!data || !Array.isArray(data.tree)) {
|
||
throw new Error('GitHub API 返回数据不完整');
|
||
}
|
||
|
||
const prefix = pathPrefix ? pathPrefix.replace(/\\/g, '/').replace(/^\//, '').replace(/\/$/, '') : '';
|
||
const matched = data.tree.filter(item => {
|
||
if (!item || item.type !== 'blob') return false;
|
||
if (!prefix) return true;
|
||
if (!item.path) return false;
|
||
return item.path === prefix || item.path.startsWith(prefix + '/');
|
||
});
|
||
|
||
return matched;
|
||
}
|
||
|
||
async function downloadGithubFiles(owner, repo, ref, treeEntries, pathPrefix) {
|
||
const files = [];
|
||
const prefix = pathPrefix ? pathPrefix.replace(/\\/g, '/').replace(/^\//, '').replace(/\/$/, '') : '';
|
||
const repoIdentifier = `${owner}/${repo}@${ref}`;
|
||
|
||
const queue = treeEntries.slice();
|
||
const concurrency = 4;
|
||
const workers = new Array(concurrency).fill(null).map(async () => {
|
||
while (queue.length > 0) {
|
||
const entry = queue.shift();
|
||
if (!entry || !entry.path) continue;
|
||
let relativePath = entry.path;
|
||
if (prefix) {
|
||
if (!entry.path.startsWith(prefix + '/')) {
|
||
continue;
|
||
}
|
||
relativePath = entry.path.slice(prefix.length + 1);
|
||
}
|
||
if (!relativePath || relativePath.endsWith('/')) continue;
|
||
const ext = deriveExtension(relativePath);
|
||
if (!isSupportedFileExtension(ext)) continue;
|
||
|
||
const rawPathParts = entry.path.split('/').map(encodeURIComponent).join('/');
|
||
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${rawPathParts}`;
|
||
try {
|
||
const fileResponse = await fetch(rawUrl);
|
||
if (!fileResponse.ok) {
|
||
console.warn(`无法获取 ${rawUrl}: ${fileResponse.status}`);
|
||
continue;
|
||
}
|
||
const blob = await fileResponse.blob();
|
||
const displayName = relativePath.split('/').pop();
|
||
const file = new File([blob], displayName || 'document', {
|
||
type: blob.type || 'application/octet-stream',
|
||
lastModified: Date.now()
|
||
});
|
||
const pathSegments = [repo];
|
||
if (prefix) pathSegments.push(prefix);
|
||
pathSegments.push(relativePath);
|
||
const annotatedPath = pathSegments
|
||
.filter(Boolean)
|
||
.join('/')
|
||
.replace(/\/+/g, '/');
|
||
annotateFileMetadata(file, annotatedPath);
|
||
try {
|
||
file.virtualSource = 'github';
|
||
file.sourceArchive = repoIdentifier;
|
||
} catch (_) {}
|
||
files.push(file);
|
||
} catch (error) {
|
||
console.warn('下载 GitHub 文件失败:', error);
|
||
}
|
||
}
|
||
});
|
||
|
||
await Promise.all(workers);
|
||
return files;
|
||
}
|
||
|
||
/**
|
||
* 根据目标语言下拉菜单的选择,更新自定义语言输入框的可见性。
|
||
* 如果选择了 "custom",则显示自定义语言名称输入框,否则隐藏。
|
||
*/
|
||
function updateCustomLanguageInputVisibility() {
|
||
const targetLangValue = document.getElementById('targetLanguage').value;
|
||
const customInputContainer = document.getElementById('customTargetLanguageContainer');
|
||
if (targetLangValue === 'custom') {
|
||
customInputContainer.classList.remove('hidden');
|
||
} else {
|
||
customInputContainer.classList.add('hidden');
|
||
}
|
||
|
||
if (typeof window.updateDeeplxTargetLangHint === 'function') {
|
||
window.updateDeeplxTargetLangHint();
|
||
}
|
||
}
|
||
|
||
function updateDeeplxTargetLangHint() {
|
||
const hintEl = document.getElementById('deeplxTargetLangHint');
|
||
if (!hintEl) return;
|
||
const modelSelect = document.getElementById('translationModel');
|
||
const modelValue = modelSelect ? modelSelect.value : '';
|
||
if (modelValue !== 'deeplx') {
|
||
hintEl.textContent = '选择 DeepLX 后会显示对应的目标语言代码。';
|
||
return;
|
||
}
|
||
|
||
const targetSelect = document.getElementById('targetLanguage');
|
||
let langValue = targetSelect ? targetSelect.value : '';
|
||
if (langValue === 'custom') {
|
||
const customInput = document.getElementById('customTargetLanguageInput');
|
||
if (customInput && customInput.value.trim()) {
|
||
langValue = customInput.value.trim();
|
||
} else {
|
||
langValue = '';
|
||
}
|
||
}
|
||
|
||
const mapper = (typeof window.mapToDeeplxLangCode === 'function') ? window.mapToDeeplxLangCode : null;
|
||
const code = mapper ? mapper(langValue) : undefined;
|
||
if (code) {
|
||
const displayMap = (typeof window.DEEPLX_LANG_DISPLAY === 'object') ? window.DEEPLX_LANG_DISPLAY : null;
|
||
const display = displayMap && displayMap[code];
|
||
const zhName = display && display.zh ? display.zh : '';
|
||
hintEl.textContent = zhName ? `当前目标语言代码:${code}(${zhName})` : `当前目标语言代码:${code}`;
|
||
} else {
|
||
hintEl.textContent = '请在目标语言中选择 DeepL 支持的语言,或在自定义输入框中手动填写如 EN、DE 等代码。';
|
||
}
|
||
}
|
||
|
||
window.updateDeeplxTargetLangHint = updateDeeplxTargetLangHint;
|
||
|
||
/**
|
||
* 保存当前所有用户设置到 localStorage。
|
||
* 从UI元素读取各项配置值,构建设置对象,然后调用 `saveSettings` (来自 storage.js)。
|
||
*/
|
||
function saveCurrentSettings() {
|
||
// 从 DOM 读取当前所有设置值
|
||
const targetLangValue = document.getElementById('targetLanguage').value;
|
||
const selectedModel = document.getElementById('translationModel').value;
|
||
let selectedSiteId = null;
|
||
if (selectedModel === 'custom') {
|
||
const siteDropdown = document.getElementById('customSourceSiteSelect');
|
||
if (siteDropdown) {
|
||
selectedSiteId = siteDropdown.value;
|
||
}
|
||
}
|
||
|
||
const settingsData = {
|
||
maxTokensPerChunk: document.getElementById('maxTokensPerChunk').value,
|
||
skipProcessedFiles: document.getElementById('skipProcessedFiles').checked,
|
||
selectedTranslationModel: selectedModel,
|
||
selectedCustomSourceSiteId: selectedSiteId,
|
||
concurrencyLevel: document.getElementById('concurrencyLevel').value,
|
||
translationConcurrencyLevel: document.getElementById('translationConcurrencyLevel').value,
|
||
targetLanguage: targetLangValue,
|
||
customTargetLanguageName: targetLangValue === 'custom' ? document.getElementById('customTargetLanguageInput').value : '',
|
||
defaultSystemPrompt: document.getElementById('defaultSystemPrompt').value,
|
||
defaultUserPromptTemplate: document.getElementById('defaultUserPromptTemplate').value,
|
||
promptMode: document.querySelector('input[name="promptMode"]:checked')?.value || 'builtin',
|
||
enableGlossary: document.getElementById('enableGlossaryToggle')?.checked || false,
|
||
batchModeEnabled: batchModeEnabled,
|
||
batchModeTemplate: batchModeTemplate,
|
||
batchModeFormats: Array.from(new Set(batchModeFormats)),
|
||
batchModeZipEnabled: batchModeZipEnabled
|
||
};
|
||
if (!settingsData.batchModeFormats.includes('original')) {
|
||
settingsData.batchModeFormats.unshift('original');
|
||
}
|
||
// 调用 storage.js 中的保存函数
|
||
saveSettings(settingsData);
|
||
|
||
// 旧的自定义模型设置保存逻辑已移除,因为它们通过源站点配置进行管理和保存。
|
||
// If a specific custom source site is selected, its details are already saved via key-manager-ui.js
|
||
// and `loadAllCustomSourceSites()` in `handleProcessClick` will fetch them.
|
||
}
|
||
|
||
// =====================
|
||
// 核心处理流程启动
|
||
// =====================
|
||
/**
|
||
* 处理点击"开始处理"按钮的事件,启动核心的文件处理流程。
|
||
* 步骤包括:
|
||
* 1. 加载最新设置。
|
||
* 2. 根据是否需要OCR(PDF文件)和用户选择的翻译模型,初始化 `KeyProvider` 实例。
|
||
* 3. 检查所需API Keys是否可用,若不可用则提示用户并中止。
|
||
* 4. 检查是否有文件被选中。
|
||
* 5. 设置处理状态变量,更新UI(如进度条、按钮状态)。
|
||
* 6. 获取并发数、重试次数、目标语言等处理参数。
|
||
* 7. 遍历文件列表,对于需要处理的文件(未跳过),将其加入处理队列。
|
||
* 8. 启动异步处理队列 `processQueue`,该队列会并发地调用 `processSinglePdf` 处理每个文件。
|
||
* 9. `processSinglePdf` 会处理单个文件的OCR、分块、翻译,并处理可能的Key失效和重试。
|
||
* 10. 收集每个文件的处理结果(成功、失败、跳过)。
|
||
* 11. 处理完成后,更新UI,保存已处理文件记录,并显示结果下载区域。
|
||
* @async
|
||
*/
|
||
async function handleProcessClick() {
|
||
if (isProcessing) return;
|
||
|
||
// 1. 获取设置,包括选定的翻译模型
|
||
const settings = loadSettings(); // 从存储加载最新设置
|
||
// const selectedTranslationModelName = document.getElementById('translationModel').value; // 旧的直接读取方式
|
||
const selectedTranslationModelName = settings.selectedTranslationModel;
|
||
|
||
const hasPdfFiles = pdfFiles.some(file => file.name.toLowerCase().endsWith('.pdf'));
|
||
const filesToProcess = getActiveFiles();
|
||
|
||
// 2. 检查 OCR 配置(如果有 PDF 文件)
|
||
if (hasPdfFiles) {
|
||
let ocrEngine = 'mineru';
|
||
try {
|
||
if (window.ocrSettingsManager && typeof window.ocrSettingsManager.getCurrentConfig === 'function') {
|
||
ocrEngine = window.ocrSettingsManager.getCurrentConfig().engine || 'mineru';
|
||
const validation = window.ocrSettingsManager.validateConfig();
|
||
if (!validation.valid) {
|
||
const engineNames = { mistral: 'Mistral OCR', mineru: 'MinerU', doc2x: 'Doc2X', none: '不需要 OCR' };
|
||
const engineName = engineNames[ocrEngine] || ocrEngine;
|
||
showNotification(`OCR 引擎(${engineName})配置不完整:${validation.message}`, 'error');
|
||
return;
|
||
}
|
||
} else {
|
||
// 回退逻辑:检查 Mistral Keys
|
||
const mistralKeyProvider = new KeyProvider('mistral');
|
||
if (!mistralKeyProvider.hasAvailableKeys()) {
|
||
showNotification('检测到 PDF 文件,但没有可用的 Mistral API Key (请在Key管理中添加并确保状态为有效或未测试)', 'error');
|
||
return;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('[OCR Check] Failed:', e);
|
||
showNotification('OCR 配置检查失败,请刷新页面重试', 'error');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 初始化翻译 Key Provider
|
||
let translationKeyProvider = null;
|
||
/** @type {string|null} 用于 KeyProvider 的模型名 (例如 'gemini', 'custom_source_xxx') */
|
||
let currentTranslationModelForProvider = null;
|
||
/** @type {Object|null} 用于 processSinglePdf 的模型配置对象 (包含baseUrl, modelId等) */
|
||
let translationModelConfigForProcess = null;
|
||
|
||
if (selectedTranslationModelName !== 'none') {
|
||
if (selectedTranslationModelName === 'custom') {
|
||
const selectedCustomSourceId = settings.selectedCustomSourceSiteId; // 从保存的设置中获取
|
||
if (!selectedCustomSourceId) {
|
||
isProcessing = false;
|
||
updateProcessButtonState(pdfFiles, isProcessing);
|
||
showNotification('请先在主页面选择一个自定义源站点,并确保已配置API Key。', 'error');
|
||
addProgressLog('错误: 未选择自定义源站点 (从设置加载失败)。');
|
||
|
||
// 尝试自动展开自定义源站点设置区域
|
||
const customSourceSiteToggle = document.getElementById('customSourceSiteToggle');
|
||
const customSourceSite = document.getElementById('customSourceSite');
|
||
const customSourceSiteToggleIcon = document.getElementById('customSourceSiteToggleIcon');
|
||
|
||
if (customSourceSiteToggle && customSourceSite && customSourceSite.classList.contains('hidden')) {
|
||
customSourceSite.classList.remove('hidden');
|
||
if (customSourceSiteToggleIcon) {
|
||
customSourceSiteToggleIcon.setAttribute('icon', 'carbon:chevron-up');
|
||
}
|
||
}
|
||
|
||
return;
|
||
}
|
||
const allSourceSites = typeof loadAllCustomSourceSites === 'function' ? loadAllCustomSourceSites() : {};
|
||
const siteConfig = allSourceSites[selectedCustomSourceId];
|
||
|
||
if (!siteConfig) {
|
||
isProcessing = false;
|
||
updateProcessButtonState(pdfFiles, isProcessing);
|
||
showNotification(`未能加载ID为 "${selectedCustomSourceId}" 的自定义源站配置。`, 'error');
|
||
addProgressLog(`错误: 未能加载自定义源站配置 (ID: ${selectedCustomSourceId})。`);
|
||
// UI 清理和返回的逻辑...
|
||
return;
|
||
}
|
||
currentTranslationModelForProvider = `custom_source_${selectedCustomSourceId}`;
|
||
translationModelConfigForProcess = siteConfig; // siteConfig 包含 apiBaseUrl, modelId 等
|
||
addProgressLog(`使用自定义源站: ${siteConfig.displayName || selectedCustomSourceId}`);
|
||
} else {
|
||
// For preset models
|
||
currentTranslationModelForProvider = selectedTranslationModelName;
|
||
translationModelConfigForProcess = typeof loadModelConfig === 'function' ? loadModelConfig(selectedTranslationModelName) : {}; // 可能包含预设的baseUrl等
|
||
if (!translationModelConfigForProcess && selectedTranslationModelName !== 'none'){
|
||
//尝试从旧的 customModelSettings 加载,以兼容旧版单自定义模型设置,但这部分应逐渐淘汰
|
||
console.warn(`Preset model config for ${selectedTranslationModelName} not found via loadModelConfig. Attempting fallback (legacy).`);
|
||
}
|
||
addProgressLog(`使用预设翻译模型: ${selectedTranslationModelName}`);
|
||
}
|
||
|
||
if (currentTranslationModelForProvider) {
|
||
translationKeyProvider = new KeyProvider(currentTranslationModelForProvider);
|
||
if (!translationKeyProvider.hasAvailableKeys()) {
|
||
// 优化:更友好的错误提示消息
|
||
const modelDisplayName = translationModelConfigForProcess?.displayName || currentTranslationModelForProvider;
|
||
const isCustomSource = currentTranslationModelForProvider.startsWith('custom_source_');
|
||
let errorMsg = '';
|
||
|
||
if (isCustomSource) {
|
||
const sourceSiteId = currentTranslationModelForProvider.replace('custom_source_', '');
|
||
errorMsg = `源站 "${modelDisplayName}" 没有可用的 API Key。请点击源站信息下方的"管理该站点 API Key"按钮添加Key。`;
|
||
|
||
// 如果当前在处理页,则尝试自动触发API Key管理
|
||
if (typeof showNotification === 'function') {
|
||
setTimeout(() => {
|
||
const manageBtn = document.getElementById('manageSourceSiteKeyBtn');
|
||
if (manageBtn && !manageBtn.classList.contains('hidden')) {
|
||
if (confirm(`是否立即打开源站 "${modelDisplayName}" 的API Key管理界面添加Key?`)) {
|
||
manageBtn.click();
|
||
}
|
||
}
|
||
}, 1000);
|
||
}
|
||
} else {
|
||
errorMsg = `模型 "${modelDisplayName}" 没有可用的 API Key。请点击页面右上方的"模型与Key管理"按钮添加Key。`;
|
||
}
|
||
|
||
showNotification(errorMsg, 'error');
|
||
return;
|
||
}
|
||
}
|
||
} else {
|
||
addProgressLog('未选择翻译模型,跳过翻译步骤。');
|
||
}
|
||
|
||
if (filesToProcess.length === 0) {
|
||
showNotification('请选择至少一个可处理的文件(检查格式筛选是否排除全部文件)', 'error');
|
||
return;
|
||
}
|
||
|
||
// 4. 设置处理状态等...
|
||
isProcessing = true;
|
||
if (typeof window !== 'undefined' && window.promptPoolUI && typeof window.promptPoolUI.resetSessionLock === 'function') {
|
||
window.promptPoolUI.resetSessionLock();
|
||
}
|
||
activeProcessingCount = 0;
|
||
retryAttempts.clear();
|
||
allResults = new Array(filesToProcess.length);
|
||
updateProcessButtonState(pdfFiles, isProcessing);
|
||
// 隐藏处理进度
|
||
// showProgressSection();
|
||
addProgressLog('=== 开始批量处理 ===');
|
||
|
||
// 5. 获取并发和重试设置等...
|
||
const concurrencyLevel = parseInt(settings.concurrencyLevel) || 1;
|
||
const translationConcurrencyLevel = parseInt(settings.translationConcurrencyLevel) || 2;
|
||
const skipEnabled = settings.skipProcessedFiles;
|
||
const maxTokensValue = parseInt(settings.maxTokensPerChunk) || 2000;
|
||
const targetLanguageSetting = settings.targetLanguage;
|
||
const customTargetLanguageNameSetting = settings.customTargetLanguageName;
|
||
const defaultSystemPromptSetting = settings.defaultSystemPrompt;
|
||
const defaultUserPromptTemplateSetting = settings.defaultUserPromptTemplate;
|
||
const useCustomPromptsSetting = settings.useCustomPrompts;
|
||
|
||
const effectiveTargetLanguage = targetLanguageSetting === 'custom'
|
||
? customTargetLanguageNameSetting.trim() || 'English'
|
||
: targetLanguageSetting;
|
||
|
||
if (batchModeEnabled && pdfFiles.length >= 2) {
|
||
const uniqueFormats = Array.from(new Set(batchModeFormats && batchModeFormats.length > 0 ? batchModeFormats : ['markdown']));
|
||
activeBatchSession = {
|
||
id: `batch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||
total: pdfFiles.length,
|
||
template: batchModeTemplate && batchModeTemplate.trim() ? batchModeTemplate.trim() : DEFAULT_BATCH_TEMPLATE,
|
||
formats: uniqueFormats,
|
||
outputLanguage: effectiveTargetLanguage,
|
||
startedAt: new Date().toISOString(),
|
||
counter: 0,
|
||
zipOutput: batchModeZipEnabled
|
||
};
|
||
} else {
|
||
activeBatchSession = null;
|
||
}
|
||
|
||
translationSemaphore.limit = translationConcurrencyLevel;
|
||
translationSemaphore.count = 0;
|
||
translationSemaphore.queue = [];
|
||
|
||
addProgressLog(`文件并发: ${concurrencyLevel}, 翻译并发: ${translationConcurrencyLevel}, 最大重试: ${MAX_RETRIES}, 跳过已处理: ${skipEnabled}`);
|
||
updateConcurrentProgress(0);
|
||
|
||
let successCount = 0;
|
||
let skippedCount = 0;
|
||
let errorCount = 0;
|
||
const pendingIndices = new Set();
|
||
|
||
for (let i = 0; i < filesToProcess.length; i++) {
|
||
const file = filesToProcess[i];
|
||
const fileIdentifier = `${file.name}_${file.size}`;
|
||
if (skipEnabled && isAlreadyProcessed(fileIdentifier, processedFilesRecord)) {
|
||
addProgressLog(`[${file.name}] 已处理过,跳过。`);
|
||
skippedCount++;
|
||
allResults[i] = { file: file, skipped: true };
|
||
} else {
|
||
pendingIndices.add(i);
|
||
}
|
||
}
|
||
|
||
updateOverallProgress(successCount, skippedCount, errorCount, filesToProcess.length);
|
||
|
||
/**
|
||
* 异步处理队列,负责调度单个文件的处理。
|
||
* 它会根据设定的并发数 (`concurrencyLevel`) 来启动 `processSinglePdf` 任务。
|
||
* 内部维护一个待处理文件索引集合 `pendingIndices` 和当前活动处理数 `activeProcessingCount`。
|
||
* @async
|
||
*/
|
||
const processQueue = async () => {
|
||
while (pendingIndices.size > 0 || activeProcessingCount > 0) {
|
||
while (pendingIndices.size > 0 && activeProcessingCount < concurrencyLevel) {
|
||
const currentFileIndex = pendingIndices.values().next().value;
|
||
pendingIndices.delete(currentFileIndex);
|
||
|
||
const currentFile = filesToProcess[currentFileIndex];
|
||
const fileIdentifier = `${currentFile.name}_${currentFile.size}`;
|
||
const currentRetry = retryAttempts.get(fileIdentifier) || 0;
|
||
|
||
activeProcessingCount++;
|
||
updateConcurrentProgress(activeProcessingCount);
|
||
|
||
const retryText = currentRetry > 0 ? ` (重试 ${currentRetry}/${MAX_RETRIES})` : '';
|
||
addProgressLog(`--- [${successCount + skippedCount + errorCount + 1}/${filesToProcess.length}] 开始处理: ${currentFile.name}${retryText} ---`);
|
||
|
||
// OCR Key 管理现在由 OcrManager 和各个适配器内部处理,不再需要在这里检查
|
||
|
||
let translationKeyObject = null;
|
||
// 使用 currentTranslationModelForProvider 来决定是否需要翻译以及获取Key
|
||
if (translationKeyProvider && currentTranslationModelForProvider && currentTranslationModelForProvider !== 'none') {
|
||
if (!translationKeyProvider.hasAvailableKeys()) {
|
||
const modelDisplayName = translationModelConfigForProcess?.displayName || currentTranslationModelForProvider;
|
||
addProgressLog(`[${currentFile.name}] 警告: ${modelDisplayName} 模型无可用Key,将跳过翻译。`);
|
||
} else {
|
||
translationKeyObject = translationKeyProvider.getNextKey();
|
||
if (!translationKeyObject) {
|
||
const modelDisplayName = translationModelConfigForProcess?.displayName || currentTranslationModelForProvider;
|
||
addProgressLog(`[${currentFile.name}] 警告: ${modelDisplayName} Key Provider 返回 null Key。将跳过翻译。`);
|
||
}
|
||
}
|
||
}
|
||
// 对于自定义模型,再次确认配置完整性 (translationModelConfigForProcess 应该已经包含所需信息)
|
||
if (
|
||
selectedTranslationModelName === 'custom' &&
|
||
(
|
||
!translationModelConfigForProcess ||
|
||
(!translationModelConfigForProcess.apiEndpoint && !translationModelConfigForProcess.apiBaseUrl) ||
|
||
!translationModelConfigForProcess.modelId
|
||
)
|
||
) {
|
||
addProgressLog(`[${currentFile.name}] 错误: 自定义翻译模型 (${translationModelConfigForProcess?.displayName || '未知'}) 配置不完整,将跳过翻译。`);
|
||
translationKeyObject = null; // 强制跳过翻译
|
||
}
|
||
|
||
console.log('translationKeyProvider', translationKeyProvider);
|
||
console.log('currentTranslationModelForProvider', currentTranslationModelForProvider);
|
||
console.log('translationKeyProvider.hasAvailableKeys()', translationKeyProvider && translationKeyProvider.hasAvailableKeys());
|
||
console.log('translationKeyProvider.availableKeys', translationKeyProvider && translationKeyProvider.availableKeys);
|
||
console.log('handleProcessClick: translationKeyObject', translationKeyObject);
|
||
|
||
let batchContextForFile = null;
|
||
if (activeBatchSession) {
|
||
activeBatchSession.counter += 1;
|
||
batchContextForFile = {
|
||
id: activeBatchSession.id,
|
||
total: activeBatchSession.total,
|
||
template: activeBatchSession.template,
|
||
formats: activeBatchSession.formats,
|
||
outputLanguage: activeBatchSession.outputLanguage,
|
||
startedAt: activeBatchSession.startedAt,
|
||
order: currentFileIndex + 1,
|
||
originalIndex: currentFileIndex,
|
||
attempt: activeBatchSession.counter
|
||
};
|
||
}
|
||
|
||
processSinglePdf(
|
||
currentFile,
|
||
null, // mistralKeyObject - OCR Key 现在由 OcrManager 内部管理
|
||
translationKeyObject,
|
||
selectedTranslationModelName, // 'custom' or preset model name
|
||
translationModelConfigForProcess, // Specific site config or preset model config
|
||
maxTokensValue,
|
||
effectiveTargetLanguage,
|
||
acquireTranslationSlot,
|
||
releaseTranslationSlot,
|
||
defaultSystemPromptSetting,
|
||
defaultUserPromptTemplateSetting,
|
||
useCustomPromptsSetting,
|
||
batchContextForFile,
|
||
function onFileSuccess(fileObj) {
|
||
// ... (onFileSuccess logic)
|
||
}
|
||
)
|
||
.then(async result => {
|
||
if (result && result.keyInvalid) {
|
||
const { type, keyIdToInvalidate, modelName: invalidModelNameFromCallback } = result.keyInvalid;
|
||
// OCR Key 失效由 OcrManager 内部处理,这里只处理翻译 Key 失效
|
||
if (type === 'mistral') {
|
||
addProgressLog(`[${currentFile.name}] Mistral OCR Key 失效,由 OCR Manager 处理。`);
|
||
// 不需要额外处理,OcrManager 会自动切换到下一个 Key
|
||
} else {
|
||
const affectedKeyProvider = translationKeyProvider;
|
||
// 使用 currentTranslationModelForProvider (如 custom_source_id) 或 invalidModelNameFromCallback
|
||
const modelNameToLog = (currentTranslationModelForProvider && currentTranslationModelForProvider.startsWith('custom_source_') ?
|
||
(translationModelConfigForProcess?.displayName || currentTranslationModelForProvider) :
|
||
(invalidModelNameFromCallback || selectedTranslationModelName));
|
||
|
||
if (affectedKeyProvider && keyIdToInvalidate) {
|
||
addProgressLog(`[${currentFile.name}] 检测到 ${modelNameToLog} API Key (ID: ${keyIdToInvalidate.slice(0,8)}...) 失效。`);
|
||
await affectedKeyProvider.markKeyAsInvalid(keyIdToInvalidate);
|
||
|
||
if (affectedKeyProvider.hasAvailableKeys()) {
|
||
pendingIndices.add(currentFileIndex);
|
||
addProgressLog(`[${currentFile.name}] 将使用下一个可用的 ${modelNameToLog} Key 重试文件。`);
|
||
} else {
|
||
addProgressLog(`[${currentFile.name}] ${modelNameToLog} 模型已无可用Key,文件处理失败。`);
|
||
allResults[currentFileIndex] = { file: currentFile, error: `${modelNameToLog} 模型已无可用Key` };
|
||
errorCount++;
|
||
retryAttempts.delete(fileIdentifier);
|
||
}
|
||
} else {
|
||
addProgressLog(`[${currentFile.name}] Key失效报告不完整,无法标记。文件可能处理失败。`);
|
||
allResults[currentFileIndex] = { file: currentFile, error: result.error || 'Key失效报告不完整' };
|
||
errorCount++;
|
||
retryAttempts.delete(fileIdentifier);
|
||
}
|
||
}
|
||
} else if (result && !result.error) {
|
||
allResults[currentFileIndex] = result;
|
||
markFileAsProcessed(fileIdentifier, processedFilesRecord);
|
||
addProgressLog(`[${currentFile.name}] 处理成功!`);
|
||
successCount++;
|
||
retryAttempts.delete(fileIdentifier);
|
||
|
||
// 单文件模式:更新 window.data
|
||
if (filesToProcess.length === 1 && result.markdown !== undefined) {
|
||
window.data = {
|
||
name: currentFile.name,
|
||
ocr: result.markdown || '',
|
||
translation: result.translation || '',
|
||
images: result.images || [],
|
||
summaries: {}
|
||
};
|
||
}
|
||
|
||
// OCR Key 成功记录现在由 OcrManager 内部处理
|
||
// 仅记录翻译 Key 的成功使用
|
||
// 当翻译成功时,使用 currentTranslationModelForProvider 记录Key
|
||
if (translationKeyObject && currentTranslationModelForProvider && currentTranslationModelForProvider !== 'none') {
|
||
recordLastSuccessfulKey(currentTranslationModelForProvider, translationKeyObject.id);
|
||
}
|
||
|
||
} else {
|
||
const errorMsg = result?.error || '未知错误';
|
||
const nextRetryCount = (retryAttempts.get(fileIdentifier) || 0) + 1;
|
||
|
||
if (nextRetryCount <= MAX_RETRIES) {
|
||
retryAttempts.set(fileIdentifier, nextRetryCount);
|
||
pendingIndices.add(currentFileIndex);
|
||
addProgressLog(`[${currentFile.name}] 处理失败: ${errorMsg}. 稍后重试 (${nextRetryCount}/${MAX_RETRIES}).`);
|
||
} else {
|
||
addProgressLog(`[${currentFile.name}] 处理失败: ${errorMsg}. 已达最大重试次数.`);
|
||
allResults[currentFileIndex] = result || { file: currentFile, error: errorMsg };
|
||
errorCount++;
|
||
retryAttempts.delete(fileIdentifier);
|
||
}
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error(`处理文件 ${currentFile.name} 时发生意外错误:`, error);
|
||
addProgressLog(`错误: 处理 ${currentFile.name} 失败 - ${error.message}`);
|
||
allResults[currentFileIndex] = { file: currentFile, error: error.message };
|
||
errorCount++;
|
||
retryAttempts.delete(fileIdentifier);
|
||
})
|
||
.finally(() => {
|
||
activeProcessingCount--;
|
||
updateConcurrentProgress(activeProcessingCount);
|
||
updateOverallProgress(successCount, skippedCount, errorCount, filesToProcess.length);
|
||
});
|
||
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
}
|
||
if (pendingIndices.size > 0 || activeProcessingCount > 0) {
|
||
await new Promise(resolve => setTimeout(resolve, 200));
|
||
}
|
||
}
|
||
};
|
||
|
||
try {
|
||
await processQueue();
|
||
} catch (err) {
|
||
console.error("处理队列时发生严重错误:", err);
|
||
addProgressLog(`严重错误: 处理队列失败 - ${err.message}`);
|
||
const currentCompleted = successCount + skippedCount + errorCount;
|
||
errorCount = filesToProcess.length - currentCompleted;
|
||
} finally {
|
||
addProgressLog('=== 批量处理完成 ===');
|
||
updateOverallProgress(successCount, skippedCount, errorCount, filesToProcess.length);
|
||
updateProgress('全部完成!', 100);
|
||
updateConcurrentProgress(0);
|
||
|
||
activeBatchSession = null;
|
||
isProcessing = false;
|
||
updateProcessButtonState(pdfFiles, isProcessing);
|
||
// 不显示处理完成
|
||
// showResultsSection(successCount, skippedCount, errorCount, filesToProcess.length);
|
||
saveProcessedFilesRecord(processedFilesRecord);
|
||
// 跳转到历史细节界面
|
||
const successfulResult = allResults.find(r => r && r.file && !r.error && !r.skipped);
|
||
if (filesToProcess.length === 1 && successfulResult && successfulResult.file) {
|
||
const recordId = `${successfulResult.file.name}_${successfulResult.file.size}`;
|
||
window.location.href = `views/history/history_detail.html?id=${encodeURIComponent(recordId)}`;
|
||
} else {
|
||
window.location.href = 'history.html';
|
||
}
|
||
|
||
allResults = allResults.filter(r => r !== undefined && r !== null);
|
||
console.log("Final results count:", allResults.length);
|
||
}
|
||
}
|
||
|
||
// =====================
|
||
// 下载处理
|
||
// =====================
|
||
/**
|
||
* 处理点击"下载全部结果"按钮的事件。
|
||
* 如果 `allResults` 数组中有内容,则调用 `downloadAllResults` (来自 ui.js) 来打包并下载结果。
|
||
*/
|
||
function handleDownloadClick() {
|
||
if (allResults.length > 0) {
|
||
downloadAllResults(allResults);
|
||
} else {
|
||
showNotification('没有可下载的结果', 'warning');
|
||
}
|
||
}
|
||
|
||
// =====================
|
||
// 仅阅读(不做处理直接跳转)
|
||
// =====================
|
||
/**
|
||
* 处理点击"仅阅读"按钮的事件。
|
||
* 如果已有处理结果,直接跳转历史详情。
|
||
* 如果没有处理结果但有上传文件,则将文件保存到历史记录后跳转。
|
||
*/
|
||
async function handleReadClick() {
|
||
// 1. 优先检查是否有已处理的结果
|
||
if (allResults.length > 0) {
|
||
const successfulResult = allResults.find(r => r && r.file && !r.error && !r.skipped);
|
||
if (successfulResult && successfulResult.file) {
|
||
const recordId = `${successfulResult.file.name}_${successfulResult.file.size}`;
|
||
window.location.href = `views/history/history_detail.html?id=${encodeURIComponent(recordId)}`;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 2. 检查是否有上传的文件,直接保存到历史记录
|
||
if (pdfFiles.length > 0) {
|
||
const file = pdfFiles[0]; // 只处理第一个文件
|
||
const recordId = `${file.name}_${file.size}`;
|
||
|
||
try {
|
||
// 读取文件内容为 base64
|
||
const arrayBuffer = await file.arrayBuffer();
|
||
const base64Content = arrayBufferToBase64(arrayBuffer);
|
||
|
||
// 获取文件扩展名和类型
|
||
const ext = file.name.split('.').pop().toLowerCase();
|
||
const fileType = ext;
|
||
|
||
// 创建历史记录对象
|
||
const record = {
|
||
id: recordId,
|
||
name: file.name,
|
||
size: file.size,
|
||
time: new Date().toISOString(),
|
||
ocr: '',
|
||
translation: '',
|
||
images: [],
|
||
ocrChunks: [],
|
||
translatedChunks: [],
|
||
fileType: fileType,
|
||
targetLanguage: '',
|
||
originalEncoding: 'binary',
|
||
originalBinary: base64Content,
|
||
originalExtension: ext,
|
||
ocrEngine: null,
|
||
ocrSource: null,
|
||
translationModelName: 'none',
|
||
batchId: null,
|
||
batchOrder: null,
|
||
batchTotal: null,
|
||
batchTemplate: null,
|
||
batchFormats: null,
|
||
batchStartedAt: null,
|
||
// 对于 PDF 文件,将 base64 也存入 metadata.originalPdfBase64
|
||
// 以便历史详情页面能够正确显示PDF
|
||
metadata: fileType === 'pdf' ? { originalPdfBase64: base64Content } : null
|
||
};
|
||
|
||
console.log('[仅阅读] 保存记录:', {
|
||
id: recordId,
|
||
fileType,
|
||
hasMetadata: !!record.metadata,
|
||
hasOriginalPdfBase64: !!(record.metadata && record.metadata.originalPdfBase64)
|
||
});
|
||
|
||
// 保存到数据库
|
||
let savedDoc = record;
|
||
if (typeof window.storageAdapter !== 'undefined' && typeof window.storageAdapter.saveResultToDB === 'function') {
|
||
const result = await window.storageAdapter.saveResultToDB(record);
|
||
// 后端模式:使用返回的 UUID
|
||
if (result && result.id) {
|
||
savedDoc = result;
|
||
console.log('[仅阅读] 后端返回的 UUID:', result.id);
|
||
}
|
||
} else if (typeof saveResultToDB === 'function') {
|
||
await saveResultToDB(record);
|
||
} else {
|
||
showNotification('存储功能不可用', 'error');
|
||
return;
|
||
}
|
||
|
||
// 添加短暂延迟确保 IndexedDB 事务完全提交
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
console.log('[仅阅读] 数据已保存,准备跳转...');
|
||
|
||
// 跳转到历史详情页面(使用保存后的 ID)
|
||
window.location.href = `views/history/history_detail.html?id=${encodeURIComponent(savedDoc.id)}`;
|
||
} catch (error) {
|
||
console.error('保存文件到历史记录失败:', error);
|
||
showNotification(`保存失败: ${error.message}`, 'error');
|
||
}
|
||
} else {
|
||
showNotification('请先上传文件', 'warning');
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// =====================
|
||
// 内置提示模板获取
|
||
// =====================
|
||
/**
|
||
* 根据指定的目标语言名称,获取内置的翻译系统提示和用户提示模板。
|
||
* @param {string} languageName - 目标语言的名称 (例如 'chinese', 'english', 'japanese', 或自定义语言名)。
|
||
* @returns {{systemPrompt: string, userPromptTemplate: string}} 包含系统提示和用户提示模板的对象。
|
||
*/
|
||
function getBuiltInPrompts(languageName) {
|
||
const langLower = languageName.toLowerCase();
|
||
let sys_prompt = '';
|
||
let user_prompt_template = '';
|
||
const sourceLang = 'English'; // Assume source is always English
|
||
|
||
switch (langLower) {
|
||
case 'chinese':
|
||
sys_prompt = "你是一个专业的文档翻译助手,擅长将文本精确翻译为简体中文,同时保留原始的 Markdown 格式。";
|
||
user_prompt_template = `请将以下内容翻译为 **简体中文**。\n要求:\n\n1. 保持所有 Markdown 语法元素不变(如 # 标题、 *斜体*、 **粗体**、 [链接]()、 ![图片]()、 \`\`\`代码块\`\`\` 等)。\n2. 代码块必须保持原样,不要翻译代码块内的内容,保留代码块的语言标记(如 \`\`\`python, \`\`\`javascript 等)。\n3. 学术/专业术语应准确翻译。\n4. 保持原文的段落结构和格式。\n5. 仅输出翻译后的内容,不要包含任何额外的解释或注释。\n6. 对于行间公式,使用 $$...$$ 标记。\n\n请主动区分该内容是行间公式还是行内公式,使用准确的公式标记。输出$$,$$$$的公式时候:前后需要带空格或换行,公式内部不需要带空格。\n\n文档内容:\n\n\${content}`;
|
||
break;
|
||
case 'japanese':
|
||
sys_prompt = "あなたはプロの文書翻訳アシスタントで、テキストを正確に日本語に翻訳し、元の Markdown 形式を維持することに長けています。";
|
||
user_prompt_template = `以下の内容を **日本語** に翻訳してください。\n要件:\n\n1. すべての Markdown 構文要素(例: # 見出し、 *イタリック*、 **太字**、 [リンク]()、 ![画像]()、 \`\`\`コードブロック\`\`\` など)は変更しないでください。\n2. コードブロックはそのまま保持し、コードブロック内の内容は翻訳しないでください。言語指定(例: \`\`\`python, \`\`\`javascript など)も保持してください。\n3. 学術/専門用語は正確に翻訳してください。\n4. 元の段落構造と書式を維持してください。\n5. 翻訳された内容のみを出力し、余分な説明や注釈は含めないでください。\n6. 表示数式には $$...$$ を使用してください。\n\n数式がディスプレイ数式(行間)かインライン数式かを必ず区別し、正しい数式記号を使用してください。$$や$$$$の数式を出力する際は、前後にスペースまたは改行を入れ、数式内部にはスペースを入れないでください。\n\nドキュメント内容:\n\n\${content}`;
|
||
break;
|
||
case 'korean':
|
||
sys_prompt = "당신은 전문 문서 번역 도우미로, 텍스트를 정확하게 한국어로 번역하고 원본 마크다운 형식을 유지하는 데 능숙합니다.";
|
||
user_prompt_template = `다음 내용을 **한국어** 로 번역해 주세요。\n요구 사항:\n\n1. 모든 마크다운 구문 요소(예: # 제목, *기울임꼴*, **굵게**, [링크](), ![이미지](), \`\`\`코드 블록\`\`\` 등)를 변경하지 마십시오.\n2. 코드 블록은 그대로 유지하고, 코드 블록 내의 내용은 번역하지 마십시오. 언어 지정(예: \`\`\`python, \`\`\`javascript 등)도 유지하십시오.\n3. 학술/전문 용어는 정확하게 번역하십시오.\n4. 원본 단락 구조와 서식을 유지하십시오.\n5. 번역된 내용만 출력하고 추가 설명이나 주석을 포함하지 마십시오.\n6. 수식 표시는 $$...$$ 를 사용하십시오。\n\n수식이 디스플레이 수식(행간)인지 인라인 수식인지 반드시 구분하고, 정확한 수식 표기법을 사용하세요. $$, $$$$ 수식을 출력할 때는 앞뒤에 공백 또는 줄바꿈을 넣고, 수식 내부에는 공백을 넣지 마세요.\n\n문서 내용:\n\n\${content}`;
|
||
break;
|
||
case 'french':
|
||
sys_prompt = "Vous êtes un assistant de traduction de documents professionnel, compétent pour traduire avec précision le texte en français tout en préservant le format Markdown d'origine.";
|
||
user_prompt_template = `Veuillez traduire le contenu suivant en **Français**。\nExigences:\n\n1. Conserver tous les éléments de syntaxe Markdown inchangés (par exemple, # titres, *italique*, **gras**, [liens](), ![images](), \`\`\`blocs de code\`\`\`).\n2. Les blocs de code doivent rester intacts, ne traduisez pas le contenu à l'intérieur des blocs de code. Conservez les spécifications de langage (par exemple, \`\`\`python, \`\`\`javascript, etc.).\n3. Traduire avec précision les termes académiques/professionnels.\n4. Maintenir la structure et le formatage des paragraphes d'origine.\n5. Produire uniquement le contenu traduit, sans explications ni annotations supplémentaires.\n6. Pour les formules mathématiques, utiliser \\$\$...\$\$.\n\nVeuillez distinguer explicitement entre les formules en ligne et les formules en display, et utilisez la notation appropriée. Lors de la sortie des formules $$ ou $$$$, ajoutez un espace ou un saut de ligne avant et après, sans espace à l'intérieur de la formule.\n\nContenu du document:\n\n\${content}`;
|
||
break;
|
||
case 'english':
|
||
sys_prompt = "You are a professional document translation assistant, skilled at accurately translating text into English while preserving the original document format.";
|
||
user_prompt_template = `Please translate the following content into **English**.\n Requirements:\n\n 1. Keep all Markdown syntax elements unchanged (e.g., #headings, *italics*, **bold**, [links](), ![images](), \`\`\`code blocks\`\`\`).\n 2. Code blocks must remain intact. Do not translate the content inside code blocks. Preserve language specifications (e.g., \`\`\`python, \`\`\`javascript, etc.).\n 3. Translate academic/professional terms accurately. Maintain a formal, academic tone.\n 4. Maintain the original paragraph structure and formatting.\n 5. Output only the translated content.\n 6. For display math formulas, use:\n \\$\$\n ...\n \\$\$\n\nPlease explicitly distinguish between display (block) and inline formulas, and use the correct formula markers. When outputting formulas with $$ or $$$$, add a space or line break before and after, and do not add spaces inside the formula.\n\n Document Content:\n\n \${content}`;
|
||
break;
|
||
default: // Fallback for custom languages or other cases
|
||
const targetLangDisplayName = languageName; // Use the passed name directly
|
||
sys_prompt = `You are a professional document translation assistant, skilled at accurately translating content into ${targetLangDisplayName} while preserving the original document format.`;
|
||
user_prompt_template = `Please translate the following content into **${targetLangDisplayName}**. \nRequirements:\n\n1. Keep all Markdown syntax elements unchanged (e.g., #headings, *italics*, **bold**, [links](), ![images](), \`\`\`code blocks\`\`\`).\n2. Code blocks must remain intact. Do not translate the content inside code blocks. Preserve language specifications (e.g., \`\`\`python, \`\`\`javascript, etc.).\n3. Translate academic/professional terms accurately. If necessary, keep the original term in parentheses if unsure about the translation in ${targetLangDisplayName}.\n4. Maintain the original paragraph structure and formatting.\n5. Translate only the content; do not add extra explanations.\n6. For display math formulas, use:\n\$\$\n...\n\$\$\n\nPlease explicitly distinguish between display (block) and inline formulas, and use the correct formula markers. When outputting formulas with $$ or $$$$, add a space or line break before and after, and do not add spaces inside the formula.\n\nDocument Content:\n\n\${content}`;
|
||
break;
|
||
} // End of switch
|
||
//console.log('getBuiltInPrompts 返回:', {systemPrompt: sys_prompt, userPromptTemplate: user_prompt_template});
|
||
return { systemPrompt: sys_prompt, userPromptTemplate: user_prompt_template };
|
||
} // End of getBuiltInPrompts function
|
||
|
||
// =====================
|
||
// 提示区内容与状态联动
|
||
// =====================
|
||
// updatePromptTextareasContent函数已移除,因为新的提示词系统通过promptPoolUI处理
|
||
|
||
// =====================
|
||
// 其他协调逻辑
|
||
// =====================
|
||
// ...(如有其他 app.js 级别的协调逻辑,可在此补充)...
|
||
|
||
/**
|
||
* 更新本地存储中指定模型最后成功使用的Key ID。
|
||
* @param {string} modelName - 模型名称 (例如 'mistral', 'gemini', 或 'custom_source_xxx')。
|
||
* @param {string} keyId - 成功使用的 API Key 的 ID。
|
||
*/
|
||
function recordLastSuccessfulKey(modelName, keyId) {
|
||
if (!modelName || !keyId) return;
|
||
try {
|
||
let records = JSON.parse(localStorage.getItem(LAST_SUCCESSFUL_KEYS_LS_KEY) || '{}');
|
||
records[modelName] = keyId;
|
||
localStorage.setItem(LAST_SUCCESSFUL_KEYS_LS_KEY, JSON.stringify(records));
|
||
} catch (e) {
|
||
console.error('Failed to record last successful key:', e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取本地存储中指定模型最后成功使用的Key ID。
|
||
* @param {string} modelName - 模型名称。
|
||
* @returns {string | null} 存储的 Key ID,如果未找到则返回 null。
|
||
*/
|
||
function getLastSuccessfulKeyId(modelName) {
|
||
if (!modelName) return null;
|
||
try {
|
||
const records = JSON.parse(localStorage.getItem(LAST_SUCCESSFUL_KEYS_LS_KEY) || '{}');
|
||
return records[modelName] || null;
|
||
} catch (e) {
|
||
console.error('Failed to get last successful key ID:', e);
|
||
return null;
|
||
}
|
||
}
|