paper-burner/js/process/translation.js

980 lines
49 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/translation.js
// 辅助函数:构建预定义 API 配置
/**
* 为预定义的翻译模型构建 API 请求配置。
* 此函数接收一个基础的 API 配置对象 (`apiConfig`) 和 API 密钥 (`key`)
* 然后根据模型名称(从 `apiConfig.modelName` 中提取并转换为小写)来设置特定的认证头部。
*
* 主要逻辑:
* 1. **配置浅拷贝**:创建 `apiConfig` 和 `apiConfig.headers` 的浅拷贝,以避免修改原始对象。
* 2. **认证头部设置**
* - 将 `config.modelName` 转为小写进行比较。
* - 如果模型名称包含 "claude",则在头部设置 `x-api-key`。
* - 如果模型名称包含 "gemini",则将 API 密钥作为查询参数 `key` 追加到端点 URL。
* 它会正确处理端点 URL 中可能已存在的查询参数。
* - 对于其他模型(如 Mistral, DeepSeek 等),默认在头部设置 `Authorization: Bearer {key}`。
* 3. **返回配置**:返回更新后的配置对象。
*
* @param {Object} apiConfig - 预定义模型的初始配置对象,通常包含 `endpoint`, `modelName`, `headers`, `bodyBuilder`, `responseExtractor`。
* @param {string} key - 用于认证的 API 密钥。
* @returns {Object} 添加了认证头部(或更新了端点)的完整 API 配置对象。
*/
function buildPredefinedApiConfig(apiConfig, key) {
const config = { ...apiConfig }; // 浅拷贝
config.headers = { ...config.headers }; // 浅拷贝 headers
// 设置认证 - 添加防御性检查避免在modelName为undefined时调用toLowerCase()
const modelNameLower = config.modelName ? config.modelName.toLowerCase() : '';
if (modelNameLower.includes('claude')) {
config.headers['x-api-key'] = key;
} else if (modelNameLower.includes('gemini')) {
// Correctly handle potential existing query parameters
let baseUrl = config.endpoint.split('?')[0];
config.endpoint = `${baseUrl}?key=${key}`;
} else if (modelNameLower.includes('deeplx')) {
const encodedKey = encodeURIComponent(key.trim());
const placeholderPatterns = ['{API_KEY}', '{api_key}', '{apiKey}', '{key}', '__API_KEY__', '<api-key>', '<API_KEY>', ':API_KEY', ':api_key', ':key', '${API_KEY}', '${api_key}'];
let endpoint = config.endpoint || '';
let replaced = false;
placeholderPatterns.forEach(function(pattern) {
if (endpoint.includes(pattern)) {
endpoint = endpoint.replace(new RegExp(pattern, 'g'), encodedKey);
replaced = true;
}
});
if (!replaced) {
// 如果模板中未包含占位符,则尝试在末尾追加 Key
if (!endpoint.endsWith('/')) endpoint += '/';
endpoint += encodedKey;
}
config.endpoint = endpoint;
// DeepLX 接口通常不需要 Authorization 头,确保移除可能的残留
if (config.headers['Authorization']) delete config.headers['Authorization'];
} else {
config.headers['Authorization'] = `Bearer ${key}`;
}
return config;
}
function buildInstructionBlock(content) {
const trimmed = (content || '').trim();
if (!trimmed) return '';
return `[[PBX_INSTR_START]]
${trimmed}
[[PBX_INSTR_END]]
`;
}
function stripInstructionBlocks(text) {
if (typeof text !== 'string') return text;
return text.replace(/\s*\[\[PBX_INSTR_START\]\][\s\S]*?\[\[PBX_INSTR_END\]\]\s*/gi, '').trim();
}
function joinUrlSegments(base, segment) {
if (!base) return segment || '';
if (!segment) return base;
const hasTrailingSlash = base.endsWith('/');
const hasLeadingSlash = segment.startsWith('/');
if (hasTrailingSlash && hasLeadingSlash) {
return base + segment.slice(1);
}
if (!hasTrailingSlash && !hasLeadingSlash) {
return `${base}/${segment}`;
}
return base + segment;
}
function appendQueryParam(urlString, param, value) {
try {
const url = new URL(urlString);
url.searchParams.set(param, value);
return url.toString();
} catch (error) {
const sanitized = urlString.replace(new RegExp(`([?&])${param}=[^&]*`, 'i'), '$1').replace(/[?&]$/, '');
const separator = sanitized.includes('?') ? '&' : '?';
return `${sanitized}${separator}${encodeURIComponent(param)}=${encodeURIComponent(value)}`;
}
}
function extractGeminiModelId(pathname) {
if (typeof pathname !== 'string') return null;
const match = pathname.match(/\/models\/([^/:]+)(?::generatecontent)?$/i);
return match ? match[1] : null;
}
function normalizeGeminiEndpoint(baseUrlInput, modelIdInput, requestFormat) {
if (!baseUrlInput || typeof baseUrlInput !== 'string') {
throw new Error('自定义 Gemini 模型需要提供有效的 API 地址');
}
let url;
try {
url = new URL(baseUrlInput.trim());
} catch (error) {
try {
url = new URL(`https://${baseUrlInput.trim()}`);
} catch (_) {
throw new Error('Gemini API Base URL 必须包含协议,例如 https://generativelanguage.googleapis.com');
}
}
url.searchParams.delete('key');
let path = url.pathname || '';
if (path.length > 1 && path.endsWith('/')) {
path = path.slice(0, -1);
}
const defaultModelId = requestFormat === 'gemini-preview'
? 'gemini-2.5-flash-preview-05-20'
: 'gemini-2.0-flash';
const inferredModelId = extractGeminiModelId(path);
const resolvedModelId = (modelIdInput && modelIdInput.trim()) || inferredModelId || defaultModelId;
const lowerPath = (path || '').toLowerCase();
const endsWithGenerate = /:generatecontent$/i.test(lowerPath);
if (!path || path === '/') {
path = `/v1beta/models/${resolvedModelId}:generateContent`;
} else if (/\/models\/$/i.test(path)) {
path = `${path}${resolvedModelId}:generateContent`;
} else if (/\/models$/i.test(path)) {
path = `${path}/${resolvedModelId}:generateContent`;
} else if (/\/models\/[^/]+$/i.test(path)) {
path = path.replace(/\/models\/[^/]+$/i, `/models/${resolvedModelId}`);
if (!endsWithGenerate) {
path = `${path}:generateContent`;
}
} else if (/\/v1beta$/i.test(path) || /\/v1$/i.test(path)) {
path = `${path}/models/${resolvedModelId}:generateContent`;
} else if (!endsWithGenerate) {
path = `${path}/v1beta/models/${resolvedModelId}:generateContent`;
}
if (!/:generatecontent$/i.test(path)) {
path = `${path}:generateContent`;
}
url.pathname = path;
url.search = '';
return {
endpoint: url.toString(),
modelName: resolvedModelId
};
}
function normalizeOpenAIEndpoint(baseApiUrlInput, format, endpointMode = 'auto', targetSegment) {
if (!baseApiUrlInput || typeof baseApiUrlInput !== 'string') {
throw new Error('自定义模型需要提供 API Base URL');
}
const trimmed = baseApiUrlInput.trim();
if (!trimmed) {
throw new Error('自定义模型需要提供 API Base URL');
}
const mode = endpointMode || 'auto';
const normalizedSegment = (targetSegment
? targetSegment
: (format === 'anthropic' ? 'messages' : 'chat/completions'))
.replace(/^\/+/, '');
const lower = trimmed.toLowerCase();
const base = trimmed.replace(/\/+$/, '');
const v1Segment = normalizedSegment.startsWith('v1/') ? normalizedSegment : `v1/${normalizedSegment}`;
const terminalPaths = [
normalizedSegment,
`/${normalizedSegment}`,
v1Segment,
`/${v1Segment}`
];
if (terminalPaths.some(path => lower.endsWith(path))) {
return base;
}
if (mode === 'manual') {
return base;
}
if (mode === 'chat') {
return joinUrlSegments(base, normalizedSegment);
}
if (/\/v1$/.test(lower)) {
return joinUrlSegments(base, normalizedSegment);
}
return joinUrlSegments(base, v1Segment);
}
const DEEPLX_LANG_CODE_MAP = {
'bulgarian': 'BG', 'bg': 'BG', 'български': 'BG', '保加利亚语': 'BG',
'chinese': 'ZH', 'zh': 'ZH', 'zh-cn': 'ZH', 'zh_cn': 'ZH', '中文': 'ZH', '中文(简体)': 'ZH', '简体中文': 'ZH', 'traditional chinese': 'ZH', 'zh-tw': 'ZH', 'zh_tw': 'ZH', '中文(繁体)': 'ZH', '繁体中文': 'ZH',
'czech': 'CS', 'cs': 'CS', 'čeština': 'CS', '捷克语': 'CS',
'danish': 'DA', 'da': 'DA', 'dansk': 'DA', '丹麦语': 'DA',
'dutch': 'NL', 'nl': 'NL', 'nederlands': 'NL', '荷兰语': 'NL',
'english': 'EN', 'en': 'EN', 'english (uk)': 'EN', 'english (gb)': 'EN', 'en-gb': 'EN', 'english (us)': 'EN', 'en-us': 'EN', 'english (american)': 'EN', '英语': 'EN',
'estonian': 'ET', 'et': 'ET', 'eesti': 'ET', '爱沙尼亚语': 'ET',
'finnish': 'FI', 'fi': 'FI', 'suomi': 'FI', '芬兰语': 'FI',
'french': 'FR', 'fr': 'FR', 'français': 'FR', '法语': 'FR',
'german': 'DE', 'de': 'DE', 'deutsch': 'DE', '德语': 'DE',
'greek': 'EL', 'el': 'EL', 'ελληνικά': 'EL', '希腊语': 'EL',
'hungarian': 'HU', 'hu': 'HU', 'magyar': 'HU', '匈牙利语': 'HU',
'italian': 'IT', 'it': 'IT', 'italiano': 'IT', '意大利语': 'IT',
'japanese': 'JA', 'ja': 'JA', '日本語': 'JA', '日语': 'JA',
'latvian': 'LV', 'lv': 'LV', 'latviešu': 'LV', '拉脱维亚语': 'LV',
'lithuanian': 'LT', 'lt': 'LT', 'lietuvių': 'LT', '立陶宛语': 'LT',
'polish': 'PL', 'pl': 'PL', 'polski': 'PL', '波兰语': 'PL',
'portuguese': 'PT', 'pt': 'PT', 'português': 'PT', 'portuguese (portugal)': 'PT', '葡萄牙语': 'PT', '葡萄牙语(葡萄牙)': 'PT', '葡萄牙语(巴西)': 'PT', 'portuguese (brazil)': 'PT', 'pt-br': 'PT',
'romanian': 'RO', 'ro': 'RO', 'română': 'RO', '罗马尼亚语': 'RO',
'russian': 'RU', 'ru': 'RU', 'русский': 'RU', '俄语': 'RU'
};
const DEEPLX_LANG_DISPLAY = {
'BG': { zh: '保加利亚语', native: 'Български' },
'ZH': { zh: '中文', native: '中文' },
'CS': { zh: '捷克语', native: 'Česky' },
'DA': { zh: '丹麦语', native: 'Dansk' },
'NL': { zh: '荷兰语', native: 'Nederlands' },
'EN': { zh: '英语', native: 'English' },
'ET': { zh: '爱沙尼亚语', native: 'Eesti' },
'FI': { zh: '芬兰语', native: 'Suomi' },
'FR': { zh: '法语', native: 'Français' },
'DE': { zh: '德语', native: 'Deutsch' },
'EL': { zh: '希腊语', native: 'Ελληνικά' },
'HU': { zh: '匈牙利语', native: 'Magyar' },
'IT': { zh: '意大利语', native: 'Italiano' },
'JA': { zh: '日语', native: '日本語' },
'LV': { zh: '拉脱维亚语', native: 'Latviešu' },
'LT': { zh: '立陶宛语', native: 'Lietuvių' },
'PL': { zh: '波兰语', native: 'Polski' },
'PT': { zh: '葡萄牙语', native: 'Português' },
'RO': { zh: '罗马尼亚语', native: 'Română' },
'RU': { zh: '俄语', native: 'Русский' }
};
function mapToDeeplxLangCode(targetLang) {
if (!targetLang) return undefined;
const normalized = String(targetLang).trim();
if (!normalized) return undefined;
const lower = normalized.toLowerCase();
if (DEEPLX_LANG_CODE_MAP[lower]) {
return DEEPLX_LANG_CODE_MAP[lower];
}
if (/^[a-z]{2}$/i.test(normalized)) {
return normalized.toUpperCase();
}
if (/^[a-z]{2}-[a-z]{2}$/i.test(normalized)) {
return normalized.toUpperCase();
}
return undefined;
}
if (typeof window !== 'undefined') {
window.mapToDeeplxLangCode = mapToDeeplxLangCode;
window.DEEPLX_LANG_CODE_MAP = DEEPLX_LANG_CODE_MAP;
window.DEEPLX_LANG_DISPLAY = DEEPLX_LANG_DISPLAY;
if (typeof window.updateDeeplxTargetLangHint === 'function') {
try { window.updateDeeplxTargetLangHint(); } catch (e) { console.warn('updateDeeplxTargetLangHint failed', e); }
}
}
// 辅助函数:构建自定义 API 配置
/**
* 为用户自定义的翻译模型构建 API 请求配置(翻译模块专用,避免与聊天模块冲突)。
* 此函数根据用户提供的基础 URL、模型 ID、请求格式、密钥以及可选的温度和最大 token 数,
* 生成一个完整的、可用于调用自定义翻译 API 的配置对象。
*
* 主要逻辑:
* 1. **格式归一化**:根据 `customRequestFormat` 统一为小写格式,默认视为 `openai`。
* 2. **端点规范化**
* - 对于 OpenAI 兼容(含默认)与 Anthropic 格式,使用 `normalizeOpenAIEndpoint` 根据格式智能拼接 `/v1/chat/completions` 或 `/v1/messages` 等常用后缀,避免重复追加。
* - 对于 Gemini (`gemini` / `gemini-preview`),通过 `normalizeGeminiEndpoint` 解析或自动补全 `/v1beta/models/{modelId}:generateContent` 路径,并移除可能残留的 `key` 查询参数。
* - Gemini 端点会在后续步骤中附加 `?key={API Key}` 查询参数。
* 3. **配置对象初始化**:创建包含 `endpoint`, `modelName`, `headers`, `bodyBuilder`, `responseExtractor` 的配置。
* 4. **按格式生成请求构造器与响应解析器**
* - `openai`:使用 Bearer Token 认证,构建传统聊天补全请求体,并从 `choices[0].message.content` 中提取结果。
* - `anthropic`:设置 `x-api-key` 与 `anthropic-version` 头部,构建 Claude 兼容消息体,从 `content[0].text` 中提取结果。
* - `gemini` / `gemini-preview`:将系统提示与用户提示合并到 Gemini `contents` 结构,配置 `generationConfig`,并从 `candidates[0].content.parts[0].text` 中提取结果(预览版本额外指定 `responseModalities`)。
* - 其他未知格式:回退到 OpenAI 兼容请求结构并发出警告。
* 5. **返回配置**:最终返回可直接用于请求翻译 API 的配置对象。
*
* @param {string} key - API 密钥。
* @param {string} baseApiUrlInput - 用户提供的 API 基础 URL (例如 `https://api.example.com` 或 `https://api.gemini.example/v1beta/models/gemini-pro:generateContent`)。
* @param {string} customModelId - 用户指定的模型 ID (例如 `gpt-3.5-turbo`, `claude-2`, `gemini-pro`)。
* @param {string} customRequestFormat - 请求体和响应体的格式类型 (如 'openai', 'anthropic', 'gemini')。
* @param {number} [temperature] - (可选) 模型生成时的温度参数。
* @param {number} [max_tokens] - (可选) 模型生成的最大 token 数。
* @returns {Object} 构建好的 API 配置对象,包含 `endpoint`, `modelName`, `headers`, `bodyBuilder`, `responseExtractor`。
*/
const buildCustomApiConfigForTranslation = function(key, baseApiUrlInput, customModelId, customRequestFormat, temperature, max_tokens, options = {}) {
const format = (customRequestFormat || 'openai').toLowerCase();
let effectiveModelId = (customModelId && customModelId.trim()) || '';
const endpointMode = options.endpointMode || 'auto';
let finalApiEndpoint;
if (format === 'gemini' || format === 'gemini-preview') {
const geminiInfo = normalizeGeminiEndpoint(baseApiUrlInput, effectiveModelId, format);
finalApiEndpoint = appendQueryParam(geminiInfo.endpoint, 'key', key);
effectiveModelId = geminiInfo.modelName;
}
else {
finalApiEndpoint = normalizeOpenAIEndpoint(baseApiUrlInput, format, endpointMode);
}
const config = {
endpoint: finalApiEndpoint,
modelName: effectiveModelId,
headers: { 'Content-Type': 'application/json' },
bodyBuilder: null,
responseExtractor: null
};
const temperatureValue = temperature ?? 0.5;
const maxTokensValue = max_tokens ?? 8000;
const modelToUse = effectiveModelId || customModelId;
switch (format) {
case 'openai':
config.headers['Authorization'] = `Bearer ${key}`;
config.bodyBuilder = (sys_prompt, user_prompt) => ({
model: modelToUse,
messages: [{ role: "system", content: sys_prompt }, { role: "user", content: user_prompt }],
temperature: temperatureValue,
max_tokens: maxTokensValue
});
config.responseExtractor = (data) => data?.choices?.[0]?.message?.content;
break;
case 'anthropic':
config.headers['x-api-key'] = key;
config.headers['anthropic-version'] = '2023-06-01';
config.bodyBuilder = (sys_prompt, user_prompt) => ({
model: modelToUse,
system: sys_prompt,
messages: [{ role: "user", content: user_prompt }],
temperature: temperatureValue,
max_tokens: maxTokensValue
});
config.responseExtractor = (data) => data?.content?.[0]?.text;
break;
case 'gemini':
case 'gemini-preview':
config.modelName = effectiveModelId;
config.headers = { 'Content-Type': 'application/json' };
const geminiMaxTokens = max_tokens ?? 8192;
config.bodyBuilder = (sys_prompt, user_prompt) => ({
contents: [{ role: "user", parts: [{ text: `${sys_prompt}\n\n${user_prompt}` }] }],
generationConfig: {
temperature: temperatureValue,
maxOutputTokens: geminiMaxTokens,
...(format === 'gemini-preview' ? { responseModalities: ["TEXT"], responseMimeType: 'text/plain' } : {})
}
});
config.responseExtractor = (data) => data?.candidates?.[0]?.content?.parts?.[0]?.text;
break;
default:
config.headers['Authorization'] = `Bearer ${key}`;
config.bodyBuilder = (sys_prompt, user_prompt) => ({
model: modelToUse,
messages: [{ role: "system", content: sys_prompt }, { role: "user", content: user_prompt }],
temperature: temperatureValue,
max_tokens: maxTokensValue
});
config.responseExtractor = (data) => data?.choices?.[0]?.message?.content;
console.warn(`Unsupported custom request format: ${customRequestFormat}. Defaulting to OpenAI-like structure.`);
break;
}
return config;
}
/**
* 翻译单个 Markdown 文本块,支持预定义模型和自定义模型,并可选择性处理内嵌的表格占位符。
*
* 主要步骤:
* 1. **参数处理与兼容性**
* - 由于函数签名在支持自定义模型配置 (`modelConfig`) 时变得复杂,通过检查 `arguments` 来正确解析传入的参数,
* 特别是当 `model` 为 "custom" 时,`modelConfigForCustom` 从 `arguments[4]` 获取,后续参数依次顺延。
* 2. **表格预处理** (如果 `actualProcessTablePlaceholders` 为 true 且 `protectMarkdownTables` 函数可用)
* - 调用 `protectMarkdownTables` 将 Markdown 中的表格替换为占位符 (如 `__TABLE_PLACEHOLDER_0__`)。
* - 存储原始表格内容在 `tablePlaceholders` 中。
* - 如果检测到表格,则在系统提示中追加说明,告知模型如何处理这些占位符(即保持不变)。
* 3. **构建 Prompt**
* - 初始化 `systemPrompt` 和 `userPrompt`。
* - 如果使用了表格保护,则向 `systemPrompt` 追加关于如何处理表格占位符的指示。
* - 如果未使用自定义提示 (`!actualUseCustomPrompts`) 或者自定义提示为空,则调用 `getBuiltInPrompts` (如果可用) 获取内置的针对目标语言的提示模板,否则使用非常基础的兜底提示。
* - **替换模板变量**:在最终的 `userPrompt` 中,将 `${targetLangName}` 替换为实际的目标语言名称,将 `${content}` 替换为经过表格预处理的文本 (`processedText`)。
* - *警告检查*:如果最终的 `userPrompt` 未包含 `processedText`,则打印警告,因为模型可能无法接收到待翻译内容。
* 4. **构建 API 配置 (`apiConfig`)**
* - 如果 `model` 是 "custom"
* - 检查 `modelConfigForCustom` 是否有效(包含端点和模型 ID
* - 调用 `buildCustomApiConfig` 生成配置。
* - 否则(预定义模型):
* - 从全局设置 (`loadSettings`) 中获取温度和最大 token 数等参数。
* - 定义一个包含各预设模型(如 'deepseek', 'gemini', 'mistral', 'tongyi-...', 'volcano-...')详细配置的 `predefinedConfigs` 对象。
* 每个模型的配置包括 `endpoint`, `modelName`, `headers`, `bodyBuilder`, `responseExtractor`。
* - 检查选定的 `model` 是否在 `predefinedConfigs` 中,如果不在则抛出错误。
* - 调用 `buildPredefinedApiConfig` 生成配置。
* 5. **构建请求体 (`requestBody`)**
* - 使用 `apiConfig.bodyBuilder` (如果存在) 并传入 `systemPrompt` 和 `userPrompt` 来构建请求体。
* - 如果 `bodyBuilder` 不存在,则构建一个通用的包含 `model` 和 `messages` (system + user) 的请求体。
* 6. **调用翻译 API**
* - 调用 `callTranslationApi` (应为实际的 fetch 调用封装) 并传入 `apiConfig` 和 `requestBody`,获取翻译结果 `result`。
* 7. **表格后处理** (如果之前进行了表格保护且 `actualProcessTablePlaceholders` 为 true 且 `extractTableFromTranslation` 函数可用)
* - **逐个翻译表格内容**
* - 遍历 `tablePlaceholders` 中的每个原始表格。
* - 为每个表格构建特定的翻译提示(强调保持结构,仅翻译文本)。
* - 再次调用 `callTranslationApi` 翻译该表格。
* - 使用 `extractTableFromTranslation` 从翻译结果中提取纯净的表格 Markdown。
* - 在主翻译结果 `finalResult` (初始为 `result`) 中,用翻译后的表格替换其占位符。
* - 如果表格翻译或提取失败,则用原始表格替换占位符作为兜底。
* - 返回包含已翻译并恢复表格的 `finalResult`。
* 8. **直接返回结果**:如果未进行表格处理,则直接返回步骤 6 中得到的 `result`。
*
* @param {string} markdown - 待翻译的 Markdown 文本块。
* @param {string} targetLang - 目标翻译语言代码 (如 'zh-CN', 'en')。
* @param {string} model - 使用的翻译模型名称 (如 'mistral', 'custom', 'deepseek')。
* @param {string} apiKey - 对应翻译模型的 API 密钥。
* @param {string} [logContext=""] - (或 `modelConfig` 当 `model`='custom') 日志记录的上下文前缀。如果 `model` 为 "custom",此位置应为 `modelConfig` 对象,后续参数顺延。
* @param {string} [defaultSystemPrompt=""] - (顺延参数) 翻译时使用的默认系统提示词。
* @param {string} [defaultUserPromptTemplate=""] - (顺延参数) 翻译时使用的默认用户提示词模板 (应包含 `${content}` 和 `${targetLangName}` 占位符)。
* @param {boolean} [useCustomPrompts=false] - (顺延参数) 是否使用用户自定义的提示词。
* @param {boolean} [processTablePlaceholders=true] - (顺延参数) 是否对文本中的 Markdown 表格进行占位符保护和独立翻译处理。
* @returns {Promise<string>} 翻译后的 Markdown 文本块。如果处理了表格,则表格内容也会被翻译并恢复到文本中。
* @throws {Error} 如果模型名称不支持、自定义模型配置不完整或在API调用过程中发生不可恢复的错误。
*/
async function translateMarkdown(
markdown,
targetLang,
model,
apiKey,
logContext = "",
defaultSystemPrompt = "",
defaultUserPromptTemplate = "",
useCustomPrompts = false,
processTablePlaceholders = true,
options = {}
) {
//console.log('translateMarkdown 分块内容:', markdown);
let actualLogContext = logContext;
let actualDefaultSystemPrompt = defaultSystemPrompt;
let actualDefaultUserPromptTemplate = defaultUserPromptTemplate;
let actualUseCustomPrompts = useCustomPrompts;
let actualProcessTablePlaceholders = processTablePlaceholders;
let modelConfigForCustom = null;
if (model === "custom") {
modelConfigForCustom = arguments[4]; // 这是从调用处传来的 modelConfig
actualLogContext = arguments[5] !== undefined ? arguments[5] : "";
actualDefaultSystemPrompt = arguments[6] !== undefined ? arguments[6] : "";
actualDefaultUserPromptTemplate = arguments[7] !== undefined ? arguments[7] : "";
actualUseCustomPrompts = arguments[8] !== undefined ? arguments[8] : false;
actualProcessTablePlaceholders = arguments[9] !== undefined ? arguments[9] : true;
options = arguments[10] !== undefined ? arguments[10] : {};
}
// 表格预处理 - 仅当需要处理表格时
let processedText = markdown;
let tablePlaceholders = {};
let hasProtectedTables = false;
if (actualProcessTablePlaceholders && typeof protectMarkdownTables === 'function') {
const processed = protectMarkdownTables(markdown);
processedText = processed.processedText;
tablePlaceholders = processed.tablePlaceholders;
hasProtectedTables = Object.keys(tablePlaceholders).length > 0;
if (hasProtectedTables) {
console.log(`${actualLogContext} 检测到 ${Object.keys(tablePlaceholders).length} 个表格,已进行特殊保护`);
if (typeof addProgressLog === "function") {
addProgressLog(`${actualLogContext} 检测到 ${Object.keys(tablePlaceholders).length} 个表格,将作为整体处理`);
}
}
}
// 构建 prompt - 集成提示词池支持
let systemPrompt = actualDefaultSystemPrompt;
let userPrompt = actualDefaultUserPromptTemplate;
// 增加表格处理提示
let tableHandlingNote = "";
if (hasProtectedTables) {
tableHandlingNote = "\n\n注意文档中的表格已被特殊标记为占位符如__TABLE_PLACEHOLDER_0__请直接翻译占位符以外的内容保持占位符不变。表格将在后续步骤中单独处理。";
}
// 检查是否应该使用提示词池(支持外部绑定覆盖)
let usePromptPool = false;
let promptFromPool = null;
// 尝试从提示词池UI获取提示词如果可用
if (!options.boundPrompt && typeof window !== 'undefined' && window.promptPoolUI) {
const poolPrompt = window.promptPoolUI.getPromptForTranslation();
if (poolPrompt) {
// 仅当提示词非空时才启用提示词池
const sys = (poolPrompt.systemPrompt || '').trim();
const usr = (poolPrompt.userPromptTemplate || '').trim();
if (sys && usr) {
promptFromPool = poolPrompt;
usePromptPool = true;
}
}
}
// 如果调用方传入 boundPrompt则优先使用
if (options.boundPrompt && options.boundPrompt.id && options.boundPrompt.systemPrompt && options.boundPrompt.userPromptTemplate) {
promptFromPool = options.boundPrompt;
usePromptPool = true;
}
// 根据提示词来源设置提示词
if (usePromptPool && promptFromPool) {
// 使用提示词池的提示词(再次防御非空)
const sys = (promptFromPool.systemPrompt || '').trim();
const usr = (promptFromPool.userPromptTemplate || '').trim();
if (sys && usr) {
systemPrompt = sys + tableHandlingNote;
userPrompt = usr;
console.log(`[翻译] 使用提示词池的提示词`);
} else {
usePromptPool = false; // 回退
}
}
if (!usePromptPool) {
if (!actualUseCustomPrompts || !systemPrompt || !userPrompt) {
// 使用内置模板或后备方案
if (typeof getBuiltInPrompts === "function") {
const prompts = getBuiltInPrompts(targetLang);
systemPrompt = prompts.systemPrompt + tableHandlingNote;
userPrompt = prompts.userPromptTemplate;
} else {
// 兜底
systemPrompt = "You are a professional document translation assistant." + tableHandlingNote;
userPrompt = "Please translate the following content into the target language:\n\n${content}";
}
} else {
// 使用自定义提示词(再次检查非空)
const sys = (actualDefaultSystemPrompt || '').trim();
const usr = (actualDefaultUserPromptTemplate || '').trim();
if (sys && usr) {
systemPrompt = sys + tableHandlingNote;
userPrompt = usr;
} else {
// 回退到内置
if (typeof getBuiltInPrompts === "function") {
const prompts = getBuiltInPrompts(targetLang);
systemPrompt = prompts.systemPrompt + tableHandlingNote;
userPrompt = prompts.userPromptTemplate;
} else {
systemPrompt = "You are a professional document translation assistant." + tableHandlingNote;
userPrompt = "Please translate the following content into the target language:\n\n${content}";
}
}
}
}
// 注入:翻译备择库(术语库)命中与提示注入
try {
const settingsForGlossary = (typeof loadSettings === 'function') ? loadSettings() : {};
const glossaryEnabled = !!settingsForGlossary.enableGlossary;
if (glossaryEnabled && typeof getGlossaryMatchesForText === 'function') {
const matches = getGlossaryMatchesForText(processedText);
if (matches && matches.length > 0) {
const instr = (typeof buildGlossaryInstruction === 'function') ? buildGlossaryInstruction(matches, targetLang) : '';
if (instr) {
systemPrompt = (systemPrompt || '') + "\n\n" + instr;
// 获取实际限制的数量
let actualLimit = 50; // 默认
if (typeof loadGlossarySets === 'function') {
const sets = loadGlossarySets();
const setIds = Object.keys(sets || {});
for (const id of setIds) {
const set = sets[id];
if (set && set.enabled && set.maxTermsInPrompt) {
actualLimit = set.maxTermsInPrompt;
break;
}
}
}
const actualCount = Math.min(matches.length, actualLimit);
if (typeof addProgressLog === 'function') {
const names = matches.slice(0, 6).map(m => m.term).join(', ');
const filterInfo = matches.length > actualCount ? ` (已过滤至 ${actualCount} 条)` : '';
addProgressLog(`${actualLogContext} 命中术语库 ${matches.length}${filterInfo}${names}${matches.length>6?'...':''}`);
}
}
}
}
} catch (e) {
console.warn('Glossary injection skipped due to error:', e);
}
// 替换模板变量 - 使用预处理后的文本
userPrompt = userPrompt
.replace(/\$\{targetLangName\}/g, targetLang)
.replace(/\$\{content\}/g, processedText);
if (!userPrompt.includes(processedText)) {
console.warn('警告:当前 userPrompt 模板未包含 ${content} 占位符AI 无法获得正文内容!');
//console.warn('当前 userPrompt:', userPrompt);
//console.warn('当前 processedText:', processedText);
}
//console.log('translateMarkdown defaultUserPromptTemplate:', actualDefaultUserPromptTemplate);
//console.log('translateMarkdown userPrompt (before replace):', userPrompt);
// 构建 API 配置
let apiConfig;
if (model === "custom") {
// 使用 modelConfigForCustom
if (!modelConfigForCustom || (!modelConfigForCustom.apiEndpoint && !modelConfigForCustom.apiBaseUrl) || !modelConfigForCustom.modelId) {
throw new Error('Custom model configuration is incomplete. API Endpoint (或 apiBaseUrl) and Model ID are required.');
}
//console.log('translateMarkdown activeModelConfig:', modelConfigForCustom);
apiConfig = buildCustomApiConfigForTranslation(
apiKey,
modelConfigForCustom.apiEndpoint || modelConfigForCustom.apiBaseUrl,
modelConfigForCustom.modelId,
modelConfigForCustom.requestFormat || 'openai',
modelConfigForCustom.temperature !== undefined ? modelConfigForCustom.temperature : 0.5,
modelConfigForCustom.max_tokens !== undefined ? modelConfigForCustom.max_tokens : 8000,
{
endpointMode: modelConfigForCustom.endpointMode || 'auto'
}
);
} else {
// 预设模型
// 获取翻译参数
const settings = typeof loadSettings === "function" ? loadSettings() : {};
const temperature = (settings.customModelSettings && settings.customModelSettings.temperature) || 0.5;
const maxTokens = (settings.customModelSettings && settings.customModelSettings.max_tokens) || 8000;
// 允许从配置覆盖部分预设端点/模型(例如 Gemini 选择具体模型)
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) { /* ignore and use default */ }
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 = {
'aliyun': {
// 所有翻译请求都指向后端代理,由后端决定使用哪个模型
endpoint: (typeof window !== 'undefined' && window.ProxyConfig)
? window.ProxyConfig.getLLMProxyUrl('tongyi', '/v1/chat/completions')
: ((window.PBX_PROXY_BASE_URL || 'http://localhost:3456') + '/api/llm/tongyi/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
}
};
// 检查选择的模型是否在预设配置中
if (!predefinedConfigs[model]) {
throw new Error(`不支持的翻译模型: ${model}`);
}
apiConfig = buildPredefinedApiConfig(predefinedConfigs[model], apiKey);
}
// 构建请求体
const bodyBuilderContext = {
processedText,
targetLang,
originalText: markdown,
hasProtectedTables,
tablePlaceholders,
options,
requestType: 'initial',
instructionBlock: buildInstructionBlock(systemPrompt)
};
const requestBody = apiConfig.bodyBuilder
? apiConfig.bodyBuilder(systemPrompt, userPrompt, bodyBuilderContext)
: {
model: apiConfig.modelName,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt }
]
};
// 实际调用(记录提示词池使用成功率 + 队列入队/出队)
let result;
const poolPromptId = (usePromptPool && promptFromPool && promptFromPool.id) ? promptFromPool.id : null;
// 入队(如调用方未预入队,则在此兜底入队)
const requestId = options.requestId || `req_${Date.now()}_${Math.random().toString(36).slice(2,8)}`;
if (!options.requestId && poolPromptId && typeof window.translationPromptPool !== 'undefined' && typeof window.translationPromptPool.enqueueRequest === 'function') {
window.translationPromptPool.enqueueRequest(poolPromptId, { requestId, model: apiConfig.modelName || 'unknown' });
}
const startTimeMs = Date.now();
let primaryError = null;
try {
// 出队(开始执行,不再算“待迁移”)
if (poolPromptId && typeof window.translationPromptPool !== 'undefined' && typeof window.translationPromptPool.dequeueRequest === 'function') {
window.translationPromptPool.dequeueRequest(poolPromptId, requestId);
}
result = await callTranslationApi(apiConfig, requestBody);
result = stripInstructionBlocks(result);
if (poolPromptId && typeof window.translationPromptPool !== 'undefined' && typeof window.translationPromptPool.recordPromptUsage === 'function') {
window.translationPromptPool.recordPromptUsage(
poolPromptId,
true,
Date.now() - startTimeMs,
null,
{ model: apiConfig.modelName || 'unknown', endpoint: apiConfig.endpoint || '' }
);
}
} catch (e) {
// 出队(失败也确保不再算“待迁移”)
if (poolPromptId && typeof window.translationPromptPool !== 'undefined' && typeof window.translationPromptPool.dequeueRequest === 'function') {
window.translationPromptPool.dequeueRequest(poolPromptId, requestId);
}
if (poolPromptId && typeof window.translationPromptPool !== 'undefined' && typeof window.translationPromptPool.recordPromptUsage === 'function') {
window.translationPromptPool.recordPromptUsage(
poolPromptId,
false,
Date.now() - startTimeMs,
e && e.message ? e.message : String(e),
{ model: apiConfig.modelName || 'unknown', endpoint: apiConfig.endpoint || '' }
);
}
primaryError = e;
}
// 即时切换并重试一次(谨慎):仅在提示词池模式、允许失败切换、存在健康替代时执行
if (!result && poolPromptId && typeof window.translationPromptPool !== 'undefined') {
try {
const cfgOk = (typeof window.translationPromptPool.getHealthConfig === 'function') ? window.translationPromptPool.getHealthConfig() : null;
const canSwitch = cfgOk && cfgOk.switchOnFailure;
const newPrompt = (typeof window.translationPromptPool.selectHealthyPrompt === 'function')
? window.translationPromptPool.selectHealthyPrompt(poolPromptId)
: null;
if (canSwitch && newPrompt && newPrompt.id !== poolPromptId) {
if (typeof addProgressLog === 'function') {
addProgressLog(`${actualLogContext} 首次失败,尝试切换至健康提示词并重试一次...`);
}
// 重建基于新提示词的 prompts
let retrySystemPrompt = (newPrompt.systemPrompt || '') + tableHandlingNote;
let retryUserPrompt = (newPrompt.userPromptTemplate || '')
.replace(/\$\{targetLangName\}/g, targetLang)
.replace(/\$\{content\}/g, processedText);
// 重新评估术语库命中并注入(确保重试也带有一致的术语指引)
try {
const st2 = (typeof loadSettings === 'function') ? loadSettings() : {};
if (st2 && st2.enableGlossary && typeof getGlossaryMatchesForText === 'function') {
const matches2 = getGlossaryMatchesForText(processedText);
if (matches2 && matches2.length > 0 && typeof buildGlossaryInstruction === 'function') {
const instr2 = buildGlossaryInstruction(matches2, targetLang);
if (instr2) retrySystemPrompt = retrySystemPrompt + "\n\n" + instr2;
}
}
} catch (e) {
console.warn('Glossary injection (retry) skipped:', e);
}
// 入队 + 出队(重试请求)
const retryRequestId = `${requestId}_r1`;
if (typeof window.translationPromptPool.enqueueRequest === 'function') {
window.translationPromptPool.enqueueRequest(newPrompt.id, { requestId: retryRequestId, model: apiConfig.modelName || 'unknown' });
}
if (typeof window.translationPromptPool.dequeueRequest === 'function') {
window.translationPromptPool.dequeueRequest(newPrompt.id, retryRequestId);
}
const retryBody = apiConfig.bodyBuilder
? apiConfig.bodyBuilder(retrySystemPrompt, retryUserPrompt, { ...bodyBuilderContext, requestType: 'retry' })
: {
model: apiConfig.modelName,
messages: [
{ role: 'system', content: retrySystemPrompt },
{ role: 'user', content: retryUserPrompt }
]
};
const retryStart = Date.now();
try {
result = await callTranslationApi(apiConfig, retryBody);
result = stripInstructionBlocks(result);
if (typeof window.translationPromptPool.recordPromptUsage === 'function') {
window.translationPromptPool.recordPromptUsage(
newPrompt.id,
true,
Date.now() - retryStart,
null,
{ model: apiConfig.modelName || 'unknown', endpoint: apiConfig.endpoint || '' }
);
}
if (typeof window !== 'undefined' && window.isProcessing && window.promptPoolUI) {
// 将会话锁定到新的健康提示词
window.promptPoolUI.sessionLockedPrompt = newPrompt;
}
if (typeof addProgressLog === 'function') {
addProgressLog(`${actualLogContext} 重试成功。`);
}
} catch (e2) {
if (typeof window.translationPromptPool.recordPromptUsage === 'function') {
window.translationPromptPool.recordPromptUsage(
newPrompt.id,
false,
Date.now() - retryStart,
e2 && e2.message ? e2.message : String(e2),
{ model: apiConfig.modelName || 'unknown', endpoint: apiConfig.endpoint || '' }
);
}
if (typeof addProgressLog === 'function') {
addProgressLog(`${actualLogContext} 重试失败:${e2.message}`);
}
}
}
} catch (swErr) {
// 保守处理:任何切换逻辑错误都不影响主异常流
console.warn('Immediate switch-retry failed silently:', swErr);
}
}
if (!result) {
// 两次均失败,抛出原始异常
throw primaryError || new Error('调用翻译 API 失败');
}
// 如果存在表格保护处理且需要处理表格占位符,恢复表格
if (hasProtectedTables && actualProcessTablePlaceholders && typeof extractTableFromTranslation === 'function') {
if (typeof addProgressLog === "function") {
addProgressLog(`${actualLogContext} 翻译主文本完成,正在处理表格...`);
}
// 获取表格翻译结果并替换
let finalResult = result;
for (const [placeholder, tableContent] of Object.entries(tablePlaceholders)) {
try {
const tableSystemPrompt = `你是一个精确翻译表格的助手。请将表格翻译成${targetLang},严格保持以下格式要求:
1. 保持所有表格分隔符(|)和结构完全不变
2. 保持表格对齐标记(:--:、:--、--:)不变
3. 保持表格的行数和列数完全一致
4. 保持数学公式、符号和百分比等专业内容不变
5. 翻译表格标题(如有)和表格内的文本内容
6. 表格内容与表格外内容要明确区分`;
const tableUserPrompt = `请将以下Markdown表格翻译成${targetLang},请确保完全保持表格结构和格式:
${tableContent}
注意:请保持表格格式完全不变,包括所有的 | 符号、对齐标记、数学公式和符号。`;
const tableRequestBody = apiConfig.bodyBuilder
? apiConfig.bodyBuilder(tableSystemPrompt, tableUserPrompt, {
processedText: tableContent,
rawText: tableContent,
targetLang,
tablePlaceholder: placeholder,
requestType: 'table'
})
: {
model: apiConfig.modelName,
messages: [
{ role: "system", content: tableSystemPrompt },
{ role: "user", content: tableUserPrompt }
]
};
if (typeof addProgressLog === "function") {
addProgressLog(`${actualLogContext} 正在翻译表格...`);
}
const translatedTable = await callTranslationApi(apiConfig, tableRequestBody);
const cleanedTable = extractTableFromTranslation(stripInstructionBlocks(translatedTable)) || tableContent;
finalResult = finalResult.replace(placeholder, cleanedTable);
} catch (tableError) {
console.error(`表格翻译失败:`, tableError);
if (typeof addProgressLog === "function") {
addProgressLog(`${actualLogContext} 表格翻译失败: ${tableError.message},将使用原表格`);
}
finalResult = finalResult.replace(placeholder, tableContent);
}
}
if (typeof addProgressLog === "function") {
addProgressLog(`${actualLogContext} 表格处理完成。`);
}
return finalResult;
}
return result;
}
// 将函数添加到processModule对象
if (typeof processModule !== 'undefined') {
processModule.buildPredefinedApiConfig = buildPredefinedApiConfig;
processModule.buildCustomApiConfig = buildCustomApiConfigForTranslation; // 使用重命名后的函数
processModule.translateMarkdown = translateMarkdown;
processModule.stripInstructionBlocks = stripInstructionBlocks;
processModule.buildInstructionBlock = buildInstructionBlock;
}