// 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} * @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} * @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} 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} * @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} * @description 存储从localStorage加载的原始key对象数组。 * 每个对象结构: {id: string, value: string, remark: string, status: string, order: number} */ this.keys = []; /** * @type {Array} * @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} 当成功获取槽位时 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 = ''; 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 = '暂无文件'; 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(` `); }); // fragments.push('
'); container.innerHTML = `
${fragments.join('')}
`; } 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} - 下载的文件对象 */ 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} - 文件对象 */ 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} - 文件对象 */ 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 = 'mistral'; try { if (window.ocrSettingsManager && typeof window.ocrSettingsManager.getCurrentConfig === 'function') { ocrEngine = window.ocrSettingsManager.getCurrentConfig().engine || 'mistral'; 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) }); // 保存到数据库 if (typeof window.storageAdapter !== 'undefined' && typeof window.storageAdapter.saveResultToDB === 'function') { await window.storageAdapter.saveResultToDB(record); } else if (typeof saveResultToDB === 'function') { await saveResultToDB(record); } else { showNotification('存储功能不可用', 'error'); return; } // 添加短暂延迟确保 IndexedDB 事务完全提交 await new Promise(resolve => setTimeout(resolve, 100)); console.log('[仅阅读] 数据已保存,准备跳转...'); // 跳转到历史详情页面 window.location.href = `views/history/history_detail.html?id=${encodeURIComponent(recordId)}`; } 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; } }