paper-burner/js/process/document.js

878 lines
44 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.

// process/document.js
/**
* 将长文档分割为可翻译的块。
* 此函数旨在将 Markdown 文本按照指定的 token 限制进行智能分块,
* 以便适应大语言模型处理上下文长度的限制。
*
* 主要策略:
* 1. **Token 估算与初步判断**
* - 使用 `estimateTokenCount` 估算整个文档的 token 数。
* - 如果文档未超过 token 限制的 1.1 倍,则不进行分割,直接返回原文作为一个块。
* 2. **行级初步分割**
* - 遍历文本的每一行。
* - 跟踪当前块的 token 数和行内容。
* - 智能分割点选择:
* - 当 `currentTokenCount + lineTokens > tokenLimit` 且当前块已有一定内容 (`currentTokenCount > tokenLimit * 0.1`) 时,进行分割。
* - 在非代码块内,如果遇到一级或二级 Markdown 标题 (`#` 或 `##`),并且当前块内容已超过限制的 50%,则在此标题前分割,以保持章节完整性。
* - 维护 `inCodeBlock` 状态,避免在代码块内部错误地根据标题分割。
* 3. **二次段落级分割(针对超大块)**
* - 对初步分割产生的每个块进行检查。
* - 如果某个块的 token 数仍然超过限制的 1.1 倍,则调用 `splitByParagraphs` 对其进行更细致的段落级分割。
* 4. **日志记录**:在关键步骤通过 `addProgressLog` (如果可用) 输出日志,方便追踪分割过程。
*
* @param {string} markdown - 要分割的Markdown文本。
* @param {number} tokenLimit - 每块的最大token数。
* @param {string} [logContext=""] - 日志前缀,用于区分不同上下文的日志输出。
* @returns {Array<string>} 分割后的文本块数组。
*/
function splitMarkdownIntoChunks(markdown, tokenLimit, logContext = "") {
const estimatedTokens = estimateTokenCount(markdown);
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 估算总 token 数: ~${estimatedTokens}, 分段限制: ${tokenLimit}`);
}
if (estimatedTokens <= tokenLimit * 1.1) {
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 文档未超过大小限制,不进行分割。`);
}
return [markdown];
}
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 文档超过大小限制,开始分割...`);
}
const lines = markdown.split('\n');
const chunks = [];
let currentChunkLines = [];
let currentTokenCount = 0;
let inCodeBlock = false;
const headingRegex = /^(#+)\s+.*/;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineTokens = estimateTokenCount(line);
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock;
}
let shouldSplit = false;
if (currentChunkLines.length > 0) {
if (currentTokenCount + lineTokens > tokenLimit) {
if (currentTokenCount > tokenLimit * 0.1) {
shouldSplit = true;
}
}
else if (!inCodeBlock && headingRegex.test(line)) {
const match = line.match(headingRegex);
if (match && match[1].length <= 2 && currentTokenCount > tokenLimit * 0.5) {
shouldSplit = true;
}
}
}
if (shouldSplit) {
chunks.push(currentChunkLines.join('\n'));
currentChunkLines = [];
currentTokenCount = 0;
}
currentChunkLines.push(line);
currentTokenCount += lineTokens;
}
if (currentChunkLines.length > 0) {
chunks.push(currentChunkLines.join('\n'));
}
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 初始分割为 ${chunks.length} 个片段.`);
}
const finalChunks = [];
for(let j = 0; j < chunks.length; j++) {
const chunk = chunks[j];
const chunkTokens = estimateTokenCount(chunk);
if (chunkTokens > tokenLimit * 1.1) {
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 警告: 第 ${j+1} 段 (${chunkTokens} tokens) 仍然超过限制 ${tokenLimit}. 尝试段落分割.`);
}
const subChunks = splitByParagraphs(chunk, tokenLimit, logContext, j+1);
finalChunks.push(...subChunks);
} else {
finalChunks.push(chunk);
}
}
if (finalChunks.length !== chunks.length && typeof addProgressLog === "function") {
addProgressLog(`${logContext} 二次分割后总片段数: ${finalChunks.length}`);
}
return finalChunks;
}
/**
* 按段落分割过大的文本块。
* 当 `splitMarkdownIntoChunks` 初步分割后,某些块可能仍然过大,
* 此函数尝试将这些超大块按照 Markdown 的段落(空行分隔)进一步细分。
*
* 主要逻辑:
* 1. **段落分割**:使用 `text.split('\n\n')` 将文本块分割成段落数组。
* 2. **逐段累加与分割**
* - 遍历每个段落。
* - 估算段落的 token 数。
* - 如果单个段落本身就超过 `tokenLimit * 1.1`,则直接将其作为一个独立的块(不再细分),并记录警告。
* - 否则,将段落加入当前子块,并累加 token 数。
* - 如果加入当前段落会导致子块超过 `tokenLimit`,并且子块中已有内容,则先将当前子块保存,然后开始新的子块。
* 3. **日志记录**:记录段落分割的过程和结果。
*
* @param {string} text - 需要按段落分割的文本块。
* @param {number} tokenLimit - 每块的最大token数。
* @param {string} logContext - 日志前缀。
* @param {number} chunkIndex - 当前块在原始分割结果中的索引 (用于日志)。
* @returns {Array<string>} 分割后的子块数组。
*/
function splitByParagraphs(text, tokenLimit, logContext, chunkIndex) {
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 对第 ${chunkIndex} 段进行段落分割...`);
}
const paragraphs = text.split('\n\n');
const chunks = [];
let currentChunkLines = [];
let currentTokenCount = 0;
for (const paragraph of paragraphs) {
const paragraphTokens = estimateTokenCount(paragraph);
if (paragraphTokens > tokenLimit * 1.1) {
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 警告: 第 ${chunkIndex} 段中的段落 (${paragraphTokens} tokens) 超过限制 ${tokenLimit}. 将尝试按原样处理.`);
}
if (currentChunkLines.length > 0) {
chunks.push(currentChunkLines.join('\n\n'));
}
chunks.push(paragraph); // Keep the large paragraph as a single chunk
currentChunkLines = [];
currentTokenCount = 0;
continue;
}
if (currentTokenCount + paragraphTokens > tokenLimit && currentChunkLines.length > 0) {
chunks.push(currentChunkLines.join('\n\n'));
currentChunkLines = [];
currentTokenCount = 0;
}
currentChunkLines.push(paragraph);
currentTokenCount += paragraphTokens;
}
if (currentChunkLines.length > 0) {
chunks.push(currentChunkLines.join('\n\n'));
}
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext}${chunkIndex} 段分割为 ${chunks.length} 个子段.`);
}
return chunks;
}
/**
* 翻译长文档,支持分段、表格保护、并发控制和自定义模型配置。
*
* 核心流程:
* 1. **参数准备与 Token 限制**
* - 确保 `tokenLimitInput` 被正确解析为数字。
* 2. **表格保护**
* - 调用 `protectMarkdownTables` (如果可用) 将 Markdown 中的表格替换为占位符 (如 `__TABLE_PLACEHOLDER_0__`)
* 并将原始表格内容存储在 `tablePlaceholders` 对象中。这可以防止翻译API破坏表格结构。
* - 如果检测到表格,更新系统提示 `updatedSystemPrompt`,告知模型如何处理这些占位符。
* 3. **文本分块**
* - 使用 `splitMarkdownIntoChunks` 将经过表格保护处理的文本 (`processedText`) 分割成 `originalTextChunks`。
* 4. **API 配置构建**
* - 根据 `model` 参数是 'custom' 还是预定义模型,调用 `buildCustomApiConfig` 或 `buildPredefinedApiConfig` 来准备 API 请求所需的配置对象 (`apiConfig`)。
* - 对于自定义模型,会从 `modelConfig` 参数中获取详细配置如端点、模型ID、请求格式等
* 5. **创建翻译任务队列 (`allTranslationTasks`)**
* - 将所有文本块的翻译任务添加到队列中。
* - 如果有受保护的表格,将每个表格的翻译也作为一个独立的任务添加到队列中。
* 6. **并发翻译与重试**
* - 遍历 `allTranslationTasks`,为每个任务创建一个异步翻译 Promise。
* - 使用 `acquireSlot` 和 `releaseSlot` 控制并发翻译的数量。
* - 对每个任务执行翻译:
* - **文本块翻译**:调用 `translateMarkdown` (并根据模型类型传递必要的参数,如 `modelConfig` for custom)。
* - **表格翻译**:构造特定的系统提示和用户提示,指导模型仅翻译表格内容并保持结构,然后调用 `callTranslationApi`。翻译结果会经过 `extractTableFromTranslation` 清理。
* - 实现重试机制 (最多 `MAX_TRANSLATION_RETRIES` 次),使用 `getRetryDelay` 计算退避延迟。
* - 如果任务在多次重试后仍然失败,则记录错误,并将原文(或原始表格内容)作为翻译结果的兜底。
* - 将所有任务的翻译结果(成功或失败的兜底)存储在 `translationResults` Map 中,键为 `text-{index}` 或 `table-{index}`。
* 7. **等待所有任务完成**:使用 `Promise.all` 等待所有翻译 Promise 执行完毕。
* 8. **结果组装与表格还原**
* - **构建翻译后表格映射**:从 `translationResults` 中提取已翻译的表格内容,存入 `translatedTablePlaceholders`。
* - **还原分块中的表格**
* - 对 `originalTextChunks` 中的每个块,使用 `restoreMarkdownTables` 和原始 `tablePlaceholders` 还原其包含的原始表格,得到 `restoredOcrChunks`。
* - 对 `translationResults` 中每个文本块的翻译结果,使用 `restoreMarkdownTables` 和 `translatedTablePlaceholders` 还原其包含的已翻译表格,得到 `translatedTextChunks`。
* - **合并翻译文本**:将 `translatedTextChunks` 连接起来得到 `combinedTranslation`。
* - **最终表格还原**:为保险起见,再次对 `combinedTranslation` 使用 `translatedTablePlaceholders` 进行一次整体的表格占位符替换。
* 9. **返回结果**:返回一个对象,包含最终的完整翻译文本 `translatedText`,以及还原了表格的原文分块 `originalChunks` 和译文分块 `translatedTextChunks`。
*
* @param {string} markdownText - 待翻译的Markdown文本。
* @param {string} targetLang - 目标语言代码 (如 'zh-CN', 'en')。
* @param {string} model - 使用的翻译模型名称 (如 'mistral', 'custom')。
* @param {string} apiKey - 对应翻译模型的 API 密钥。
* @param {Object | null} modelConfig - 当 `model` 为 "custom" 时,提供自定义模型的配置对象,
* 包含 `apiEndpoint` (或 `apiBaseUrl`), `modelId`, `requestFormat`, `temperature`, `max_tokens` 等。
* @param {number | string} tokenLimitInput - 每个翻译分块的最大 token 限制。
* @param {function} acquireSlot - 用于获取并发执行槽位的函数。
* @param {function} releaseSlot - 用于释放并发执行槽位的函数。
* @param {string} [logContext=""] - 日志记录的上下文前缀。
* @param {string} [defaultSystemPrompt=""] - 默认的系统提示词。
* @param {string} [defaultUserPromptTemplate=""] - 默认的用户提示词模板 (应包含 `${content}` 和 `${targetLangName}` 占位符)。
* @param {boolean} [useCustomPrompts=false] - 是否使用自定义的提示词(如果为 false则使用内置或默认提示词
* @returns {Promise<Object>} 一个包含翻译结果的对象,结构为:
* `{ translatedText: string, originalChunks: Array<string>, translatedTextChunks: Array<string> }`。
* `originalChunks` 和 `translatedTextChunks` 是经过表格还原处理后的分块数组。
* @throws {Error} 如果自定义模型配置不完整或发生其他严重错误。
*/
async function translateLongDocument(
markdownText,
targetLang,
model,
apiKey,
modelConfig, // 新增参数
tokenLimitInput,
acquireSlot,
releaseSlot,
logContext = "",
defaultSystemPrompt = "",
defaultUserPromptTemplate = "",
useCustomPrompts = false
) {
console.log('translateLongDocument: apiKey', apiKey);
const tokenLimit = parseInt(tokenLimitInput, 10) || 2000; // 确保是数字,提供默认值
// 先进行表格保护处理
let processedText = markdownText;
let tablePlaceholders = {};
let hasProtectedTables = false;
if (typeof protectMarkdownTables === 'function') {
const processed = protectMarkdownTables(markdownText);
processedText = processed.processedText;
tablePlaceholders = processed.tablePlaceholders;
hasProtectedTables = Object.keys(tablePlaceholders).length > 0;
if (hasProtectedTables) {
console.log(`${logContext} 长文档中检测到 ${Object.keys(tablePlaceholders).length} 个表格,已进行特殊保护`);
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 长文档翻译: 已保护 ${Object.keys(tablePlaceholders).length} 个表格结构,将作为整体处理`);
}
}
}
// 增加表格处理提示到系统提示中
let updatedSystemPrompt = defaultSystemPrompt;
if (hasProtectedTables) {
updatedSystemPrompt = defaultSystemPrompt + "\n\n注意文档中的表格已被特殊标记为占位符如__TABLE_PLACEHOLDER_0__请直接翻译占位符以外的内容保持占位符不变。表格将在后续步骤中单独处理。";
}
// 继续原有的分块处理逻辑 - 使用处理后的文本
const originalTextChunks = splitMarkdownIntoChunks(processedText, tokenLimit, logContext);
console.log(`${logContext} 文档分割为 ${originalTextChunks.length} 部分进行翻译 (Limit: ${tokenLimit})`);
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 文档被分割为 ${originalTextChunks.length} 部分进行翻译`);
}
// 准备API配置用于文本和表格翻译
let apiConfig;
if (model === "custom") {
// 兼容 apiEndpoint 和 apiBaseUrl
const endpoint = modelConfig.apiEndpoint || modelConfig.apiBaseUrl;
if (!modelConfig || !endpoint || !modelConfig.modelId) {
throw new Error('Custom model configuration is incomplete for translateLongDocument. API Endpoint (或 apiBaseUrl) and Model ID are required.');
}
apiConfig = buildCustomApiConfig(
apiKey,
endpoint, // 兼容 apiEndpoint 和 apiBaseUrl
modelConfig.modelId, // 使用传入的 modelConfig
modelConfig.requestFormat, // 使用传入的 modelConfig
modelConfig.temperature,
modelConfig.max_tokens,
{
endpointMode: modelConfig.endpointMode || 'auto'
}
);
} else {
// 预设模型
const settingsForModels = typeof loadSettings === 'function' ? loadSettings() : {};
const customModelSettings = settingsForModels && settingsForModels.customModelSettings ? settingsForModels.customModelSettings : {};
let temperature = 0.5;
if (customModelSettings.temperature !== undefined && customModelSettings.temperature !== null && customModelSettings.temperature !== '') {
const parsedTemp = parseFloat(customModelSettings.temperature);
if (!Number.isNaN(parsedTemp)) {
temperature = parsedTemp;
}
}
let maxTokens = 8000;
if (customModelSettings.max_tokens !== undefined && customModelSettings.max_tokens !== null && customModelSettings.max_tokens !== '') {
const parsedMax = parseInt(customModelSettings.max_tokens, 10);
if (!Number.isNaN(parsedMax) && parsedMax > 0) {
maxTokens = parsedMax;
}
}
let geminiEndpointDynamic = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
try {
if (typeof loadModelConfig === 'function') {
const gcfg = loadModelConfig('gemini');
const preferred = gcfg && (gcfg.preferredModelId || gcfg.modelId);
if (preferred && typeof preferred === 'string' && preferred.trim()) {
geminiEndpointDynamic = `https://generativelanguage.googleapis.com/v1beta/models/${preferred.trim()}:generateContent`;
}
}
} catch (e) {
console.warn('加载 Gemini 配置失败,将在长文档翻译中使用默认模型。', e);
}
let deeplxEndpointTemplate = 'https://api.deeplx.org/<api-key>/translate';
try {
if (typeof loadModelConfig === 'function') {
const dlcfg = loadModelConfig('deeplx');
if (dlcfg) {
if (dlcfg.endpointTemplate && typeof dlcfg.endpointTemplate === 'string') {
deeplxEndpointTemplate = dlcfg.endpointTemplate.trim() || deeplxEndpointTemplate;
} else if (dlcfg.apiBaseUrlTemplate && typeof dlcfg.apiBaseUrlTemplate === 'string') {
deeplxEndpointTemplate = dlcfg.apiBaseUrlTemplate.trim() || deeplxEndpointTemplate;
} else if (dlcfg.apiBaseUrl && typeof dlcfg.apiBaseUrl === 'string') {
const base = dlcfg.apiBaseUrl.trim();
if (base) {
deeplxEndpointTemplate = base.endsWith('/') ? `${base}<api-key>/translate` : `${base}/<api-key>/translate`;
}
}
}
}
} catch (e) {
console.warn('加载 DeepLX 配置失败,将在长文档翻译中使用默认模板。', e);
}
const predefinedConfigs = {
"mistral": {
endpoint: "https://api.mistral.ai/v1/chat/completions",
modelName: "mistral-large-latest",
headers: { "Content-Type": "application/json" },
bodyBuilder: (sys, user) => ({
model: "mistral-large-latest",
messages: [
{ role: "system", content: sys },
{ role: "user", content: user }
],
temperature: temperature,
max_tokens: maxTokens
}),
responseExtractor: (data) => data?.choices?.[0]?.message?.content
},
"deepseek": {
endpoint: "https://api.deepseek.com/v1/chat/completions",
modelName: "DeepSeek",
headers: { "Content-Type": "application/json" },
bodyBuilder: (sys, user) => {
let modelId = 'deepseek-chat';
try { const cfg = loadModelConfig && loadModelConfig('deepseek'); if (cfg && (cfg.preferredModelId || cfg.modelId)) modelId = cfg.preferredModelId || cfg.modelId; } catch (_) {}
return {
model: modelId,
messages: [
{ role: "system", content: sys },
{ role: "user", content: user }
],
temperature: temperature,
max_tokens: maxTokens
};
},
responseExtractor: (data) => data?.choices?.[0]?.message?.content
},
"gemini": {
endpoint: geminiEndpointDynamic,
modelName: "Google Gemini",
headers: { "Content-Type": "application/json" },
bodyBuilder: (sys, user) => ({
contents: [
{
role: "user",
parts: [{ text: `${sys}\n\n${user}` }]
}
],
generationConfig: {
temperature: temperature,
maxOutputTokens: maxTokens
}
}),
responseExtractor: (data) => {
if (data?.candidates && data.candidates.length > 0 && data.candidates[0].content) {
const parts = data.candidates[0].content.parts;
return parts && parts.length > 0 ? parts[0].text : '';
}
return '';
}
},
"gemini-preview": {
endpoint: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent",
modelName: "Google gemini-2.5-flash-preview-05-20",
headers: { "Content-Type": "application/json" },
bodyBuilder: (sys, user) => ({
contents: [
{
role: "user",
parts: [{ text: `${sys}\n\n${user}` }]
}
],
generationConfig: {
temperature: temperature,
maxOutputTokens: maxTokens,
responseModalities: ["TEXT"],
responseMimeType: "text/plain"
}
}),
responseExtractor: (data) => {
if (data?.candidates && data.candidates.length > 0 && data.candidates[0].content) {
const parts = data.candidates[0].content.parts;
return parts && parts.length > 0 ? parts[0].text : '';
}
return '';
}
},
"tongyi": {
endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
modelName: "通义百炼",
headers: { "Content-Type": "application/json" },
bodyBuilder: (sys, user) => {
let modelId = 'qwen-turbo-latest';
try { const cfg = loadModelConfig && loadModelConfig('tongyi'); if (cfg && (cfg.preferredModelId || cfg.modelId)) modelId = cfg.preferredModelId || cfg.modelId; } catch (_) {}
const isQwenMT = typeof modelId === 'string' && modelId.toLowerCase().includes('qwen-mt');
const mergedContent = isQwenMT ? `${sys}\n\n${user}`.trim() : null;
return {
model: modelId,
messages: isQwenMT
? [
{ role: "user", content: mergedContent }
]
: [
{ role: "system", content: sys },
{ role: "user", content: user }
],
temperature: temperature,
max_tokens: maxTokens,
enable_thinking: false
};
},
responseExtractor: (data) => data?.choices?.[0]?.message?.content
},
"volcano": {
endpoint: "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
modelName: "火山引擎",
headers: { "Content-Type": "application/json" },
bodyBuilder: (sys, user) => {
let modelId = 'doubao-1-5-pro-32k-250115';
try { const cfg = loadModelConfig && loadModelConfig('volcano'); if (cfg && (cfg.preferredModelId || cfg.modelId)) modelId = cfg.preferredModelId || cfg.modelId; } catch (_) {}
return {
model: modelId,
messages: [
{ role: "system", content: sys },
{ role: "user", content: user }
],
temperature: temperature,
max_tokens: Math.min(maxTokens, 16384)
};
},
responseExtractor: (data) => data?.choices?.[0]?.message?.content
},
"deeplx": {
endpoint: deeplxEndpointTemplate,
modelName: "DeepLX",
headers: { "Content-Type": "application/json" },
bodyBuilder: (sys, user, ctx = {}) => {
const payload = {
text: ctx && ctx.processedText ? ctx.processedText : user
};
const targetLangCode = (typeof mapToDeeplxLangCode === 'function')
? mapToDeeplxLangCode(ctx && ctx.targetLang ? ctx.targetLang : undefined)
: undefined;
if (targetLangCode) {
payload.target_lang = targetLangCode;
}
if (ctx && ctx.sourceLang) {
const src = (typeof mapToDeeplxLangCode === 'function') ? mapToDeeplxLangCode(ctx.sourceLang) : undefined;
if (src) payload.source_lang = src;
}
return payload;
},
responseExtractor: (data) => {
if (!data) return '';
if (typeof data === 'string') return data;
if (typeof data.text === 'string') return data.text;
if (data.data) {
if (typeof data.data === 'string') return data.data;
if (typeof data.data.text === 'string') return data.data.text;
}
if (Array.isArray(data.translations) && data.translations.length > 0) {
const first = data.translations[0];
if (typeof first === 'string') return first;
if (first && typeof first.text === 'string') return first.text;
}
if (Array.isArray(data.alternatives) && data.alternatives.length > 0) {
const alt = data.alternatives[0];
if (typeof alt === 'string') return alt;
if (alt && typeof alt.text === 'string') return alt.text;
}
if (typeof data.result === 'string') return data.result;
if (data.result && typeof data.result.text === 'string') return data.result.text;
if (typeof data.translation === 'string') return data.translation;
return null;
}
}
};
if (!predefinedConfigs[model]) {
throw new Error(`暂不支持模型 ${model} 的长文档处理。`);
}
apiConfig = buildPredefinedApiConfig(predefinedConfigs[model], apiKey);
}
// 创建所有翻译任务的统一队列(包括文本块和表格)
const allTranslationTasks = [];
// 添加所有文本块翻译任务
originalTextChunks.forEach((part, i) => {
allTranslationTasks.push({
type: 'text',
index: i,
content: part,
context: `${logContext} (Part ${i+1}/${originalTextChunks.length})`
});
});
// 添加所有表格翻译任务(如果有)
if (hasProtectedTables) {
let tableIndex = 0;
for (const [placeholder, tableContent] of Object.entries(tablePlaceholders)) {
allTranslationTasks.push({
type: 'table',
index: tableIndex,
placeholder: placeholder,
content: tableContent,
context: `${logContext} (Table ${tableIndex+1}/${Object.keys(tablePlaceholders).length})`
});
tableIndex++;
}
}
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 总计待翻译任务: ${allTranslationTasks.length} (文本块: ${originalTextChunks.length}, 表格: ${hasProtectedTables ? Object.keys(tablePlaceholders).length : 0})`);
}
let hasErrors = false;
const MAX_TRANSLATION_RETRIES = 3;
const translationResults = new Map(); // 使用Map存储翻译结果
// 为所有任务创建翻译Promise
const translationPromises = allTranslationTasks.map(async (task) => {
const taskLogContext = task.context;
let lastError = null;
for (let attempt = 0; attempt <= MAX_TRANSLATION_RETRIES; attempt++) {
const attemptNum = attempt + 1;
if (typeof addProgressLog === "function") {
addProgressLog(`${taskLogContext} 排队等待翻译槽 (尝试 ${attemptNum})...`);
}
await acquireSlot();
if (typeof addProgressLog === "function") {
addProgressLog(`${taskLogContext} 翻译槽已获取。开始翻译 ${task.type === 'table' ? '表格' : '文本'} (尝试 ${attemptNum})...`);
}
try {
let result;
if (task.type === 'text') {
// Step 1: 为任务预绑定提示词并入队,便于失败时做“真正队列替换”
let boundPrompt = null;
let requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2,8)}_t${task.index}`;
try {
if (typeof window !== 'undefined' && window.promptPoolUI && typeof window.promptPoolUI.getPromptForTranslation === 'function') {
const p = window.promptPoolUI.getPromptForTranslation();
// 仅当池模式返回 id/system/user 时认为可用
if (p && p.id && p.systemPrompt && p.userPromptTemplate) {
boundPrompt = p;
if (typeof window.translationPromptPool !== 'undefined' && typeof window.translationPromptPool.enqueueRequest === 'function') {
window.translationPromptPool.enqueueRequest(p.id, { requestId, model: (apiConfig && apiConfig.modelName) || model });
}
}
}
} catch (e) {
console.warn('[PromptPool] 预绑定提示词失败(跳过绑定):', e);
}
// 翻译文本块
//console.log('document.js 调用 translateMarkdown 参数:', {
// useCustomPrompts,
// defaultUserPromptTemplate,
// defaultSystemPrompt,
// modelConfig,
// content: task.content,
// targetLang,
// model,
// apiKey,
// taskLogContext
//});
if (model === 'custom') {
result = await translateMarkdown(
task.content,
targetLang,
model,
apiKey,
modelConfig,
taskLogContext,
updatedSystemPrompt,
defaultUserPromptTemplate,
useCustomPrompts,
false,
{ boundPrompt, requestId }
);
} else {
result = await translateMarkdown(
task.content,
targetLang,
model,
apiKey,
taskLogContext,
updatedSystemPrompt,
defaultUserPromptTemplate,
useCustomPrompts,
false,
{ boundPrompt, requestId }
);
}
//console.log('document.js translateMarkdown 返回:', result);
translationResults.set('text-' + task.index, result);
} else if (task.type === 'table') {
// 翻译表格
let tableSystemPrompt = `你是一个精确翻译表格的助手。请将表格翻译成${targetLang},严格保持以下格式要求:
1. 保持所有表格分隔符(|)和结构完全不变
2. 保持表格对齐标记(:--:、:--、--:)不变
3. 保持表格的行数和列数完全一致
4. 保持数学公式、符号和百分比等专业内容不变
5. 翻译表格标题(如有)和表格内的文本内容
6. 表格内容与表格外内容要明确区分`;
// 注入术语库(如启用且有命中)
try {
const settingsForGlossary = (typeof loadSettings === 'function') ? loadSettings() : {};
const glossaryEnabled = !!settingsForGlossary.enableGlossary;
if (glossaryEnabled && typeof getGlossaryMatchesForText === 'function') {
const matches = getGlossaryMatchesForText(task.content);
if (matches && matches.length > 0 && typeof buildGlossaryInstruction === 'function') {
const instr = buildGlossaryInstruction(matches, targetLang);
if (instr) {
tableSystemPrompt = tableSystemPrompt + "\n\n" + instr;
if (typeof addProgressLog === 'function') {
const names = matches.slice(0, 6).map(m => m.term).join(', ');
addProgressLog(`${taskLogContext} [表格] 命中备择库 ${matches.length} 条:${names}${matches.length>6?'...':''}`);
}
}
}
}
} catch (e) {
console.warn('Glossary injection for table skipped due to error:', e);
}
// 用户提示词
const tableUserPrompt = `请将以下Markdown表格翻译成${targetLang},请确保完全保持表格结构和格式:
${task.content}
注意:请保持表格格式完全不变,包括所有的 | 符号、对齐标记、数学公式和符号。`;
// 构建请求体
const requestBody = apiConfig.bodyBuilder
? apiConfig.bodyBuilder(tableSystemPrompt, tableUserPrompt, {
processedText: task.content,
rawText: task.content,
targetLang,
tablePlaceholder: task.placeholder,
requestType: 'table'
})
: {
model: apiConfig.modelName,
messages: [
{ role: "system", content: tableSystemPrompt },
{ role: "user", content: tableUserPrompt }
]
};
// 调用API翻译表格
const translatedTable = await callTranslationApi(apiConfig, requestBody);
// 提取和清理翻译结果中的表格部分
const cleanedTable = typeof extractTableFromTranslation === 'function' ?
(extractTableFromTranslation(translatedTable) || task.content) :
task.content;
translationResults.set('table-' + task.index, {
placeholder: task.placeholder,
translatedContent: cleanedTable
});
}
if (typeof releaseSlot === "function") {
releaseSlot();
}
if (typeof addProgressLog === "function") {
addProgressLog(`${taskLogContext} 翻译槽已释放 (成功)。`);
}
return; // 成功,退出重试循环
} catch (error) {
// 释放翻译槽
if (typeof releaseSlot === "function") {
releaseSlot();
}
if (typeof addProgressLog === "function") {
addProgressLog(`${taskLogContext} 翻译槽已释放 (失败)。`);
}
lastError = error;
console.error(`${taskLogContext} 翻译失败 (尝试 ${attemptNum}/${MAX_TRANSLATION_RETRIES + 1}):`, error);
if (typeof addProgressLog === "function") {
addProgressLog(`${taskLogContext} 警告: 翻译失败 (尝试 ${attemptNum}/${MAX_TRANSLATION_RETRIES + 1}) - ${error.message}.`);
}
if (attempt < MAX_TRANSLATION_RETRIES) {
const delay = typeof getRetryDelay === 'function' ?
getRetryDelay(attempt) :
Math.min(1000 * Math.pow(2, attempt), 30000);
if (typeof addProgressLog === "function") {
addProgressLog(`${taskLogContext} ${delay.toFixed(0)}ms 后重试...`);
}
await new Promise(resolve => setTimeout(resolve, delay));
} else {
if (typeof addProgressLog === "function") {
addProgressLog(`${taskLogContext} 已达最大重试次数 (${MAX_TRANSLATION_RETRIES + 1}次尝试),使用原文。`);
}
hasErrors = true;
// 保存原始内容作为结果
if (task.type === 'text') {
translationResults.set('text-' + task.index,
`\n\n> **[翻译错误 (重试 ${MAX_TRANSLATION_RETRIES + 1} 次失败) - 保留原文 Part ${task.index+1}]**\n\n${task.content}\n\n`);
} else if (task.type === 'table') {
translationResults.set('table-' + task.index, {
placeholder: task.placeholder,
translatedContent: task.content
});
}
return; // 结束重试
}
}
}
console.error(`${taskLogContext} Unexpected state reached after retry loop.`);
if (typeof addProgressLog === "function") {
addProgressLog(`${taskLogContext} 警告: 翻译重试逻辑结束后状态意外,保留原文。`);
}
hasErrors = true;
// 安全兜底,保存原始内容
if (task.type === 'text') {
translationResults.set('text-' + task.index,
`\n\n> **[翻译意外失败 - 保留原文 Part ${task.index+1}]**\n\n${task.content}\n\n`);
} else if (task.type === 'table') {
translationResults.set('table-' + task.index, {
placeholder: task.placeholder,
translatedContent: task.content
});
}
});
// 等待所有并发翻译任务完成
try {
await Promise.all(translationPromises);
} catch (error) {
console.error(`${logContext} An unexpected error occurred during Promise.all for translations:`, error);
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 错误: 并发翻译过程中出现意外错误。`);
}
hasErrors = true;
}
if (hasErrors) {
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 部分或全部翻译任务处理失败 (已完成重试)。`);
}
} else {
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 所有翻译任务处理完成。`);
}
}
// 构建翻译后表格占位符映射
let translatedTablePlaceholders = {};
if (hasProtectedTables) {
for (let i = 0; i < Object.keys(tablePlaceholders).length; i++) {
const tableResult = translationResults.get('table-' + i);
if (tableResult && tableResult.placeholder) {
translatedTablePlaceholders[tableResult.placeholder] = tableResult.translatedContent;
}
}
}
// 收集所有文本块的翻译结果(原文和译文都做表格还原)
const restoredOcrChunks = [];
const translatedTextChunks = [];
for (let i = 0; i < originalTextChunks.length; i++) {
let ocrChunk = originalTextChunks[i];
let translatedChunk = translationResults.get('text-' + i);
// 原文分块还原原文表格
if (hasProtectedTables && typeof restoreMarkdownTables === 'function') {
ocrChunk = await restoreMarkdownTables(ocrChunk, tablePlaceholders);
}
// 译文分块还原翻译后表格
if (hasProtectedTables && typeof restoreMarkdownTables === 'function') {
translatedChunk = await restoreMarkdownTables(translatedChunk, translatedTablePlaceholders);
}
restoredOcrChunks.push(ocrChunk);
translatedTextChunks.push(translatedChunk || originalTextChunks[i]);
}
// 合并已翻译的块
let combinedTranslation = translatedTextChunks.join('\n\n');
// 如果有表格,替换所有表格占位符(保险起见,整体再替换一遍)
if (hasProtectedTables) {
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 正在替换表格占位符...`);
}
for (let i = 0; i < Object.keys(translatedTablePlaceholders).length; i++) {
const placeholder = Object.keys(translatedTablePlaceholders)[i];
const translatedContent = translatedTablePlaceholders[placeholder];
combinedTranslation = combinedTranslation.replace(
placeholder,
translatedContent
);
}
if (typeof addProgressLog === "function") {
addProgressLog(`${logContext} 表格占位符替换完成。`);
}
}
return {
translatedText: combinedTranslation,
originalChunks: restoredOcrChunks, // 现在是还原了表格的原文分块
translatedTextChunks: translatedTextChunks // 现在是还原了表格的译文分块
};
}
// 将函数添加到processModule对象
if (typeof processModule !== 'undefined') {
processModule.splitMarkdownIntoChunks = splitMarkdownIntoChunks;
processModule.splitByParagraphs = splitByParagraphs;
processModule.translateLongDocument = translateLongDocument;
}