feat: 完成MinerU结构化翻译pdf对照

This commit is contained in:
肖应宇 2026-03-10 12:50:37 +08:00
parent e7b6c360c1
commit e764a2b847
10 changed files with 609 additions and 82 deletions

View File

@ -15,6 +15,8 @@ body {
max-width: 1200px; max-width: 1200px;
margin: 40px auto; margin: 40px auto;
height: 100vh; height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-bg-base); background: var(--color-bg-base);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
/* 使用变量定义的轻量化阴影 */ /* 使用变量定义的轻量化阴影 */
@ -87,8 +89,8 @@ body {
.tab-content { .tab-content {
background: transparent; background: transparent;
padding: 8px 0; padding: 8px 0;
height: 100vh;
min-height: 300px; min-height: 300px;
flex: 1;
margin-top: 0; margin-top: 0;
/* 优化阅读体验的字体设置 */ /* 优化阅读体验的字体设置 */
font-size: 1.0625rem; /* 17px */ font-size: 1.0625rem; /* 17px */

View File

@ -231,7 +231,7 @@
pointer-events: none; pointer-events: none;
} }
/* 新增:右侧大 Logo 背景装饰 */ /* 新增:右侧大 Logo 背景装饰 */
.workspace-header::after { /* .workspace-header::after {
content: ''; content: '';
position: absolute; position: absolute;
right: -20px; right: -20px;
@ -242,10 +242,11 @@
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center; background-position: center;
background-size: contain; background-size: contain;
opacity: 0.07; /* 极低透明度,仅作纹理 */ 极低透明度,仅作纹理
opacity: 0.07;
transform: rotate(-10deg); transform: rotate(-10deg);
pointer-events: none; pointer-events: none;
} } */
.workspace-header-content { .workspace-header-content {
position: relative; position: relative;
z-index: 1; z-index: 1;
@ -594,7 +595,7 @@
</nav> </nav>
<!-- 删除界面会加载不出来 --> <!-- 删除界面会加载不出来 -->
<div class="p-3 transition-all" id="sidebarFooter"> <div class="p-3 transition-all" id="sidebarFooter">
<div class="p-4 bg-slate-50 rounded-2xl border border-slate-100 overflow-hidden"> <div class="p-4 overflow-hidden">
</div> </div>
<div class="text-center py-1 mt-1 nav-text transition-opacity"> <div class="text-center py-1 mt-1 nav-text transition-opacity">
@ -694,13 +695,13 @@
</div> </div>
OCR 文档解析 OCR 文档解析
</h2> </h2>
<div class="flex items-center"> <!-- <div class="flex items-center">
<span id="flashConfigTip" class="flash-tip-anim text-sm font-medium mr-2" style="color: var(--color-primary);">配置模型与Key</span> <span id="flashConfigTip" class="flash-tip-anim text-sm font-medium mr-2" style="color: var(--color-primary);">配置模型与Key</span>
<button id="modelKeyManagerBtn" class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-600 bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors" title="模型与Key管理"> <button id="modelKeyManagerBtn" class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-600 bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors" title="模型与Key管理">
<iconify-icon icon="carbon:settings" width="18"></iconify-icon> <iconify-icon icon="carbon:settings" width="18"></iconify-icon>
<span>设置</span> <span>设置</span>
</button> </button>
</div> </div> -->
</div> </div>
<!-- OCR 引擎选择(简化版) --> <!-- OCR 引擎选择(简化版) -->

BIN
input/English PDF Test.pdf Normal file

Binary file not shown.

Binary file not shown.

View File

@ -535,6 +535,32 @@ async function triggerReprocess(includeTranslation) {
} }
} }
/**
* 显示 Tab 按钮加载状态
* @param {string} tabId - Tab 按钮 ID
* @param {boolean} loading - 是否显示加载状态
*/
function setTabLoadingState(tabId, loading) {
const btn = document.getElementById(tabId);
if (!btn) return;
if (loading) {
// 保存原始内容
btn.dataset.originalContent = btn.innerHTML;
// 显示加载动画
btn.innerHTML = `<div class="spinner" style="width: 18px; height: 18px; border: 2px solid #e5e7eb; border-top-color: #3b82f6; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto;"></div>`;
btn.disabled = true;
btn.style.minWidth = '60px';
} else {
// 恢复原始内容
if (btn.dataset.originalContent) {
btn.innerHTML = btn.dataset.originalContent;
}
btn.disabled = false;
btn.style.minWidth = '';
}
}
/** /**
* 处理"Word文档"标签点击 * 处理"Word文档"标签点击
* 如果没有OCR数据询问用户是否需要生成 * 如果没有OCR数据询问用户是否需要生成
@ -561,7 +587,14 @@ async function handleOcrTabClick() {
); );
if (confirmed) { if (confirmed) {
await triggerReprocess(false); // 仅OCR不翻译 // 显示加载状态
setTabLoadingState('tab-ocr', true);
try {
await triggerReprocess(false); // 仅OCR不翻译
} finally {
// 恢复按钮状态
setTabLoadingState('tab-ocr', false);
}
} }
} }
@ -591,7 +624,396 @@ async function handleTranslationTabClick() {
); );
if (confirmed) { if (confirmed) {
await triggerReprocess(true); // OCR + 翻译 // 显示加载状态
setTabLoadingState('tab-translation', true);
try {
await triggerReprocess(true); // OCR + 翻译
} finally {
// 恢复按钮状态
setTabLoadingState('tab-translation', false);
}
}
}
/**
* 显示 PDF 对照确认对话框
* 询问用户是否进行 MinerU 结构化翻译
*/
async function showPdfCompareConfirmDialog() {
// 检查是否有 MinerU 数据contentListJson
const hasMinerUData = window.data && window.data.metadata && window.data.metadata.contentListJson;
if (!hasMinerUData) {
// 没有 MinerU 数据,询问是否重新使用 MinerU 处理
const confirmed = await showConfirmDialog(
'PDF 对照视图',
'当前文档未使用 MinerU 引擎处理,无法进行结构化翻译。\n\n是否重新使用 MinerU 引擎处理文档?\n\n处理完成后将自动进行翻译并显示 PDF 对照视图。',
'开始处理',
'取消'
);
if (confirmed) {
// 使用 MinerU 重新处理并翻译
await triggerReprocessWithMinerU();
} else {
// 用户取消,跳转回原始文件标签页
if (typeof showTab === 'function') {
showTab('original-file');
}
}
return;
}
// 有 MinerU 数据,询问是否进行翻译
const confirmed = await showConfirmDialog(
'PDF 对照视图',
'当前文档尚未进行 MinerU 结构化翻译。是否现在开始翻译?\n\n翻译完成后将显示原文与译文的 PDF 对照视图。',
'开始翻译',
'取消'
);
if (confirmed) {
await executeMinerUStructuredTranslation();
} else {
// 用户取消,跳转回原始文件标签页
if (typeof showTab === 'function') {
showTab('original-file');
}
}
}
/**
* 使用 MinerU 引擎重新处理文档并翻译
*/
async function triggerReprocessWithMinerU() {
const docId = window.docIdForLocalStorage;
const docName = window.data ? window.data.name : '未知文档';
if (!docId) {
showToast('无法获取文档ID请刷新页面重试。', 'error');
return;
}
// 检查是否有原始 PDF 数据
const pdfBase64 = window.data?.metadata?.originalPdfBase64;
if (!pdfBase64) {
showToast('当前记录没有保存原始PDF数据无法重新处理。', 'error');
return;
}
// 显示加载状态
setTabLoadingState('tab-pdf-compare', true);
// 保存当前 OCR 配置
const savedEngine = localStorage.getItem('ocrEngine');
const savedTranslationMode = localStorage.getItem('mineruTranslationMode');
const savedMineruMode = localStorage.getItem('mineruMode');
try {
showToast('正在使用 MinerU 处理文档...', 'info');
// 临时设置 OCR 配置为 MinerU + 结构化翻译模式
localStorage.setItem('ocrEngine', 'mineru');
localStorage.setItem('mineruTranslationMode', 'structured');
localStorage.setItem('mineruMode', 'txt');
// 将 PDF 转换为 Blob
const pdfBytes = Uint8Array.from(atob(pdfBase64), c => c.charCodeAt(0));
const pdfBlob = new Blob([pdfBytes], { type: 'application/pdf' });
const pdfFile = new File([pdfBlob], docName || 'document.pdf', { type: 'application/pdf' });
// 获取翻译模型配置 - 使用后端代理,不需要前端选择模型
const settings = typeof loadSettings === 'function' ? loadSettings() : {};
// 调用 OCR 处理
if (typeof window.processSinglePdf !== 'function') {
showToast('处理模块未加载,请刷新页面重试。', 'error');
return;
}
// 使用后端代理进行翻译,不需要传递 API Key
const result = await window.processSinglePdf(
pdfFile,
null, // mistralKeyObject - 使用 MinerU 不需要
null, // translationKeyObject - 使用后端代理不需要
'tongyi', // 使用通义模型,后端代理会处理
null, // translationModelConfig
settings.maxTokensPerChunk || 2000,
settings.targetLanguage || 'Chinese',
() => Promise.resolve(), // acquireSlot
() => {}, // releaseSlot
settings.defaultSystemPrompt || '',
settings.defaultUserPromptTemplate || '',
settings.useCustomPrompts || false,
null, // batchContext
() => {} // onFileSuccess
);
if (result.error) {
throw new Error(result.error);
}
console.log('[triggerReprocessWithMinerU] 处理结果:', result);
console.log('[triggerReprocessWithMinerU] metadata:', result.metadata);
console.log('[triggerReprocessWithMinerU] contentListJson:', result.metadata?.contentListJson);
// 更新 window.data
if (result.ocr) window.data.ocr = result.ocr;
if (result.translation) window.data.translation = result.translation;
if (result.images) window.data.images = result.images;
if (result.ocrChunks) window.data.ocrChunks = result.ocrChunks;
if (result.translatedChunks) window.data.translatedChunks = result.translatedChunks;
if (result.metadata) {
window.data.metadata = window.data.metadata || {};
Object.assign(window.data.metadata, result.metadata);
}
// 保存到 IndexedDB
if (typeof saveResultToDB === 'function') {
await saveResultToDB(window.data);
}
showToast('处理完成!', 'success');
// 刷新页面显示
if (typeof renderDetail === 'function') {
renderDetail();
}
// 自动跳转到原始文件标签页
if (typeof showTab === 'function') {
showTab('original-file');
}
} catch (error) {
console.error('[triggerReprocessWithMinerU] 处理失败:', error);
showToast(`处理失败: ${error.message}`, 'error');
} finally {
// 恢复原始 OCR 配置
if (savedEngine !== null) localStorage.setItem('ocrEngine', savedEngine);
else localStorage.removeItem('ocrEngine');
if (savedTranslationMode !== null) localStorage.setItem('mineruTranslationMode', savedTranslationMode);
else localStorage.removeItem('mineruTranslationMode');
if (savedMineruMode !== null) localStorage.setItem('mineruMode', savedMineruMode);
else localStorage.removeItem('mineruMode');
setTabLoadingState('tab-pdf-compare', false);
}
}
/**
* 执行 MinerU 结构化翻译
* 通过后端 local-proxy 代理调用 LLM APIAPI Key 由后端管理
*/
async function executeMinerUStructuredTranslation() {
const logPrefix = '[MinerU结构化翻译]';
const PROXY_BASE = 'http://localhost:3456';
// 获取翻译配置
const settings = typeof loadSettings === 'function' ? loadSettings() : {};
const modelName = "tongyi";
// 显示进度容器
const tabContent = document.getElementById('tabContent');
tabContent.innerHTML = `
<div id="structured-translation-progress" style="padding: 24px;">
<div style="display: flex; align-items: center; margin-bottom: 16px;">
<div class="spinner" style="width: 24px; height: 24px; border: 3px solid #e5e7eb; border-top-color: #3b82f6; border-radius: 50%; animation: spin 1s linear infinite; margin-right: 12px;"></div>
<h3 style="margin: 0; font-size: 16px; color: #1f2937;">正在执行 MinerU 结构化翻译...</h3>
</div>
<div id="structured-translation-log" style="
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 13px;
color: #6b7280;
">
<div class="log-entry">准备开始翻译...</div>
</div>
<div style="margin-top: 16px;">
<div class="progress-bar" style="
background: #e5e7eb;
border-radius: 8px;
height: 8px;
overflow: hidden;
">
<div id="structured-translation-progress-bar" style="
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
height: 100%;
width: 0%;
transition: width 0.3s ease;
"></div>
</div>
<p id="structured-translation-status" style="margin: 8px 0 0 0; font-size: 13px; color: #6b7280;">进度: 0%</p>
</div>
</div>
<style>
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
`;
const logEl = document.getElementById('structured-translation-log');
const progressBar = document.getElementById('structured-translation-progress-bar');
const statusEl = document.getElementById('structured-translation-status');
// 日志函数
const addLog = (msg) => {
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
logEl.appendChild(entry);
logEl.scrollTop = logEl.scrollHeight;
console.log(`${logPrefix} ${msg}`);
};
// 进度回调
const onProgress = (progress) => {
const pct = progress.percentage || 0;
progressBar.style.width = `${pct}%`;
statusEl.textContent = `进度: ${pct}% - ${progress.message || ''}`;
};
try {
addLog('初始化翻译器...');
// 检查 MinerUStructuredTranslation 是否可用
if (typeof MinerUStructuredTranslation === 'undefined') {
throw new Error('MinerU 结构化翻译模块未加载');
}
const translator = new MinerUStructuredTranslation();
addLog('翻译器初始化完成');
// 获取全局 data 对象
const dataObj = window.data;
console.log('dataObj:', dataObj);
if (!dataObj || !dataObj.metadata || !dataObj.metadata.contentListJson) {
console.log(!dataObj ,!dataObj.metadata , !dataObj.metadata.contentListJson);
throw new Error('缺少必要的内容数据');
}
// 提取可翻译内容
const contentListJson = dataObj.metadata.contentListJson;
addLog(`提取可翻译内容...`);
const translatableContent = translator.extractTranslatableContent(contentListJson);
addLog(`提取了 ${translatableContent.length} 个片段`);
// 分批
const batches = translator.splitIntoBatches(translatableContent);
addLog(`分为 ${batches.length} 个批次`);
// 获取目标语言
const targetLang = settings.targetLanguage === 'custom'
? (settings.customTargetLanguageName || 'Chinese')
: (settings.targetLanguage || 'Chinese');
// 翻译选项 - 通过后端代理,不需要 API Key
const translationOptions = {
useBackendProxy: true,
proxyBase: PROXY_BASE,
provider: modelName === 'custom' ? 'aliyun' : modelName // 默认使用 aliyun/通义
};
// 执行翻译
addLog(`开始翻译 (模型: ${modelName}, 目标语言: ${targetLang}, 通过后端代理)...`);
const translatedContentList = await translator.translateBatches(
batches,
targetLang,
modelName,
null, // API Key 为 null由后端代理处理
{
...translationOptions,
maxRetries: settings.structuredMaxRetries || 2,
retryDelay: settings.structuredRetryDelayMs || 800
},
onProgress,
() => Promise.resolve(), // acquireSlot
() => {} // releaseSlot
);
addLog('翻译完成,保存数据...');
// 保存到 metadata
if (!dataObj.metadata) dataObj.metadata = {};
dataObj.metadata.translatedContentList = translatedContentList;
dataObj.metadata.supportsStructuredTranslation = true;
// 收集失败项
const failedItems = [];
translatedContentList.forEach((it, idx) => {
if (it && it.failed === true) {
failedItems.push({
index: idx,
type: it.type,
page_idx: it.page_idx || 0,
text: translator.extractItemText ? translator.extractItemText(it) : (it.text || '')
});
}
});
dataObj.metadata.failedStructuredItems = failedItems;
dataObj.metadata.structuredFailedCount = failedItems.length;
// 更新全局数据
if (typeof data !== 'undefined') {
data.metadata = dataObj.metadata;
}
window.data = dataObj;
// 保存到数据库
if (typeof saveResultToDB === 'function') {
await saveResultToDB(dataObj);
addLog('数据已保存到数据库');
}
if (failedItems.length > 0) {
addLog(`注意: 有 ${failedItems.length} 个片段翻译失败`);
}
addLog('正在加载 PDF 对照视图...');
// 短暂延迟后显示 PDF 对照视图
setTimeout(() => {
if (typeof showTabImmediate === 'function') {
showTabImmediate('pdf-compare');
} else if (typeof showTab === 'function') {
showTab('pdf-compare');
}
}, 500);
} catch (error) {
console.error(`${logPrefix} 翻译失败:`, error);
addLog(`错误: ${error.message}`);
// 显示错误
tabContent.innerHTML = `
<div class="error-box" style="padding: 24px; text-align: center;">
<i class="fa fa-exclamation-triangle" style="font-size: 48px; color: #ef4444; margin-bottom: 16px;"></i>
<h3 style="margin: 0 0 12px 0; color: #991b1b;">翻译失败</h3>
<p style="margin: 0 0 16px 0; color: #6b7280;">${error.message}</p>
<button onclick="showTab('ocr')" style="
padding: 10px 20px;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
">返回 OCR 内容</button>
</div>
`;
if (typeof renderingTab !== 'undefined') renderingTab = null;
if (typeof console.timeEnd === 'function') console.timeEnd('[性能] showTab_总渲染');
} }
} }

View File

@ -300,15 +300,14 @@ function showTabImmediate(tab) {
if (DOM_CACHE.layout.meta) DOM_CACHE.layout.meta.style.display = 'none'; if (DOM_CACHE.layout.meta) DOM_CACHE.layout.meta.style.display = 'none';
if (DOM_CACHE.layout.tabsContainer) DOM_CACHE.layout.tabsContainer.style.display = 'none'; if (DOM_CACHE.layout.tabsContainer) DOM_CACHE.layout.tabsContainer.style.display = 'none';
// 验证必要数据 // 检查是否有必要的结构化翻译数据
if (!data.metadata || !data.metadata.originalPdfBase64 || !data.metadata.contentListJson || !data.metadata.translatedContentList) { const hasStructuredData = data.metadata && data.metadata.originalPdfBase64 && data.metadata.contentListJson && data.metadata.translatedContentList;
const warn = `<div class="warning-box" style="padding:12px;border:1px solid #fbbf24;background:#fffbeb;color:#92400e;border-radius:8px;">`
+ `无法进入"PDF对照":缺少必要的 MinerU 结构化翻译数据。` if (!hasStructuredData) {
+ `</div>`; // 缺少结构化翻译数据,弹出确认对话框询问用户
document.getElementById('tabContent').innerHTML = warn; (async () => {
if (typeof window.refreshTocList === 'function') window.refreshTocList(); await showPdfCompareConfirmDialog();
renderingTab = null; })();
console.timeEnd && console.timeEnd('[性能] showTab_总渲染');
return; return;
} }

View File

@ -704,13 +704,23 @@ const fileType = fileToProcess.name.split('.').pop().toLowerCase();
// --- 翻译流程 (如果需要) --- // --- 翻译流程 (如果需要) ---
if (selectedTranslationModelName !== 'none') { if (selectedTranslationModelName !== 'none') {
const translationKeyValue = translationKeyObject ? translationKeyObject.value : null; const translationKeyValue = translationKeyObject ? translationKeyObject.value : null;
if (!translationKeyValue) {
// 检查是否使用后端代理模式(不需要前端 API Key
const useBackendProxy = selectedTranslationModelName === 'tongyi' || selectedTranslationModelName === 'aliyun';
if (!translationKeyValue && !useBackendProxy) {
if (typeof addProgressLog === "function") addProgressLog(`${logPrefix} 警告: 需要翻译但未提供有效的翻译 API Key。跳过翻译。`); if (typeof addProgressLog === "function") addProgressLog(`${logPrefix} 警告: 需要翻译但未提供有效的翻译 API Key。跳过翻译。`);
currentTranslationContent = '[未翻译缺少API Key]'; currentTranslationContent = '[未翻译缺少API Key]';
ocrChunks = [currentMarkdownContent]; ocrChunks = [currentMarkdownContent];
translatedChunks = [currentTranslationContent]; translatedChunks = [currentTranslationContent];
} else { } else {
if (typeof addProgressLog === "function") addProgressLog(`${logPrefix} 开始翻译 (${selectedTranslationModelName}, Key: ...${translationKeyValue.slice(-4)})`); if (typeof addProgressLog === "function") {
if (useBackendProxy) {
addProgressLog(`${logPrefix} 开始翻译 (${selectedTranslationModelName}, 使用后端代理)`);
} else {
addProgressLog(`${logPrefix} 开始翻译 (${selectedTranslationModelName}, Key: ...${translationKeyValue.slice(-4)})`);
}
}
// ===== MinerU 结构化翻译检测 ===== // ===== MinerU 结构化翻译检测 =====
let shouldUseStructuredTranslation = false; let shouldUseStructuredTranslation = false;
@ -776,6 +786,9 @@ const fileType = fileToProcess.name.split('.').pop().toLowerCase();
}); });
// 4. 执行批量翻译 // 4. 执行批量翻译
// 检查是否使用后端代理模式
const useBackendProxy = !translationKeyValue && (selectedTranslationModelName === 'tongyi' || selectedTranslationModelName === 'aliyun');
const translatedContentList = await structuredTranslator.translateBatches( const translatedContentList = await structuredTranslator.translateBatches(
batches, batches,
targetLanguageValue, targetLanguageValue,
@ -783,6 +796,9 @@ const fileType = fileToProcess.name.split('.').pop().toLowerCase();
translationKeyValue, translationKeyValue,
{ {
...translationOptions, ...translationOptions,
useBackendProxy,
provider: selectedTranslationModelName,
proxyBase: 'http://localhost:3456',
// 允许从设置自定义重试,若无则用默认 // 允许从设置自定义重试,若无则用默认
maxRetries: (typeof loadSettings === 'function' ? (loadSettings().structuredMaxRetries || undefined) : undefined), maxRetries: (typeof loadSettings === 'function' ? (loadSettings().structuredMaxRetries || undefined) : undefined),
retryDelay: (typeof loadSettings === 'function' ? (loadSettings().structuredRetryDelayMs || undefined) : undefined) retryDelay: (typeof loadSettings === 'function' ? (loadSettings().structuredRetryDelayMs || undefined) : undefined)
@ -1009,60 +1025,61 @@ const fileType = fileToProcess.name.split('.').pop().toLowerCase();
} }
const processedAt = new Date().toISOString(); const processedAt = new Date().toISOString();
if (typeof saveResultToDB === "function") {
// 准备元数据
const metadataToSave = {};
// 如果是 MinerU 结构化翻译,保存额外的元数据 // 准备元数据(在条件块外定义,以便返回值使用)
if (ocrResult && ocrResult.metadata) { const metadataToSave = {};
// 保存 layoutJson 和 contentListJson
if (ocrResult.metadata.layoutJson) { // 如果是 MinerU 结构化翻译,保存额外的元数据
metadataToSave.layoutJson = ocrResult.metadata.layoutJson; if (ocrResult && ocrResult.metadata) {
} // 保存 layoutJson 和 contentListJson
if (ocrResult.metadata.contentListJson) { if (ocrResult.metadata.layoutJson) {
metadataToSave.contentListJson = ocrResult.metadata.contentListJson; metadataToSave.layoutJson = ocrResult.metadata.layoutJson;
}
// 保存翻译后的结构化内容
if (ocrResult.metadata.translatedContentList) {
metadataToSave.translatedContentList = ocrResult.metadata.translatedContentList;
}
// 保存原始 PDF转为 base64
if (ocrResult.metadata.originalPdf) {
try {
const pdfBlob = ocrResult.metadata.originalPdf;
const pdfArrayBuffer = await pdfBlob.arrayBuffer();
metadataToSave.originalPdfBase64 = arrayBufferToBase64(pdfArrayBuffer);
if (typeof addProgressLog === "function") {
addProgressLog(`${logPrefix} 已保存原始 PDF (${Math.round(pdfBlob.size / 1024)} KB)`);
}
} catch (e) {
console.warn(`${logPrefix} 保存原始 PDF 失败:`, e);
}
}
// 标记支持结构化翻译
metadataToSave.supportsStructuredTranslation = ocrResult.metadata.supportsStructuredTranslation;
// 持久化结构化失败项统计(如存在)
if (Array.isArray(ocrResult.metadata.failedStructuredItems)) {
metadataToSave.failedStructuredItems = ocrResult.metadata.failedStructuredItems;
}
if (typeof ocrResult.metadata.structuredFailedCount === 'number') {
metadataToSave.structuredFailedCount = ocrResult.metadata.structuredFailedCount;
}
} }
if (ocrResult.metadata.contentListJson) {
// 新增:对于所有 PDF 文件,如果还没有 originalPdfBase64则从原始文件读取 metadataToSave.contentListJson = ocrResult.metadata.contentListJson;
if (fileType === 'pdf' && !metadataToSave.originalPdfBase64) { }
// 保存翻译后的结构化内容
if (ocrResult.metadata.translatedContentList) {
metadataToSave.translatedContentList = ocrResult.metadata.translatedContentList;
}
// 保存原始 PDF转为 base64
if (ocrResult.metadata.originalPdf) {
try { try {
const pdfArrayBuffer = await fileToProcess.arrayBuffer(); const pdfBlob = ocrResult.metadata.originalPdf;
const pdfArrayBuffer = await pdfBlob.arrayBuffer();
metadataToSave.originalPdfBase64 = arrayBufferToBase64(pdfArrayBuffer); metadataToSave.originalPdfBase64 = arrayBufferToBase64(pdfArrayBuffer);
if (typeof addProgressLog === "function") { if (typeof addProgressLog === "function") {
addProgressLog(`${logPrefix} 已保存原始 PDF 用于查看 (${Math.round(fileToProcess.size / 1024)} KB)`); addProgressLog(`${logPrefix} 已保存原始 PDF (${Math.round(pdfBlob.size / 1024)} KB)`);
} }
} catch (e) { } catch (e) {
console.warn(`${logPrefix} 保存原始 PDF 失败:`, e); console.warn(`${logPrefix} 保存原始 PDF 失败:`, e);
} }
} }
// 标记支持结构化翻译
metadataToSave.supportsStructuredTranslation = ocrResult.metadata.supportsStructuredTranslation;
// 持久化结构化失败项统计(如存在)
if (Array.isArray(ocrResult.metadata.failedStructuredItems)) {
metadataToSave.failedStructuredItems = ocrResult.metadata.failedStructuredItems;
}
if (typeof ocrResult.metadata.structuredFailedCount === 'number') {
metadataToSave.structuredFailedCount = ocrResult.metadata.structuredFailedCount;
}
}
// 新增:对于所有 PDF 文件,如果还没有 originalPdfBase64则从原始文件读取
if (fileType === 'pdf' && !metadataToSave.originalPdfBase64) {
try {
const pdfArrayBuffer = await fileToProcess.arrayBuffer();
metadataToSave.originalPdfBase64 = arrayBufferToBase64(pdfArrayBuffer);
if (typeof addProgressLog === "function") {
addProgressLog(`${logPrefix} 已保存原始 PDF 用于查看 (${Math.round(fileToProcess.size / 1024)} KB)`);
}
} catch (e) {
console.warn(`${logPrefix} 保存原始 PDF 失败:`, e);
}
}
if (typeof saveResultToDB === "function") {
await saveResultToDB({ await saveResultToDB({
id: `${fileToProcess.name}_${fileToProcess.size}`, id: `${fileToProcess.name}_${fileToProcess.size}`,
name: fileToProcess.name, name: fileToProcess.name,
@ -1137,7 +1154,9 @@ const fileType = fileToProcess.name.split('.').pop().toLowerCase();
batchOutputLanguage: batchContext ? batchContext.outputLanguage : null, batchOutputLanguage: batchContext ? batchContext.outputLanguage : null,
batchOriginalIndex: batchContext ? batchContext.originalIndex : null, batchOriginalIndex: batchContext ? batchContext.originalIndex : null,
batchAttempt: batchContext ? batchContext.attempt : null, batchAttempt: batchContext ? batchContext.attempt : null,
batchZip: batchContext ? batchContext.zipOutput : null batchZip: batchContext ? batchContext.zipOutput : null,
// 返回 metadata包含 contentListJson 等结构化翻译数据
metadata: Object.keys(metadataToSave).length > 0 ? metadataToSave : null
}; };
@ -1186,3 +1205,9 @@ if (typeof processModule !== 'undefined') {
} else { } else {
console.warn('main.js: processModule is undefined at the point of assignment.'); console.warn('main.js: processModule is undefined at the point of assignment.');
} }
// 也暴露到 window 上,以便在 history_detail.html 等页面使用
if (typeof window !== 'undefined') {
window.processSinglePdf = processSinglePdf;
console.log('main.js: processSinglePdf exposed to window');
}

View File

@ -701,7 +701,88 @@ ${jsonContent}
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
async callTranslationAPI(systemPrompt, userPrompt, model, apiKey, options = {}) { async callTranslationAPI(systemPrompt, userPrompt, model, apiKey, options = {}) {
// 复用 translation.js 的配置构建逻辑 const settings = typeof loadSettings === 'function' ? loadSettings() : {};
const temperature = (settings.customModelSettings && settings.customModelSettings.temperature) || 0.5;
const maxTokens = (settings.customModelSettings && settings.customModelSettings.max_tokens) || 8000;
console.log('[MinerU Structured] callTranslationAPI 调用参数:', {
model,
hasApiKey: !!apiKey,
useBackendProxy: options.useBackendProxy,
options
});
// 后端代理模式 - API Key 由后端管理
if (options.useBackendProxy) {
const proxyBase = options.proxyBase || 'http://localhost:3456';
const provider = options.provider || 'aliyun';
// 后端代理端点映射
const providerEndpoints = {
'aliyun': `${proxyBase}/api/llm/aliyun/v1/chat/completions`,
'tongyi': `${proxyBase}/api/llm/tongyi/v1/chat/completions`,
'deepseek': `${proxyBase}/api/llm/deepseek/v1/chat/completions`,
'openai': `${proxyBase}/api/llm/openai/v1/chat/completions`,
'mistral': `${proxyBase}/api/llm/mistral/v1/chat/completions`,
'zhipu': `${proxyBase}/api/llm/zhipu/v4/chat/completions`,
'anthropic': `${proxyBase}/api/llm/anthropic/v1/messages`,
'gemini': `${proxyBase}/api/llm/gemini/v1beta/models/gemini-pro:generateContent`
};
const endpoint = providerEndpoints[provider] || providerEndpoints['aliyun'];
// 获取模型 ID
let modelId = 'qwen-turbo-latest';
try {
const modelConfig = typeof loadModelConfig === 'function' ? loadModelConfig(provider) : null;
if (modelConfig && (modelConfig.preferredModelId || modelConfig.modelId)) {
modelId = modelConfig.preferredModelId || modelConfig.modelId;
}
} catch (e) {
console.warn('[MinerU Structured] 加载模型配置失败,使用默认模型:', e);
}
const requestBody = {
model: modelId,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt }
],
temperature,
max_tokens: maxTokens
};
console.log('[MinerU Structured] 后端代理模式:', { endpoint, provider, modelId });
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`翻译 API 请求失败 (${response.status}): ${errorText}`);
}
const data = await response.json();
const result = data?.choices?.[0]?.message?.content;
if (!result) {
throw new Error('翻译 API 返回空结果');
}
// 清理指令块
return this._stripInstructionBlocks(result);
} catch (error) {
console.error('[MinerU Structured] 后端代理请求失败:', error);
throw error;
}
}
// 原有逻辑 - 前端直接调用(需要 API Key
if (typeof processModule === 'undefined' || if (typeof processModule === 'undefined' ||
typeof processModule.buildPredefinedApiConfig !== 'function' || typeof processModule.buildPredefinedApiConfig !== 'function' ||
typeof processModule.buildCustomApiConfig !== 'function') { typeof processModule.buildCustomApiConfig !== 'function') {
@ -712,14 +793,6 @@ ${jsonContent}
throw new Error('callTranslationApi 函数不可用'); throw new Error('callTranslationApi 函数不可用');
} }
console.log('[MinerU Structured] callTranslationAPI 调用参数:', {
model,
hasApiKey: !!apiKey,
options,
hasModelConfig: !!(options && options.modelConfig),
modelConfig: options ? options.modelConfig : null
});
// 构建 API 配置 // 构建 API 配置
let apiConfig; let apiConfig;
if (model === 'custom') { if (model === 'custom') {
@ -756,10 +829,6 @@ ${jsonContent}
); );
} else { } else {
// 预设模型 - 从 translation.js 获取配置 // 预设模型 - 从 translation.js 获取配置
const settings = typeof loadSettings === 'function' ? loadSettings() : {};
const temperature = (settings.customModelSettings && settings.customModelSettings.temperature) || 0.5;
const maxTokens = (settings.customModelSettings && settings.customModelSettings.max_tokens) || 8000;
// 简化:仅支持常用模型 // 简化:仅支持常用模型
// 前端发出的请求源头 // 前端发出的请求源头
const predefinedConfigs = { const predefinedConfigs = {
@ -811,17 +880,25 @@ ${jsonContent}
let result = await callTranslationApi(apiConfig, requestBody); let result = await callTranslationApi(apiConfig, requestBody);
// 清理指令块(防止系统提示词泄露到翻译结果中) // 清理指令块(防止系统提示词泄露到翻译结果中)
return this._stripInstructionBlocks(result);
}
/**
* 清理指令块
* @param {string} result
* @returns {string}
*/
_stripInstructionBlocks(result) {
if (typeof stripInstructionBlocks === 'function') { if (typeof stripInstructionBlocks === 'function') {
result = stripInstructionBlocks(result); return stripInstructionBlocks(result);
} else if (typeof processModule !== 'undefined' && typeof processModule.stripInstructionBlocks === 'function') { } else if (typeof processModule !== 'undefined' && typeof processModule.stripInstructionBlocks === 'function') {
result = processModule.stripInstructionBlocks(result); return processModule.stripInstructionBlocks(result);
} else { } else {
// 回退:手动清理 // 回退:手动清理
if (typeof result === 'string') { if (typeof result === 'string') {
result = result.replace(/\s*\[\[PBX_INSTR_START\]\][\s\S]*?\[\[PBX_INSTR_END\]\]\s*/gi, '').trim(); return result.replace(/\s*\[\[PBX_INSTR_START\]\][\s\S]*?\[\[PBX_INSTR_END\]\]\s*/gi, '').trim();
} }
} }
return result; return result;
} }

View File

@ -596,7 +596,7 @@
class="btn-mineru-primary" class="btn-mineru-primary"
onclick="mineruOpenHistory()" onclick="mineruOpenHistory()"
> >
📂 读取并打开历史界面 📂 读取并打开历史界面仅跳转不会OCR我平常使用这个按钮测试这个功能
</button> </button>
<button <button
id="mineruProcessBtn" id="mineruProcessBtn"

View File

@ -823,6 +823,7 @@
<script src="../../js/process/ocr-adapters/doc2x-adapter.js"></script> <script src="../../js/process/ocr-adapters/doc2x-adapter.js"></script>
<script src="../../js/process/ocr-adapters/local-adapter.js"></script> <script src="../../js/process/ocr-adapters/local-adapter.js"></script>
<script src="../../js/process/ocr.js"></script> <script src="../../js/process/ocr.js"></script>
<script src="../../js/process/main.js"></script>
<!-- OCR 设置管理器 --> <!-- OCR 设置管理器 -->
<script src="../../js/ui/ocr-settings.js"></script> <script src="../../js/ui/ocr-settings.js"></script>