458 lines
19 KiB
JavaScript
458 lines
19 KiB
JavaScript
/**
|
||
* 检测文本是否主要为中文
|
||
* 通过统计中文字符占比来判断
|
||
* @param {string} text - 待检测的文本
|
||
* @returns {boolean} - 如果中文字符占比超过 30% 则认为是中文文档
|
||
*/
|
||
function isChineseDocument(text) {
|
||
if (!text || typeof text !== 'string') return false;
|
||
|
||
// 移除空白字符后进行检测
|
||
const cleanText = text.replace(/\s+/g, '');
|
||
if (cleanText.length === 0) return false;
|
||
|
||
// 匹配中文字符(包括简体和繁体)
|
||
const chineseCharRegex = /[\u4e00-\u9fff]/g;
|
||
const chineseChars = cleanText.match(chineseCharRegex);
|
||
|
||
if (!chineseChars) return false;
|
||
|
||
// 计算中文字符占比
|
||
const ratio = chineseChars.length / cleanText.length;
|
||
|
||
// 如果中文字符占比超过 30%,认为是中文文档
|
||
return ratio > 0.3;
|
||
}
|
||
|
||
/**
|
||
* 使用 PDF.js 从 PDF base64 数据中提取文本
|
||
* @param {string} base64Data - PDF 文件的 base64 编码数据
|
||
* @param {number} maxPages - 最大提取页数(默认提取前3页用于检测)
|
||
* @returns {Promise<string>} 提取的文本内容
|
||
*/
|
||
async function extractTextFromPdfBase64(base64Data, maxPages = 3) {
|
||
if (!base64Data || typeof pdfjsLib === 'undefined') {
|
||
return '';
|
||
}
|
||
|
||
try {
|
||
// 将 base64 转换为 Uint8Array
|
||
const binaryString = atob(base64Data);
|
||
const bytes = new Uint8Array(binaryString.length);
|
||
for (let i = 0; i < binaryString.length; i++) {
|
||
bytes[i] = binaryString.charCodeAt(i);
|
||
}
|
||
|
||
// 使用 PDF.js 加载 PDF
|
||
const loadingTask = pdfjsLib.getDocument({ data: bytes });
|
||
const pdfDocument = await loadingTask.promise;
|
||
|
||
// 提取前几页的文本
|
||
const totalPages = Math.min(pdfDocument.numPages, maxPages);
|
||
let extractedText = '';
|
||
|
||
for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
|
||
const page = await pdfDocument.getPage(pageNum);
|
||
const textContent = await page.getTextContent();
|
||
const pageText = textContent.items.map(item => item.str).join(' ');
|
||
extractedText += pageText + ' ';
|
||
}
|
||
|
||
return extractedText;
|
||
} catch (error) {
|
||
console.error('[extractTextFromPdfBase64] 提取PDF文本失败:', error);
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据文档语言隐藏不需要的 Tab
|
||
* 中文文档隐藏:PDF对照、仅翻译、分块对比
|
||
* @param {Object} data - 文档数据对象
|
||
* @returns {Promise<boolean>} - 是否为中文文档
|
||
*/
|
||
async function hideTabsForChineseDocument(data) {
|
||
// 获取 OCR 文本用于判断语言
|
||
// 优先级:contentListJson > ocr > ocrChunks > originalBinary(PDF提取)
|
||
let textToDetect = '';
|
||
const meta = data?.metadata?.metadata || data?.metadata || {};
|
||
|
||
// 1. 优先从 contentListJson 提取文本(MinerU 结构化数据)
|
||
if (meta.contentListJson && Array.isArray(meta.contentListJson)) {
|
||
const textItems = meta.contentListJson
|
||
.filter(item => item.type === 'text' && item.text)
|
||
.map(item => item.text);
|
||
textToDetect = textItems.join(' ');
|
||
console.log('[hideTabsForChineseDocument] 从 contentListJson 提取文本,共', textItems.length, '个文本块');
|
||
}
|
||
|
||
// 2. 如果没有 contentListJson,尝试 ocr 字段
|
||
if (!textToDetect && data.ocr && typeof data.ocr === 'string') {
|
||
textToDetect = data.ocr;
|
||
console.log('[hideTabsForChineseDocument] 使用 data.ocr 字段');
|
||
}
|
||
|
||
// 3. 尝试 metadata.ocr
|
||
if (!textToDetect && meta.ocr && typeof meta.ocr === 'string') {
|
||
textToDetect = meta.ocr;
|
||
console.log('[hideTabsForChineseDocument] 使用 metadata.ocr 字段');
|
||
}
|
||
|
||
// 4. 尝试 ocrChunks
|
||
if (!textToDetect && data.ocrChunks && Array.isArray(data.ocrChunks) && data.ocrChunks.length > 0) {
|
||
textToDetect = data.ocrChunks.map(chunk =>
|
||
typeof chunk === 'string' ? chunk : (chunk.text || chunk.content || '')
|
||
).join(' ');
|
||
console.log('[hideTabsForChineseDocument] 从 data.ocrChunks 提取文本');
|
||
}
|
||
|
||
// 5. 尝试 metadata.ocrChunks
|
||
if (!textToDetect && meta.ocrChunks && Array.isArray(meta.ocrChunks) && meta.ocrChunks.length > 0) {
|
||
textToDetect = meta.ocrChunks.map(chunk =>
|
||
typeof chunk === 'string' ? chunk : (chunk.text || chunk.content || '')
|
||
).join(' ');
|
||
console.log('[hideTabsForChineseDocument] 从 metadata.ocrChunks 提取文本');
|
||
}
|
||
|
||
// 6. 如果以上都没有,尝试从原始PDF中提取文本
|
||
if (!textToDetect && meta.originalPdfBase64) {
|
||
console.log('[hideTabsForChineseDocument] 尝试从原始PDF提取文本进行语言检测...');
|
||
textToDetect = await extractTextFromPdfBase64(meta.originalPdfBase64, 3);
|
||
}
|
||
|
||
// 检测是否为中文文档
|
||
const isChinese = isChineseDocument(textToDetect);
|
||
|
||
console.log('[hideTabsForChineseDocument] 文档语言检测结果:', {
|
||
isChinese,
|
||
textLength: textToDetect.length,
|
||
textSample: textToDetect.substring(0, 100) + '...'
|
||
});
|
||
|
||
// 需要隐藏的 Tab ID 列表
|
||
const tabsToHide = [
|
||
'tab-pdf-compare', // PDF对照
|
||
'tab-translation', // 仅翻译
|
||
'tab-chunk-compare' // 分块对比
|
||
];
|
||
|
||
tabsToHide.forEach(tabId => {
|
||
const tabElement = document.getElementById(tabId);
|
||
if (tabElement) {
|
||
if (isChinese) {
|
||
tabElement.classList.add('hidden-tab');
|
||
console.log(`[hideTabsForChineseDocument] 隐藏 Tab: ${tabId}`);
|
||
} else {
|
||
tabElement.classList.remove('hidden-tab');
|
||
console.log(`[hideTabsForChineseDocument] 显示 Tab: ${tabId}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
return isChinese;
|
||
}
|
||
|
||
/**
|
||
* 异步渲染历史详情页面的主函数。
|
||
* - 从 URL 查询参数中获取记录 ID。
|
||
* - 使用 `getResultFromDB` (来自 storage.js) 从 IndexedDB 加载对应的历史数据。
|
||
* - 如果数据成功加载:
|
||
* - 更新页面标题 (`#fileName`) 和元数据 (`#fileMeta`)。
|
||
* - 根据数据中是否存在有效的分块信息 (`ocrChunks`, `translatedChunks`),
|
||
* 决定默认显示的标签页(优先显示分块对比,否则显示 OCR 内容)。
|
||
* - 如果未找到数据,则显示提示信息。
|
||
* @async
|
||
*/
|
||
async function renderDetail() {
|
||
const id = getQueryParam('id');
|
||
if (!id) return;
|
||
docIdForLocalStorage = id; // Store doc ID for localStorage operations
|
||
window.docIdForLocalStorage = id; // 同时更新挂载到 window 对象上的变量
|
||
|
||
// Restore chatbot open state
|
||
const savedChatbotOpenState = localStorage.getItem(`chatbotOpenState_${docIdForLocalStorage}`);
|
||
if (savedChatbotOpenState === 'true') {
|
||
window.isChatbotOpen = true;
|
||
} else if (savedChatbotOpenState === 'false') {
|
||
window.isChatbotOpen = false;
|
||
}
|
||
|
||
// 从localStorage恢复保存的比例设置
|
||
const savedChunkCompareRatio = localStorage.getItem(`chunkCompareRatio_${docIdForLocalStorage}`);
|
||
if (savedChunkCompareRatio !== null && !isNaN(parseFloat(savedChunkCompareRatio))) {
|
||
window.chunkCompareRatio = parseFloat(savedChunkCompareRatio);
|
||
}
|
||
|
||
// console.log(`Chatbot state after attempting restore from localStorage for ${docIdForLocalStorage}: ${window.isChatbotOpen}`);
|
||
|
||
// Initialize Dock Logic once docIdForLocalStorage is available
|
||
if (typeof window.DockLogic !== 'undefined' && typeof window.DockLogic.init === 'function') {
|
||
window.DockLogic.init(docIdForLocalStorage);
|
||
} else {
|
||
console.error("DockLogic not available or init function missing.");
|
||
}
|
||
|
||
// 使用 storageAdapter 获取数据(支持后端模式)
|
||
let data;
|
||
if (window.storageAdapter && typeof window.storageAdapter.getResultFromDB === 'function') {
|
||
data = await window.storageAdapter.getResultFromDB(id);
|
||
} else if (typeof getResultFromDB === 'function') {
|
||
data = await getResultFromDB(id);
|
||
}
|
||
window.data = data; // for debugging
|
||
const fileMetaTimeEl = document.getElementById('fileMetaTime');
|
||
const fileMetaImagesEl = document.getElementById('fileMetaImages');
|
||
|
||
if (!data) {
|
||
document.getElementById('fileName').textContent = '未找到数据';
|
||
if (fileMetaTimeEl) fileMetaTimeEl.textContent = '时间: --';
|
||
if (fileMetaImagesEl) fileMetaImagesEl.textContent = '图片数: --';
|
||
document.getElementById('tabContent').innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
// === 移除:按钮现在默认显示,不再动态控制显示/隐藏 ===
|
||
// 标签按钮(原始文件、OCR、仅翻译、分块对比、PDF对照)现在始终可见
|
||
// 仅保留数据检测用于默认标签选择
|
||
|
||
// 兼容嵌套的 metadata 结构
|
||
const meta = data?.metadata?.metadata || data?.metadata || {};
|
||
|
||
// 调试:打印后端数据结构
|
||
console.log('[renderDetail] 后端数据结构:', {
|
||
id: data.id,
|
||
name: data.name,
|
||
fileType: data.fileType,
|
||
hasMetadata: !!data.metadata,
|
||
metadataType: typeof data.metadata,
|
||
metadataKeys: data.metadata ? Object.keys(data.metadata) : [],
|
||
hasNestedMetadata: !!(data.metadata?.metadata),
|
||
nestedMetadataKeys: data.metadata?.metadata ? Object.keys(data.metadata.metadata) : [],
|
||
metaKeys: Object.keys(meta),
|
||
hasOriginalPdfBase64: !!meta.originalPdfBase64,
|
||
hasOcrChunks: !!(meta.ocrChunks?.length),
|
||
hasTranslatedChunks: !!(meta.translatedChunks?.length),
|
||
hasImages: !!(meta.images?.length),
|
||
hasContentListJson: !!meta.contentListJson,
|
||
hasTranslatedContentList: !!meta.translatedContentList
|
||
});
|
||
|
||
const hasMinerUStructuredData =
|
||
meta.originalPdfBase64 &&
|
||
meta.contentListJson &&
|
||
meta.translatedContentList &&
|
||
meta.supportsStructuredTranslation === true;
|
||
|
||
const hasOriginalPdf = !!(meta.originalPdfBase64);
|
||
|
||
// ========== 将 PDF base64 转换为 File 对象,供 AI 助手的 PDF 功能使用 ==========
|
||
if (hasOriginalPdf && data.name) {
|
||
try {
|
||
// 使用 history_detail_scripts.js 中定义的 base64ToFile 函数
|
||
if (typeof base64ToFile === 'function') {
|
||
window.currentOriginalFile = base64ToFile(meta.originalPdfBase64, data.name);
|
||
console.log('[renderDetail] 已设置 window.currentOriginalFile,供 AI 助手使用');
|
||
} else {
|
||
// 如果 base64ToFile 不可用,手动转换
|
||
const base64Data = meta.originalPdfBase64;
|
||
const byteString = atob(base64Data);
|
||
const ab = new ArrayBuffer(byteString.length);
|
||
const ia = new Uint8Array(ab);
|
||
for (let i = 0; i < byteString.length; i++) {
|
||
ia[i] = byteString.charCodeAt(i);
|
||
}
|
||
window.currentOriginalFile = new File([ab], data.name, { type: 'application/pdf' });
|
||
console.log('[renderDetail] 已设置 window.currentOriginalFile(手动转换),供 AI 助手使用');
|
||
}
|
||
} catch (e) {
|
||
console.error('[renderDetail] 转换 PDF base64 为 File 失败:', e);
|
||
}
|
||
}
|
||
|
||
console.log('[renderDetail] 检测结果:', {
|
||
hasMinerUStructuredData,
|
||
hasOriginalPdf,
|
||
defaultTab: hasOriginalPdf ? 'original-file' : 'ocr'
|
||
});
|
||
|
||
document.getElementById('fileName').textContent = data.name;
|
||
if (fileMetaTimeEl) {
|
||
fileMetaTimeEl.textContent = `时间: ${new Date(data.time).toLocaleString()}`;
|
||
}
|
||
if (fileMetaImagesEl) {
|
||
const imageCount = Array.isArray(data.images) ? data.images.length : 0;
|
||
fileMetaImagesEl.textContent = `图片数: ${imageCount}`;
|
||
}
|
||
|
||
// ========== 确保批注数据在渲染前加载 ==========
|
||
if (id) { // 确保我们有文档 ID
|
||
try {
|
||
let annotations;
|
||
if (window.storageAdapter && typeof window.storageAdapter.getAnnotationsForDocFromDB === 'function') {
|
||
annotations = await window.storageAdapter.getAnnotationsForDocFromDB(id);
|
||
} else if (typeof getAnnotationsForDocFromDB === 'function') {
|
||
annotations = await getAnnotationsForDocFromDB(id);
|
||
}
|
||
console.log(`Annotations for docId '${id}' (loaded in renderDetail):`, annotations);
|
||
data.annotations = annotations || []; // 存储到 data 对象,确保是数组
|
||
// updateAnnotationSummary(); // Handled by updateAllDockStats via showTab
|
||
// updateHighlightSummary(); // Handled by updateAllDockStats via showTab
|
||
} catch (error) {
|
||
console.error(`Error loading annotations for docId '${id}' in renderDetail:`, error);
|
||
data.annotations = []; // 出错时也确保是个空数组
|
||
// updateAnnotationSummary(); // Handled by updateAllDockStats via showTab
|
||
// updateHighlightSummary(); // Handled by updateAllDockStats via showTab
|
||
}
|
||
} else {
|
||
// updateAnnotationSummary(); // Handled by updateAllDockStats via showTab
|
||
// updateHighlightSummary(); // Handled by updateAllDockStats via showTab
|
||
}
|
||
// =============================================
|
||
|
||
// ========== 在 window.data 设置并填充批注后,显式加载聊天记录 ==========
|
||
if (window.data) {
|
||
if (window.ChatbotCore && typeof window.ChatbotCore.reloadChatHistoryAndUpdateUI === 'function' &&
|
||
window.ChatbotUI && typeof window.ChatbotUI.updateChatbotUI === 'function') {
|
||
console.log('renderDetail: Calling reloadChatHistoryAndUpdateUI after window.data and annotations are set. Current docId:', window.ChatbotCore.getCurrentDocId ? window.ChatbotCore.getCurrentDocId() : 'unknown');
|
||
window.ChatbotCore.reloadChatHistoryAndUpdateUI(window.ChatbotUI.updateChatbotUI);
|
||
} else {
|
||
console.error('renderDetail: ChatbotCore or ChatbotUI not fully available for history reload.');
|
||
}
|
||
}
|
||
// =================================================================
|
||
|
||
// Initialize annotation system after data is loaded and DOM is likely ready
|
||
if (typeof window.initializeGlobalAnnotationVariables === 'function') {
|
||
window.initializeGlobalAnnotationVariables();
|
||
}
|
||
if (typeof window.initAnnotationSystem === 'function') {
|
||
window.initAnnotationSystem();
|
||
} else {
|
||
console.error("initAnnotationSystem is not defined. Check js/annotation_logic.js");
|
||
}
|
||
|
||
// ========== 检测文档语言并隐藏不必要的 Tab ==========
|
||
const isChinese = await hideTabsForChineseDocument(data);
|
||
// =============================================
|
||
|
||
// Determine initial tab, AFTER annotations are loaded
|
||
let initialTab = hasOriginalPdf ? 'original-file' : 'ocr'; // 有原始文件时默认显示原始文件
|
||
if (docIdForLocalStorage) {
|
||
const savedTabKey = `activeTab_${docIdForLocalStorage}`;
|
||
const savedTab = localStorage.getItem(savedTabKey);
|
||
if (
|
||
savedTab &&
|
||
['ocr', 'translation', 'chunk-compare', 'pdf-compare', 'original-file'].includes(savedTab) &&
|
||
!(savedTab !== 'ocr' && (!data.translation || data.translation.trim() === ""))
|
||
) {
|
||
initialTab = savedTab;
|
||
// 如果是中文文档,且保存的 Tab 是被隐藏的,则切换到默认 Tab
|
||
if (isChinese && ['translation', 'chunk-compare', 'pdf-compare'].includes(savedTab)) {
|
||
console.log(`[renderDetail] 中文文档,切换保存的 Tab 从 ${savedTab} 到 ocr`);
|
||
initialTab = hasOriginalPdf ? 'original-file' : 'ocr';
|
||
}
|
||
} else if (hasOriginalPdf) {
|
||
// 如果有原始文件,优先显示原始文件
|
||
initialTab = 'original-file';
|
||
} else if (
|
||
data.ocrChunks && data.ocrChunks.length > 0 &&
|
||
data.translatedChunks && data.translatedChunks.length > 0 &&
|
||
data.ocrChunks.length === data.translatedChunks.length &&
|
||
data.translation && data.translation.trim() !== ""
|
||
) {
|
||
initialTab = 'chunk-compare';
|
||
// 如果是中文文档,不显示分块对比
|
||
if (isChinese) {
|
||
initialTab = 'ocr';
|
||
}
|
||
}
|
||
} else if (hasOriginalPdf) {
|
||
// 如果有原始文件,优先显示原始文件
|
||
initialTab = 'original-file';
|
||
} else if (
|
||
data.ocrChunks && data.ocrChunks.length > 0 &&
|
||
data.translatedChunks && data.translatedChunks.length > 0 &&
|
||
data.ocrChunks.length === data.translatedChunks.length &&
|
||
data.translation && data.translation.trim() !== ""
|
||
) {
|
||
initialTab = 'chunk-compare';
|
||
// 如果是中文文档,不显示分块对比
|
||
if (isChinese) {
|
||
initialTab = 'ocr';
|
||
}
|
||
}
|
||
|
||
// 现在,在批注肯定加载完毕后,才调用 showTab
|
||
showTab(initialTab);
|
||
|
||
// The block for loading annotations (previously around line 415) has been moved up.
|
||
|
||
// Add scroll listener for saving scroll position (动态绑定到正确的滚动容器)
|
||
if (typeof bindScrollForSavePosition === 'function') {
|
||
bindScrollForSavePosition();
|
||
} else {
|
||
console.warn('[historyDetailRender] bindScrollForSavePosition function not found');
|
||
}
|
||
// Add scroll listener for updating reading progress - MOVED TO DOCK_LOGIC.JS
|
||
// window.removeEventListener('scroll', debouncedUpdateReadingProgress);
|
||
// window.addEventListener('scroll', debouncedUpdateReadingProgress);
|
||
|
||
// Add listener to save chatbot state on page unload
|
||
window.removeEventListener('beforeunload', saveChatbotStateOnUnload);
|
||
window.addEventListener('beforeunload', saveChatbotStateOnUnload);
|
||
|
||
// Manage Annotations Link Click - Changed to Settings Link - MOVED TO DOCK_LOGIC.JS
|
||
// const settingsLink = document.getElementById('settings-link');
|
||
// if (settingsLink) {
|
||
// settingsLink.onclick = function(event) {
|
||
// event.preventDefault();
|
||
// alert('管理页面即将推出!'); // Updated alert message
|
||
// };
|
||
// }
|
||
|
||
// Dock Toggle Button Click - MOVED TO DOCK_LOGIC.JS
|
||
// const dockToggleBtn = document.getElementById('dock-toggle-btn');
|
||
// const dock = document.getElementById('bottom-left-dock');
|
||
// if (dockToggleBtn && dock) {
|
||
// // Restore collapsed state
|
||
// const dockCollapsedKey = `dockCollapsed_${docIdForLocalStorage}`;
|
||
// const isCollapsed = localStorage.getItem(dockCollapsedKey) === 'true';
|
||
// if (isCollapsed) {
|
||
// dock.classList.add('dock-collapsed');
|
||
// dockToggleBtn.innerHTML = '<i class="fa fa-chevron-up"></i>';
|
||
// dockToggleBtn.title = '展开';
|
||
// }
|
||
|
||
// dockToggleBtn.onclick = function(event) {
|
||
// event.preventDefault();
|
||
// const currentlyCollapsed = dock.classList.toggle('dock-collapsed');
|
||
// if (currentlyCollapsed) {
|
||
// this.innerHTML = '<i class="fa fa-chevron-up"></i>';
|
||
// this.title = '展开';
|
||
// localStorage.setItem(dockCollapsedKey, 'true');
|
||
// } else {
|
||
// this.innerHTML = '<i class="fa fa-chevron-down"></i>';
|
||
// this.title = '折叠';
|
||
// localStorage.setItem(dockCollapsedKey, 'false');
|
||
// }
|
||
// };
|
||
// }
|
||
}
|
||
|
||
/**
|
||
* 切换并显示指定的标签页内容。
|
||
* - 更新标签按钮的激活状态 (`active` class)。
|
||
* - 根据传入的 `tab` 参数 ( 'ocr', 'translation', 'chunk-compare' ),生成对应的 HTML 内容。
|
||
* - OCR 和翻译标签页:直接渲染 `data.ocr` 或 `data.translation` 字段。
|
||
* - 分块对比标签页 (`chunk-compare`):
|
||
* - 检查 `data.ocrChunks` 和 `data.translatedChunks` 是否有效且数量匹配。
|
||
* - 如果有效,则为每一对原文/译文块生成对比视图。使用 `renderLevelAlignedFlex` 进行布局。
|
||
* - 提供一个按钮 (`#swap-chunks-btn`) 用于切换原文和译文在对比视图中的左右位置。
|
||
* - 如果分块数据无效,则显示提示信息。
|
||
* - 将生成的 HTML 设置到 `#tabContent` 区域。
|
||
* - 调用 `window.refreshTocList()` 更新目录(TOC)。
|
||
*
|
||
* @param {string} tab - 要显示的标签页标识符 ('ocr', 'translation', or 'chunk-compare')。
|
||
*/
|
||
|