878 lines
44 KiB
JavaScript
878 lines
44 KiB
JavaScript
// 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;
|
||
}
|