paper-burner/js/app.js

2356 lines
108 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// 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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[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. 根据是否需要OCRPDF文件和用户选择的翻译模型初始化 `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
};
// 保存到数据库
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;
}
// 跳转到历史详情页面
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;
}
}