980 lines
49 KiB
JavaScript
980 lines
49 KiB
JavaScript
// 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;
|
||
}
|