Compare commits

...

10 Commits

Author SHA1 Message Date
肖应宇 b7a9f8da9b feat(ui): 优化上传交互与首页样式 2026-04-13 17:56:20 +08:00
肖应宇 f0fb54e41f style(history): 调整历史列表与操作区样式 2026-04-13 17:56:13 +08:00
肖应宇 8d5af9799c style(history-detail): 居中标题与标签栏样式 2026-04-13 17:56:07 +08:00
肖应宇 6a25d6aa3c feat(chatbot): 重构输入区与高级选项栏 2026-04-13 17:55:59 +08:00
肖应宇 3890cca114 feat(chatbot): 禁用快捷指令区渲染 2026-04-13 17:54:16 +08:00
肖应宇 869e15d4ce style(chatbot): 调整思考过程折叠块样式 2026-04-13 17:53:40 +08:00
肖应宇 6c78f08769 feat: 集成 OSS 存储与 AI 助手 PDF 支持
- OSS 对象存储集成 - 文档上传后保存OSS URL 和 Key 到数据库,删除文档时同步清理 OSS 文件
- AI 助手 PDF 支持 - 历史详情页将 PDF base64 转换为 File 对象,供 AI 助手直接使用
- 认证状态同步 - 后端 /health 返回 authDisabled 状态,前端自动适配无认证模式
- 添加TODO: 生产环境必须移除的配置
2026-03-25 14:00:30 +08:00
肖应宇 96f136a9bc feat: 禁用退出沉浸模式功能,页面始终保持在沉浸式布局
- 移除退出沉浸模式的所有逻辑代码(360 行→29 行)
- 隐藏"简单沉浸模式"切换按钮
- 移除 PDF 对照按钮的显示样式
- 简化初始化逻辑,强制默认进入沉浸模式
2026-03-25 11:27:51 +08:00
肖应宇 fcd490b1f5 feat: 如果是中文文档,就隐藏pdf对照翻译和分块对比 2026-03-25 11:11:21 +08:00
肖应宇 95eb004c18 feat: pdf对照,word文档,翻译,分块对比功能都已经正常 2026-03-25 10:47:51 +08:00
29 changed files with 3069 additions and 2105 deletions

67
.vscode/settings.json vendored
View File

@ -1,2 +1,67 @@
{
}
"window.title": "${activeEditorShort}${separator}${separator}deer-flow/frontend",
"todo-tree.regex.regex": "((%|#|//|<!--|\\{/\\*|^\\s*\\*)\\s*($TAGS)|^\\s*- \\[ \\])",
"todo-tree.general.tags": [
"TODO:",
"BUG:",
"TAG:",
"DONE:",
"MARK:",
"TEST:",
"XXX:"
],
"todo-tree.regex.regexCaseSensitive": false,
"todo-tree.highlights.defaultHighlight": {
"foreground": "#000000",
"background": "#fff700",
"icon": "check",
"rulerColour": "#fff700",
"type": "tag",
"iconColour": "#fff700"
},
"todo-tree.highlights.customHighlight": {
"TODO:": {
"icon": "todo",
"background": "#fff700",
"rulerColour": "#fff700",
"iconColour": "#fff700"
},
"BUG:": {
"background": "#eb5c5c",
"icon": "bug",
"rulerColour": "#eb5c5c",
"iconColour": "#eb5c5c"
},
"TAG:": {
"background": "#38b2f4",
"icon": "tag",
"rulerColour": "#38b2f4",
"iconColour": "#38b2f4",
"rulerLane": "full"
},
"DONE:": {
"background": "#5eec95",
"icon": "check",
"rulerColour": "#5eec95",
"iconColour": "#5eec95"
},
"MARK:": {
"background": "#f90",
"icon": "note",
"rulerColour": "#f90",
"iconColour": "#f90"
},
"TEST:": {
"background": "#df7be6",
"icon": "flame",
"rulerColour": "#df7be6",
"iconColour": "#df7be6"
},
"XXX:": {
"background": "#d65d8e",
"icon": "versions",
"rulerColour": "#d65d8e",
"iconColour": "#d65d8e"
}
}
}

View File

@ -98,16 +98,16 @@
}
/* 标题和 meta 信息合并到一行 */
#immersive-main-content-area .container > h2 {
display: flex;
align-items: center;
justify-content: space-between;
#immersive-main-content-area .container #fileName {
color: var(--333333, #333);
font-family: "Microsoft YaHei";
font-size: 14px;
font-style: normal;
font-weight: 700;
line-height: normal;
margin: 0;
padding: 0;
gap: var(--spacing-md);
font-size: 0.7em;
font-weight: var(--font-weight-normal);
color: var(--color-text-muted);
flex-shrink: 0; /* 防止被压缩 */
min-height: 20px; /* 确保最小高度 */
}
@ -138,7 +138,7 @@
display: flex;
gap: 2px;
height: 32px;
justify-content: flex-start;
justify-content: center;
align-items: center;
background: transparent;
border-bottom: 1px solid var(--color-border-light);
@ -153,7 +153,7 @@
background-color: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--color-text-muted);
color: #999;
cursor: pointer;
transition: all var(--transition-fast);
margin: 0;
@ -170,9 +170,9 @@
#immersive-main-content-area .tabs-container .tab-btn.active {
background-color: transparent;
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: var(--font-weight-semibold);
color: #000F33;
border-bottom-color: #000F33;
/* font-weight: var(--font-weight-semibold); */
}
/* ==================== 4. 滚动容器管理(核心重构)==================== */
@ -399,7 +399,7 @@ body.immersive-active #immersive-main-content-area .container .tab-content {
overflow: hidden !important;
display: flex;
flex-direction: column;
background: var(--color-bg-secondary) !important;
/* background: var(--color-bg-secondary) !important; */
border: none !important;
transition: all var(--transition-base) ease !important;
}
@ -674,10 +674,7 @@ body.immersive-active #immersive-main-content-area .container {
flex-direction: column !important;
}
/* 沉浸模式下显示 PDF 对照按钮(用户要求在沉浸模式中也能使用) */
body.immersive-active #tab-pdf-compare {
display: flex !important;
}
/* PDF 对照按钮已隐藏(用户要求页面始终沉浸模式,无需此功能) */
/* Modal 和右键菜单的 z-index 提升 */
body.immersive-active .modal-overlay {

View File

@ -54,6 +54,11 @@ body {
border-radius: 0; /* 移除圆角,纯下划线风格 */
}
/* 隐藏的 Tab 按钮(用于中文文档隐藏翻译相关功能) */
.tab-btn.hidden-tab {
display: none !important;
}
.tab-btn:hover:not(.active) {
color: var(--slate-700);
background: transparent; /* 移除悬停背景,保持极简 */

View File

@ -22,7 +22,7 @@
/* ==================== Chatbot Modal Window (High-end Minimalist) ==================== */
.chatbot-window {
background: var(--color-bg-base); /* Pure white background */
max-width: 720px;
width: 92vw;
min-height: 520px;
@ -30,7 +30,6 @@
border-radius: 24px;
/* The Shadow Fix: Using variable for consistency and lightness */
box-shadow: var(--shadow-modal);
border: 1px solid var(--color-border-light); /* Very subtle border */
position: absolute;
@ -86,6 +85,7 @@
scroll-behavior: smooth;
position: relative;
z-index: 0;
margin-bottom: 20px;
}
/*
@ -291,13 +291,13 @@ body.dark #chatbot-toast {
/* 用户消息容器 */
.user-message-container {
justify-content: flex-end;
padding-left: 20%;
/* padding-left: 20%; */
}
/* 助手消息容器 */
.assistant-message-container {
justify-content: flex-start;
padding-right: 10%; /* 减少右侧留白,利用更多空间 */
/* padding-right: 10%; */
/*
IMPORTANT: No top padding here anymore.
@ -344,6 +344,7 @@ body.dark #chatbot-toast {
.chat-bubble.assistant {
background: transparent;
color: var(--color-text-primary);
width: 100%;
border-radius: 0;
border: none;
box-shadow: none;
@ -381,10 +382,17 @@ body.dark #chatbot-toast {
/* ==================== 思考过程块样式 (Redesigned) ==================== */
.reasoning-block {
background: rgba(248, 250, 252, 0.6); /* Slate-50 with opacity */
display: inline-flex;
padding: 10px 15px;
width: 100%;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 15px;
max-width: 100%;
background: #F8F9FA; /* Slate-50 with opacity */
color: var(--slate-500);
padding: 8px 12px;
border: 1px solid var(--slate-200); /* 更细腻的边框 */
border-radius: 8px; /* 圆角 */
/*
margin-top removed because .assistant-message padding handles the offset now.
@ -392,58 +400,52 @@ body.dark #chatbot-toast {
*/
margin: 0 0 16px 0;
position: relative;
font-size: 13px; /* 稍微小一点的字体 */
}
.reasoning-header {
display: flex;
width: 100%;
display: inline-flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.reasoning-title {
font-weight: 600;
font-size: 12px;
color: var(--slate-400);
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 6px;
}
.reasoning-title::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
background: var(--slate-300);
border-radius: 50%;
.reasoning-title {
color: var(--666666, #666);
font-family: "Microsoft YaHei";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
.reasoning-toggle-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--slate-400);
font-size: 12px;
transition: color var(--transition-fast);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.reasoning-toggle-btn:hover {
color: var(--slate-600);
.reasoning-toggle-icon {
display: block;
transform: rotate(180deg);
transition: transform var(--transition-fast);
}
.reasoning-block.is-collapsed .reasoning-toggle-icon {
transform: rotate(0deg);
}
.reasoning-content {
margin-top: 8px;
color: var(--slate-600);
font-size: 13px;
line-height: 1.6;
padding-top: 4px;
border-top: 1px solid var(--slate-100); /* 内部细线分隔 */
font-family: var(--font-family-monospace); /* 使用等宽字体或更加技术感的字体 */
color: var(--999999, #999);
font-family: "Microsoft YaHei";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 21px;
}
/* ==================== 输入指示器动画 ==================== */
@ -492,9 +494,14 @@ body.dark #chatbot-toast {
/* 输入区容器 */
.chatbot-input-container {
padding: 16px 24px 20px 24px;
height: 160px;
display: flex;
margin: 0 20px 20px;
border-radius: 10px;
flex-direction: column;
padding: 10px;
border-top: 1px solid rgba(0, 0, 0, 0.03);
background: rgba(255, 255, 255, 0.8); /* 稍微降低不透明度以增强毛玻璃感 */
background: #F8F9FA; /* 稍微降低不透明度以增强毛玻璃感 */
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
flex-shrink: 0;
@ -505,9 +512,9 @@ body.dark #chatbot-toast {
/* 输入区内部 Wrapper */
.chatbot-input-wrapper {
display: flex;
align-items: flex-end;
gap: 10px;
background: rgba(249, 250, 251, 0.8); /* var(--slate-50) with opacity */
flex: 1;
border: 1px solid transparent;
border-radius: 16px;
padding: 8px 8px 8px 14px;
@ -516,14 +523,107 @@ body.dark #chatbot-toast {
}
.chatbot-input-wrapper:focus-within {
background: white;
border-color: var(--slate-200); /* 柔和的边框颜色,避免刺眼的蓝线 */
/* background: white; */
/* border-color: var(--slate-200); 柔和的边框颜色,避免刺眼的蓝线 */
/*
使用更柔和的扩散阴影而不是实心光晕
原先的 0 0 0 3px 会产生类似边框的实心线效果
改为 0 0 0 4px rgba(...) 并降低透明度使其更像光晕
*/
box-shadow: 0 0 0 4px rgba(224, 231, 255, 0.4);
/* box-shadow: 0 0 0 4px rgba(224, 231, 255, 0.4); */
}
/* ==================== Floating Options (Switch/Selector) ==================== */
#chatbot-floating-options .chatbot-floating-option {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 6px;
border-radius: 8px;
user-select: none;
}
#chatbot-floating-options .chatbot-floating-option-label {
font-size: 11px;
color: #4b5563;
}
#chatbot-floating-options .chatbot-floating-option.is-active {
background: rgba(0, 15, 51, 0.08);
}
#chatbot-floating-options .chatbot-floating-option.is-active .chatbot-floating-option-label {
color: #000F33;
font-weight: 600;
}
/* Switch */
#chatbot-floating-options .chatbot-floating-option-switch {
cursor: pointer;
}
#chatbot-floating-options .chatbot-switch {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
}
#chatbot-floating-options .chatbot-switch-input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
#chatbot-floating-options .chatbot-switch-track {
position: absolute;
inset: 0;
background: rgba(148, 163, 184, 0.55);
border-radius: 999px;
transition: background 0.2s ease;
}
#chatbot-floating-options .chatbot-switch-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
transition: transform 0.2s ease;
}
#chatbot-floating-options .chatbot-switch-input:checked + .chatbot-switch-track {
background: #000F33;
}
#chatbot-floating-options .chatbot-switch-input:checked + .chatbot-switch-track .chatbot-switch-thumb {
transform: translateX(14px);
}
/* Selector */
#chatbot-floating-options .chatbot-floating-option-select {
cursor: default;
}
#chatbot-floating-options .chatbot-floating-selector {
font-size: 11px;
height: 22px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(255, 255, 255, 0.75);
color: #334155;
padding: 0 8px;
outline: none;
}
#chatbot-floating-options .chatbot-floating-option.is-active .chatbot-floating-selector {
border-color: #000F33;
}
/* 输入框 */
@ -784,4 +884,4 @@ body.dark #chatbot-toast {
.markdown-content table::-webkit-scrollbar-thumb {
background: var(--slate-300);
border-radius: 2px;
}
}

View File

@ -159,19 +159,19 @@
background: none;
border: none;
cursor: pointer;
padding: 2px 6px;
color: #64748b;
font-size: 15px;
transition: all var(--transition-fast) ease;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transition: opacity var(--transition-fast) ease;
}
.reasoning-toggle-btn:hover {
color: #475569;
transform: scale(1.1);
opacity: 0.8;
}
.reasoning-toggle-btn:active {
transform: scale(0.95);
opacity: 0.65;
}
/* ==================== 9. 思维导图打开按钮 ==================== */

View File

@ -61,10 +61,7 @@
}
/* 将标题和 meta 信息合并到一行 */
#immersive-main-content-area .container > h2 {
display: flex;
align-items: center;
justify-content: space-between;
#immersive-main-content-area .container #fileName {
margin: 0;
padding: 0;
gap: 12px;
@ -289,7 +286,7 @@
overflow: hidden !important;
display: flex;
flex-direction: column;
background: var(--immersive-surface-bg) !important;
/* background: var(--immersive-surface-bg) !important; */
border: none !important;
transition: all var(--immersive-transition-duration) ease !important;
}
@ -631,7 +628,7 @@ body.toc-dock-resizing #toc-vs-dock-resize-handle::before {
}
/* 第一行h2 和 meta 在同一行 - 极简设计 */
#immersive-main-content-area .container > h2 {
#immersive-main-content-area .container h2 {
grid-row: 1;
grid-column: 1;
height: 24px;

3248
index.html

File diff suppressed because it is too large Load Diff

View File

@ -90,7 +90,7 @@ async function uploadToMistral(fileToProcess, mistralKey) {
*
* @param {File|Blob} fileToProcess - 需要上传的 File Blob 对象
* @param {string} fileName - 文件名
* @returns {Promise<string>} 上传成功后返回 OSS 的公网访问 URL
* @returns {Promise<{url: string, key: string}>} 上传成功后返回 OSS 的公网访问 URL key
*/
async function uploadFileToOssViaProxy(fileToProcess, fileName) {
const formData = new FormData();
@ -115,7 +115,7 @@ async function uploadFileToOssViaProxy(fileToProcess, fileName) {
if (!resultData || !resultData.url) {
throw new Error('上传成功但未返回有效的URL');
}
return resultData.url;
return { url: resultData.url, key: resultData.key };
}
/**

View File

@ -720,12 +720,28 @@ function setupEventListeners() {
saveCurrentSettings();
});
// 文件上传
dropZone.addEventListener('dragover', handleDragOver);
dropZone.addEventListener('dragleave', handleDragLeave);
dropZone.addEventListener('drop', handleDrop);
browseBtn.addEventListener('click', () => { if (!isProcessing) fileInput.click(); });
fileInput.addEventListener('change', handleFileSelect);
// 文件上传
dropZone.addEventListener('dragover', handleDragOver);
dropZone.addEventListener('dragleave', handleDragLeave);
dropZone.addEventListener('drop', handleDrop);
dropZone.addEventListener('click', (event) => {
if (isProcessing) return;
// 点击上传区空白区域时触发文件选择;避免与内部按钮重复触发。
if (event.target.closest('button, a, input, label, select, textarea')) return;
if (browseBtn) {
browseBtn.click();
return;
}
if (fileInput) {
fileInput.click();
}
});
if (browseBtn && fileInput) {
browseBtn.addEventListener('click', () => { if (!isProcessing) fileInput.click(); });
}
if (fileInput) {
fileInput.addEventListener('change', handleFileSelect);
}
if (browseFolderBtn && folderInput) {
browseFolderBtn.addEventListener('click', () => { if (!isProcessing) folderInput.click(); });
}
@ -1103,14 +1119,14 @@ async function extractFilesFromDataTransfer(dataTransfer) {
return fallbackSnapshot;
}
function refreshFormatFilters() {
const container = document.getElementById('fileFormatFilters');
if (!container) return;
const counts = new Map();
pdfFiles.forEach(file => {
const ext = deriveExtension(file.name || '') || '';
counts.set(ext, (counts.get(ext) || 0) + 1);
});
function refreshFormatFilters() {
const container = document.getElementById('fileFormatFilters');
if (!container) return;
const counts = new Map();
pdfFiles.forEach(file => {
const ext = deriveExtension(file.name || '') || '';
counts.set(ext, (counts.get(ext) || 0) + 1);
});
// 清理不存在的扩展
Array.from(excludedExtensions).forEach(ext => {
if (!counts.has(ext)) {
@ -1125,23 +1141,39 @@ function refreshFormatFilters() {
const fragments = [];
const entries = Array.from(counts.entries()).sort((a, b) => a[0].localeCompare(b[0]));
entries.forEach(([ext, count]) => {
const checked = !isExtensionExcluded(ext) ? 'checked' : '';
const label = ext ? ext.toUpperCase() : '未知';
// XSS 防护:转义文件扩展名,防止恶意文件名注入
const safeExt = escapeHtml(ext);
const safeLabel = escapeHtml(label);
fragments.push(`
<label class="flex items-center space-x-1 bg-white border border-gray-200 rounded px-2 py-1 shadow-sm">
<input type="checkbox" class="format-filter-checkbox" data-ext="${safeExt}" ${checked}>
<span>${safeLabel} <span class="text-gray-400">(${count})</span></span>
</label>
entries.forEach(([ext, count]) => {
const checked = !isExtensionExcluded(ext) ? 'checked' : '';
const label = ext ? ext.toUpperCase() : '未知';
// XSS 防护:转义文件扩展名,防止恶意文件名注入
const safeExt = escapeHtml(ext);
const safeLabel = escapeHtml(label);
if ((ext || '').toLowerCase() === 'pdf') {
excludedExtensions.delete(ext);
fragments.push(`
<!--
<label class="flex items-center space-x-1 bg-white border border-gray-200 rounded px-2 py-1 shadow-sm">
<input type="checkbox" class="format-filter-checkbox" data-ext="${safeExt}" ${checked}>
<span>${safeLabel} <span class="text-gray-400">(${count})</span></span>
</label>
-->
`);
return;
}
fragments.push(`
<label class="flex items-center space-x-1 bg-white border border-gray-200 rounded px-2 py-1 shadow-sm">
<input type="checkbox" class="format-filter-checkbox" data-ext="${safeExt}" ${checked}>
<span>${safeLabel} <span class="text-gray-400">(${count})</span></span>
</label>
`);
});
// fragments.push('<div class="flex-grow"></div><button type="button" id="resetFormatFilters" class="text-xs text-blue-600">重置</button>');
container.innerHTML = `<div class="flex flex-wrap gap-2 items-center">${fragments.join('')}</div>`;
}
// fragments.push('<div class="flex-grow"></div><button type="button" id="resetFormatFilters" class="text-xs text-blue-600">重置</button>');
if (fragments.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = `<div class="flex flex-wrap gap-2 items-center">${fragments.join('')}</div>`;
}
function getActiveFiles() {
return pdfFiles.filter(file => {

View File

@ -110,8 +110,31 @@ window.ChatbotActions = {
// 调用上传
console.log('[handleProvidePdf] 开始上传到 OSS...');
const ossUrl = await window.uploadFileToOssViaProxy(pdfFileContent, `${currentDocName}`);
console.log('[handleProvidePdf] OSS 上传成功, URL:', ossUrl);
const { url: ossUrl, key: ossKey } = await window.uploadFileToOssViaProxy(pdfFileContent, `${currentDocName}`);
console.log('[handleProvidePdf] OSS 上传成功, URL:', ossUrl, 'Key:', ossKey);
// 保存 OSS URL 到数据库
if (window.storageAdapter && typeof window.storageAdapter.saveOssUrl === 'function') {
try {
await window.storageAdapter.saveOssUrl(dbDocId, ossUrl, ossKey);
console.log('[handleProvidePdf] OSS URL 已保存到数据库');
} catch (saveErr) {
console.warn('[handleProvidePdf] 保存 OSS URL 到数据库失败:', saveErr);
}
} else {
// 直接调用 API
try {
const apiUrl = (window.PBX_PROXY_BASE_URL || '/api') + `/documents/${dbDocId}/oss`;
await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ossUrl, ossKey })
});
console.log('[handleProvidePdf] OSS URL 已通过 API 保存');
} catch (apiErr) {
console.warn('[handleProvidePdf] 通过 API 保存 OSS URL 失败:', apiErr);
}
}
// 构建包含 file_url 的消息内容(智谱 AI 格式)
const userMessageContent = [

View File

@ -14,11 +14,8 @@ if (typeof window.ChatbotFloatingOptionsScriptLoaded === 'undefined') {
const _chatbotOptionsConfig = [
{ key: 'semanticGroups', texts: ['意群'], title: '查看/搜索意群', activeStyleColor: '#059669', isAction: true },
{ key: 'useContext', texts: ['上下文:关', '上下文:开'], values: [false, true], title: '切换是否使用对话历史', activeStyleColor: '#1d4ed8' },
{ key: 'useReActMode', texts: ['ReAct'], activeStyleColor: '#9ca3af', isDisabled: true, title: 'ReAct框架开发中推理+工具调用交织,智能动态构建上下文' },
{ key: 'multiHopRetrieval', texts: ['检索Agent:关', '检索Agent:开'], values: [false, true], defaultKey: false, title: '开启后自动启用:多轮取材+流式显示+意群分析+向量搜索+重排', activeStyleColor: '#059669' },
{ key: 'summarySource', texts: ['提供全文:OCR', '提供全文:无', '提供全文:翻译'], values: ['ocr', 'none', 'translation'], defaultKey: 'ocr', title: '切换总结时使用的文本源 (OCR/不使用文档内容/翻译)', activeStyleColor: '#1d4ed8' },
{ key: 'interestPointsActive', texts: ['兴趣点'], activeStyleColor: '#059669', isPlaceholder: true, title: '兴趣点功能 (待实现)' },
{ key: 'memoryManagementActive', texts: ['记忆管理'], activeStyleColor: '#059669', isPlaceholder: true, title: '记忆管理功能 (待实现)' }
{ key: 'summarySource', texts: ['提供全文:OCR', '提供全文:无', '提供全文:翻译'], values: ['ocr', 'none', 'translation'], defaultKey: 'ocr', title: '切换总结时使用的文本源 (OCR/不使用文档内容/翻译)', activeStyleColor: '#1d4ed8' }
];
/**
@ -26,16 +23,16 @@ if (typeof window.ChatbotFloatingOptionsScriptLoaded === 'undefined') {
* @param {HTMLElement} parentElement - 选项栏将被添加到的父容器
* @param {function} globalUpdateUICallback - 全局UI更新回调函数例如 window.ChatbotUI.updateChatbotUI
*/
function _createFloatingOptionsBar(parentElement, globalUpdateUICallback) {
if (!parentElement || document.getElementById('chatbot-floating-options')) {
return; // 如果父元素不存在或选项栏已存在,则不重复创建
}
function _createFloatingOptionsBar(parentElement, globalUpdateUICallback) {
if (!parentElement || document.getElementById('chatbot-floating-options')) {
return; // 如果父元素不存在或选项栏已存在,则不重复创建
}
const floatingOptionsContainer = document.createElement('div');
floatingOptionsContainer.id = 'chatbot-floating-options';
floatingOptionsContainer.style.cssText = `
display: flex;
justify-content: center;
justify-content: space-between;
align-items: center;
padding: 4px 0 8px 0;
gap: 5px;
@ -44,115 +41,197 @@ if (typeof window.ChatbotFloatingOptionsScriptLoaded === 'undefined') {
flex-wrap: wrap;
`;
_chatbotOptionsConfig.forEach((optConf, index) => {
const optionButton = document.createElement('button');
optionButton.id = `chatbot-option-${optConf.key}`;
optionButton.style.cssText = `
background: none;
border: none;
color: #4b5563;
cursor: pointer;
padding: 2px 4px;
font-size: 11px;
border-radius: 4px;
transition: background-color 0.2s, color 0.2s;
`;
optionButton.title = optConf.title;
_chatbotOptionsConfig.forEach((optConf, index) => {
const optionEl = (function createOptionElement() {
// useContextswitch 组件(替代“上下文:开/关”文字)
if (optConf.key === 'useContext') {
const wrapper = document.createElement('label');
wrapper.id = `chatbot-option-${optConf.key}`;
wrapper.className = 'chatbot-floating-option chatbot-floating-option-switch';
wrapper.title = optConf.title;
const labelSpan = document.createElement('span');
labelSpan.className = 'chatbot-floating-option-label';
labelSpan.textContent = '上下文';
const switchWrap = document.createElement('span');
switchWrap.className = 'chatbot-switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.id = 'chatbot-use-context-switch';
input.className = 'chatbot-switch-input';
window.chatbotActiveOptions = window.chatbotActiveOptions || {};
input.checked = !!window.chatbotActiveOptions.useContext;
wrapper.classList.toggle('is-active', !!window.chatbotActiveOptions.useContext);
const track = document.createElement('span');
track.className = 'chatbot-switch-track';
const thumb = document.createElement('span');
thumb.className = 'chatbot-switch-thumb';
track.appendChild(thumb);
switchWrap.appendChild(input);
switchWrap.appendChild(track);
wrapper.appendChild(labelSpan);
wrapper.appendChild(switchWrap);
input.addEventListener('change', function() {
window.chatbotActiveOptions = window.chatbotActiveOptions || {};
window.chatbotActiveOptions.useContext = !!input.checked;
wrapper.classList.toggle('is-active', !!input.checked);
if (typeof globalUpdateUICallback === 'function') globalUpdateUICallback();
else console.error("ChatbotFloatingOptionsUI: globalUpdateUICallback is not provided or not a function.");
});
return wrapper;
}
// summarySourceselector 组件(替代“提供全文:xxx”循环切换按钮
if (optConf.key === 'summarySource') {
const wrapper = document.createElement('div');
wrapper.id = `chatbot-option-${optConf.key}`;
wrapper.className = 'chatbot-floating-option chatbot-floating-option-select';
wrapper.title = optConf.title;
const labelSpan = document.createElement('span');
labelSpan.className = 'chatbot-floating-option-label';
labelSpan.textContent = '提供全文';
const select = document.createElement('select');
select.id = 'chatbot-summary-source-select';
select.className = 'chatbot-floating-selector';
optConf.values.forEach((val, idx) => {
const opt = document.createElement('option');
opt.value = val;
const rawText = optConf.texts[idx] || String(val);
opt.textContent = rawText.replace(/^提供全文:/, '');
select.appendChild(opt);
});
window.chatbotActiveOptions = window.chatbotActiveOptions || {};
select.value = window.chatbotActiveOptions.summarySource || optConf.defaultKey || optConf.values[0];
wrapper.classList.toggle('is-active', select.value !== optConf.defaultKey);
select.addEventListener('change', function() {
window.chatbotActiveOptions = window.chatbotActiveOptions || {};
window.chatbotActiveOptions.summarySource = select.value;
wrapper.classList.toggle('is-active', select.value !== optConf.defaultKey);
if (typeof globalUpdateUICallback === 'function') globalUpdateUICallback();
else console.error("ChatbotFloatingOptionsUI: globalUpdateUICallback is not provided or not a function.");
});
wrapper.appendChild(labelSpan);
wrapper.appendChild(select);
return wrapper;
}
// 默认:沿用按钮
const optionButton = document.createElement('button');
optionButton.id = `chatbot-option-${optConf.key}`;
optionButton.style.cssText = `
background: none;
border: none;
color: #4b5563;
cursor: pointer;
padding: 2px 4px;
font-size: 11px;
border-radius: 4px;
transition: background-color 0.2s, color 0.2s;
`;
optionButton.title = optConf.title;
optionButton.onclick = function() {
window.chatbotActiveOptions = window.chatbotActiveOptions || {};
if (optConf.isDisabled) {
console.log(`${optConf.key} clicked, but is disabled.`);
if (typeof ChatbotUtils !== 'undefined' && ChatbotUtils.showToast) {
ChatbotUtils.showToast(`${optConf.texts[0]} 功能开发中,暂不可用。`, 'info', 2000);
} else {
alert(`${optConf.texts[0]} 功能开发中,暂不可用。`);
}
return; // 阻止后续逻辑执行
}
if (optConf.isPlaceholder) {
console.log(`${optConf.key} clicked, placeholder for future feature.`);
if (typeof ChatbotUtils !== 'undefined' && ChatbotUtils.showToast) {
ChatbotUtils.showToast(`${optConf.texts[0]} 功能正在开发中。`, 'info', 2000);
} else {
alert(`${optConf.texts[0]} 功能正在开发中。`);
}
} else if (optConf.isAction && optConf.key === 'semanticGroups') {
if (window.SemanticGroupsUI && typeof window.SemanticGroupsUI.toggle === 'function') {
window.SemanticGroupsUI.toggle();
} else if (typeof ChatbotUtils !== 'undefined' && ChatbotUtils.showToast) {
ChatbotUtils.showToast('意群面板未就绪', 'warning', 2000);
} else {
alert('意群面板未就绪');
}
} else {
const currentValue = window.chatbotActiveOptions[optConf.key];
if (optConf.key === 'contentLengthStrategy') {
window.chatbotActiveOptions.contentLengthStrategy = currentValue === optConf.values[0] ? optConf.values[1] : optConf.values[0];
} else if (optConf.key === 'multiHopRetrieval') {
window.chatbotActiveOptions.multiHopRetrieval = !currentValue;
// 传统多轮检索开启时自动关闭ReAct模式避免冲突
if (window.chatbotActiveOptions.multiHopRetrieval) {
window.chatbotActiveOptions.useReActMode = false;
}
} else if (optConf.key === 'streamingRetrieval') {
window.chatbotActiveOptions.streamingRetrieval = !currentValue;
}
}
if (typeof globalUpdateUICallback === 'function') {
globalUpdateUICallback(); // 更新UI以反映选项变化
} else {
console.error("ChatbotFloatingOptionsUI: globalUpdateUICallback is not provided or not a function.");
}
};
return optionButton;
})();
floatingOptionsContainer.appendChild(optionEl);
if (index < _chatbotOptionsConfig.length - 1) {
const nextOptConf = _chatbotOptionsConfig[index + 1];
const separator = document.createElement('span');
separator.id = `chatbot-separator-${nextOptConf.key}`;
separator.textContent = '丨';
separator.style.color = '#cbd5e1';
separator.style.margin = '0 2px';
floatingOptionsContainer.appendChild(separator);
}
});
optionButton.onclick = function() {
if (optConf.isDisabled) {
console.log(`${optConf.key} clicked, but is disabled.`);
if (typeof ChatbotUtils !== 'undefined' && ChatbotUtils.showToast) {
ChatbotUtils.showToast(`${optConf.texts[0]} 功能开发中,暂不可用。`, 'info', 2000);
} else {
alert(`${optConf.texts[0]} 功能开发中,暂不可用。`);
}
return; // 阻止后续逻辑执行
}
if (optConf.isPlaceholder) {
console.log(`${optConf.key} clicked, placeholder for future feature.`);
if (typeof ChatbotUtils !== 'undefined' && ChatbotUtils.showToast) {
ChatbotUtils.showToast(`${optConf.texts[0]} 功能正在开发中。`, 'info', 2000);
} else {
alert(`${optConf.texts[0]} 功能正在开发中。`);
}
} else if (optConf.isAction && optConf.key === 'semanticGroups') {
if (window.SemanticGroupsUI && typeof window.SemanticGroupsUI.toggle === 'function') {
window.SemanticGroupsUI.toggle();
} else if (typeof ChatbotUtils !== 'undefined' && ChatbotUtils.showToast) {
ChatbotUtils.showToast('意群面板未就绪', 'warning', 2000);
} else {
alert('意群面板未就绪');
}
} else {
const currentValue = window.chatbotActiveOptions[optConf.key];
if (optConf.key === 'useContext') {
window.chatbotActiveOptions.useContext = !currentValue;
} else if (optConf.key === 'useReActMode') {
window.chatbotActiveOptions.useReActMode = !currentValue;
// ReAct模式开启时自动关闭传统多轮检索避免冲突
if (window.chatbotActiveOptions.useReActMode) {
window.chatbotActiveOptions.multiHopRetrieval = false;
}
} else if (optConf.key === 'contentLengthStrategy') {
window.chatbotActiveOptions.contentLengthStrategy = currentValue === optConf.values[0] ? optConf.values[1] : optConf.values[0];
} else if (optConf.key === 'multiHopRetrieval') {
window.chatbotActiveOptions.multiHopRetrieval = !currentValue;
// 传统多轮检索开启时自动关闭ReAct模式避免冲突
if (window.chatbotActiveOptions.multiHopRetrieval) {
window.chatbotActiveOptions.useReActMode = false;
}
} else if (optConf.key === 'streamingRetrieval') {
window.chatbotActiveOptions.streamingRetrieval = !currentValue;
} else if (optConf.key === 'summarySource') {
const currentIndex = optConf.values.indexOf(currentValue);
const nextIndex = (currentIndex + 1) % optConf.values.length;
window.chatbotActiveOptions.summarySource = optConf.values[nextIndex];
}
}
if (typeof globalUpdateUICallback === 'function') {
globalUpdateUICallback(); // 更新UI以反映选项变化
} else {
console.error("ChatbotFloatingOptionsUI: globalUpdateUICallback is not provided or not a function.");
}
};
floatingOptionsContainer.appendChild(optionButton);
if (index < _chatbotOptionsConfig.length - 1) {
const nextOptConf = _chatbotOptionsConfig[index + 1];
const separator = document.createElement('span');
separator.id = `chatbot-separator-${nextOptConf.key}`;
separator.textContent = '丨';
separator.style.color = '#cbd5e1';
separator.style.margin = '0 2px';
floatingOptionsContainer.appendChild(separator);
}
});
// 将创建的选项栏插入到父容器的合适位置
const selectedImagesPreview = parentElement.querySelector('#chatbot-selected-images-preview');
if (selectedImagesPreview) {
parentElement.insertBefore(floatingOptionsContainer, selectedImagesPreview);
// 将创建的选项栏插入到父容器的合适位置放在输入框容器chatbot-input-wrapper后面
const inputWrapper = parentElement.querySelector('#chatbot-input-wrapper, .chatbot-input-wrapper');
if (inputWrapper && inputWrapper.parentNode === parentElement) {
parentElement.insertBefore(floatingOptionsContainer, inputWrapper.nextSibling);
} else if (inputWrapper && inputWrapper.parentNode) {
inputWrapper.parentNode.insertBefore(floatingOptionsContainer, inputWrapper.nextSibling);
} else {
const mainInputDiv = parentElement.querySelector('div[style*="display:flex;align-items:center;gap:12px;"]');
if (mainInputDiv) {
parentElement.insertBefore(floatingOptionsContainer, mainInputDiv);
} else {
parentElement.appendChild(floatingOptionsContainer); // Fallback
}
parentElement.appendChild(floatingOptionsContainer); // Fallback
}
}
/**
* 更新浮动高级选项栏中各个按钮的显示状态文本样式可见性
*/
function _updateFloatingOptionsDisplay() {
const floatingOptionsContainer = document.getElementById('chatbot-floating-options');
if (!floatingOptionsContainer) return;
function _updateFloatingOptionsDisplay() {
const floatingOptionsContainer = document.getElementById('chatbot-floating-options');
if (!floatingOptionsContainer) return;
window.chatbotActiveOptions = window.chatbotActiveOptions || {};
_chatbotOptionsConfig.forEach(optConf => {
const button = document.getElementById(`chatbot-option-${optConf.key}`);
// 注意分隔符ID是基于 *下一个* 选项的key来创建的所以查找ID为 chatbot-separator-NEXT_KEY
_chatbotOptionsConfig.forEach(optConf => {
const button = document.getElementById(`chatbot-option-${optConf.key}`);
// 注意分隔符ID是基于 *下一个* 选项的key来创建的所以查找ID为 chatbot-separator-NEXT_KEY
// 但是其显示与否是基于 *当前* 按钮的显示状态
if (button) {
@ -238,42 +317,42 @@ if (typeof window.ChatbotFloatingOptionsScriptLoaded === 'undefined') {
}
});
// 第三遍:更新按钮文本和样式
_chatbotOptionsConfig.forEach(optConf => {
const button = document.getElementById(`chatbot-option-${optConf.key}`);
// 第三遍:更新按钮文本和样式
_chatbotOptionsConfig.forEach(optConf => {
const button = document.getElementById(`chatbot-option-${optConf.key}`);
if (!button || button.style.display === 'none') {
return; // 跳过不可见的按钮
}
if (!button || button.style.display === 'none') {
return; // 跳过不可见的按钮
}
const currentOptionValue = window.chatbotActiveOptions[optConf.key];
let currentText = '';
let color = '#4b5563';
let fontWeight = 'normal';
const currentOptionValue = window.chatbotActiveOptions[optConf.key];
let currentText = '';
let color = '#4b5563';
let fontWeight = 'normal';
let isActiveStyle = false;
if (optConf.isAction && optConf.key === 'semanticGroups') {
const count = (window.data && Array.isArray(window.data.semanticGroups)) ? window.data.semanticGroups.length : 0;
currentText = count > 0 ? `意群(${count})` : '意群';
// 显示为激活风格以便更醒目(当有意群时)
if (count > 0) { color = optConf.activeStyleColor; fontWeight = '600'; isActiveStyle = true; }
} else if (optConf.isPlaceholder) {
currentText = optConf.texts[0];
} else if (optConf.isDisabled) {
// 禁用状态:固定显示灰色,无法切换
currentText = optConf.texts[0];
if (optConf.isAction && optConf.key === 'semanticGroups') {
const count = (window.data && Array.isArray(window.data.semanticGroups)) ? window.data.semanticGroups.length : 0;
currentText = count > 0 ? `意群(${count})` : '意群';
// 显示为激活风格以便更醒目(当有意群时)
if (count > 0) { color = optConf.activeStyleColor; fontWeight = '600'; isActiveStyle = true; }
} else if (optConf.key === 'useContext') {
const isOn = !!currentOptionValue;
const switchInput = document.getElementById('chatbot-use-context-switch');
if (switchInput) switchInput.checked = isOn;
button.classList.toggle('is-active', isOn);
return;
} else if (optConf.isPlaceholder) {
currentText = optConf.texts[0];
} else if (optConf.isDisabled) {
// 禁用状态:固定显示灰色,无法切换
currentText = optConf.texts[0];
color = '#9ca3af'; // 灰色
fontWeight = 'normal';
isActiveStyle = false;
button.style.opacity = '0.6'; // 降低透明度
button.style.cursor = 'not-allowed'; // 禁用光标
} else if (optConf.key === 'useContext') {
currentText = currentOptionValue ? optConf.texts[1] : optConf.texts[0];
if (currentOptionValue) { color = optConf.activeStyleColor; fontWeight = '600'; isActiveStyle = true; }
} else if (optConf.key === 'useReActMode') {
currentText = currentOptionValue ? optConf.texts[1] : optConf.texts[0];
if (currentOptionValue) { color = optConf.activeStyleColor; fontWeight = '600'; isActiveStyle = true; }
} else if (optConf.key === 'contentLengthStrategy') {
} else if (optConf.key === 'contentLengthStrategy') {
currentText = currentOptionValue === optConf.defaultKey ? optConf.texts[0] : optConf.texts[1];
if (currentOptionValue !== optConf.defaultKey) {
color = optConf.activeStyleColor; fontWeight = '600'; isActiveStyle = true;
@ -281,20 +360,19 @@ if (typeof window.ChatbotFloatingOptionsScriptLoaded === 'undefined') {
} else if (optConf.key === 'multiHopRetrieval') {
currentText = currentOptionValue ? optConf.texts[1] : optConf.texts[0];
if (currentOptionValue) { color = optConf.activeStyleColor; fontWeight = '600'; isActiveStyle = true; }
} else if (optConf.key === 'streamingRetrieval') {
currentText = currentOptionValue ? optConf.texts[1] : optConf.texts[0];
if (currentOptionValue) { color = optConf.activeStyleColor; fontWeight = '600'; isActiveStyle = true; }
} else if (optConf.key === 'summarySource') {
const currentIndex = optConf.values.indexOf(currentOptionValue);
currentText = optConf.texts[currentIndex] || optConf.texts[0];
// 对于 summarySource, 'ocr' (index 0) 是默认状态,'none' (index 1) 和 'translation' (index 2) 算作激活状态
if (currentOptionValue !== optConf.defaultKey) {
color = optConf.activeStyleColor; fontWeight = '600'; isActiveStyle = true;
}
}
button.textContent = currentText;
button.style.color = color;
button.style.fontWeight = fontWeight;
} else if (optConf.key === 'streamingRetrieval') {
currentText = currentOptionValue ? optConf.texts[1] : optConf.texts[0];
if (currentOptionValue) { color = optConf.activeStyleColor; fontWeight = '600'; isActiveStyle = true; }
} else if (optConf.key === 'summarySource') {
const select = document.getElementById('chatbot-summary-source-select');
const desiredValue = currentOptionValue || optConf.defaultKey || optConf.values[0];
if (select && select.value !== desiredValue) select.value = desiredValue;
button.classList.toggle('is-active', desiredValue !== optConf.defaultKey);
return;
}
button.textContent = currentText;
button.style.color = color;
button.style.fontWeight = fontWeight;
if (isActiveStyle) {
button.style.backgroundColor = 'rgba(59, 130, 246, 0.1)'; // 淡蓝色背景表示激活

View File

@ -310,6 +310,7 @@ window.ChatbotMessageRenderer = {
if (m.reasoningContent) {
const reasoningId = `reasoning-block-${index}`;
const collapsed = window[`reasoningCollapsed_${index}`] === true;
const collapsedClass = collapsed ? ' is-collapsed' : '';
let renderedReasoningContent = '';
try {
if (typeof renderWithKatexStreaming === 'function') {
@ -324,13 +325,17 @@ window.ChatbotMessageRenderer = {
// Phase 3: 思考过程折叠按钮事件委托
if (USE_EVENT_DELEGATION) {
reasoningBlock = `
<div id="${reasoningId}" class="reasoning-block">
<div id="${reasoningId}" class="reasoning-block${collapsedClass}">
<div class="reasoning-header">
<span class="reasoning-title">思考过程</span>
<button class="reasoning-toggle-btn"
data-action="toggle-reasoning"
data-index="${index}">
${collapsed ? '▼' : '▲'}
data-index="${index}"
aria-label="展开/收起思考过程"
aria-expanded="${collapsed ? 'false' : 'true'}">
<svg class="reasoning-toggle-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 6L8 10L4 6" stroke="#666666" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="reasoning-content" style="${collapsed ? 'display:none;' : ''}">
@ -341,11 +346,13 @@ window.ChatbotMessageRenderer = {
} else {
// 旧版本:内联事件
reasoningBlock = `
<div id="${reasoningId}" class="reasoning-block">
<div id="${reasoningId}" class="reasoning-block${collapsedClass}">
<div class="reasoning-header">
<span class="reasoning-title">思考过程</span>
<button class="reasoning-toggle-btn" onclick="(function(){window['reasoningCollapsed_${index}']=!window['reasoningCollapsed_${index}'];window.ChatbotUI.updateChatbotUI();})()">
${collapsed ? '▼' : '▲'}
<svg class="reasoning-toggle-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 6L8 10L4 6" stroke="#666666" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="reasoning-content" style="${collapsed ? 'display:none;' : ''}">

View File

@ -56,9 +56,9 @@ window.ChatbotPresetQuestionsUI = {
headerLeftGroup.style.display = 'flex';
headerLeftGroup.style.alignItems = 'center';
headerLeftGroup.style.gap = '8px';
headerLeftGroup.appendChild(presetTitle);
headerLeftGroup.appendChild(presetToggleBtn);
newPresetHeader.appendChild(headerLeftGroup);
// headerLeftGroup.appendChild(presetTitle);
// headerLeftGroup.appendChild(presetToggleBtn);
// newPresetHeader.appendChild(headerLeftGroup);
// 齿轮按钮(模型配置)- 始终显示
const gearBtn = document.createElement('button');
@ -92,8 +92,8 @@ window.ChatbotPresetQuestionsUI = {
updateChatbotUICallback(); // 调用主UI更新
}
};
newPresetHeader.appendChild(gearBtn);
presetContainer.appendChild(newPresetHeader);
// newPresetHeader.appendChild(gearBtn);
// presetContainer.appendChild(newPresetHeader);
const newPresetBody = document.createElement('div');
newPresetBody.id = 'chatbot-preset-body';
@ -114,31 +114,31 @@ window.ChatbotPresetQuestionsUI = {
// 填充预设问题按钮
const presetQuestions = (window.ChatbotPreset && window.ChatbotPreset.PRESET_QUESTIONS) ? window.ChatbotPreset.PRESET_QUESTIONS : [
'总结本文', '有哪些关键公式?', '研究背景与意义?', '研究方法及发现?',
'应用与前景?', '用通俗语言解释全文', '生成思维导图🧠', '生成流程图🔄',
'生成更多配图🎨'
];
presetQuestions.forEach(q => {
const button = document.createElement('button');
button.className = 'preset-btn';
// 使用 encodeURIComponent/decodeURIComponent 来处理特殊字符
button.onclick = function() {
const text = decodeURIComponent(encodeURIComponent(q));
// 对“生成更多配图”做特殊处理:只帮用户打出前缀 [加入配图],不直接发送复杂提示
if (text.startsWith('生成更多配图')) {
const input = document.getElementById('chatbot-input');
if (input) {
input.value = '[加入配图] ';
input.focus();
}
return;
}
handlePresetQuestionCallback(text);
};
button.textContent = q;
newPresetBody.appendChild(button);
});
// const presetQuestions = (window.ChatbotPreset && window.ChatbotPreset.PRESET_QUESTIONS) ? window.ChatbotPreset.PRESET_QUESTIONS : [
// '总结本文', '有哪些关键公式?', '研究背景与意义?', '研究方法及发现?',
// '应用与前景?', '用通俗语言解释全文', '生成思维导图🧠', '生成流程图🔄',
// '生成更多配图🎨'
// ];
// presetQuestions.forEach(q => {
// const button = document.createElement('button');
// button.className = 'preset-btn';
// // 使用 encodeURIComponent/decodeURIComponent 来处理特殊字符
// button.onclick = function() {
// const text = decodeURIComponent(encodeURIComponent(q));
// // 对“生成更多配图”做特殊处理:只帮用户打出前缀 [加入配图],不直接发送复杂提示
// if (text.startsWith('生成更多配图')) {
// const input = document.getElementById('chatbot-input');
// if (input) {
// input.value = '[加入配图] ';
// input.focus();
// }
// return;
// }
// handlePresetQuestionCallback(text);
// };
// button.textContent = q;
// newPresetBody.appendChild(button);
// });
// 自动收起逻辑 (依赖全局状态 window.isPresetQuestionsCollapsed, window.presetAutoCollapseTriggeredForDoc, window.isModelSelectorOpen 和 ChatbotCore)
let userMessageCount = 0;

View File

@ -353,8 +353,6 @@ function updateChatbotUI() {
}
const chatBody = document.getElementById('chatbot-body');
const chatbotPresetHeader = document.getElementById('chatbot-preset-header');
const chatbotPresetBody = document.getElementById('chatbot-preset-body');
let modelSelectorDiv = document.getElementById('chatbot-model-selector');
const existingPresetContainer = document.getElementById('chatbot-preset-container');
@ -383,42 +381,14 @@ function updateChatbotUI() {
console.error("Error getting chatbot config for UI:", e);
}
const presetContainer = window.ChatbotPresetQuestionsUI.render(
chatbotWindow,
isCustomModel,
currentDocId,
updateChatbotUI,
window.ChatbotPreset?.handlePresetQuestion || window.handlePresetQuestion
);
chatbotWindow.appendChild(presetContainer);
let userMessageCount = 0;
if (window.ChatbotCore && window.ChatbotCore.chatHistory) {
userMessageCount = window.ChatbotCore.chatHistory.filter(m => m.role === 'user').length;
}
if (userMessageCount >= 3 &&
!window.presetAutoCollapseTriggeredForDoc[currentDocId] &&
!window.isPresetQuestionsCollapsed &&
!window.isModelSelectorOpen) {
window.isPresetQuestionsCollapsed = true;
window.presetAutoCollapseTriggeredForDoc[currentDocId] = true;
const presetToggleBtn = presetContainer.querySelector('#chatbot-preset-toggle-btn');
if (presetToggleBtn) {
presetToggleBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>';
presetToggleBtn.title = "展开快捷指令";
}
}
// 按需禁用不渲染快捷指令chatbot-preset-container
// 仅确保历史遗留 DOM 被移除,避免占用空间或影响布局
// 获取 mainContentArea 元素
const mainContentArea = document.getElementById('chatbot-main-content-area');
if (mainContentArea) {
let current_padding_top_for_main_content_area = 12;
if (presetContainer && presetContainer.style.display !== 'none' && presetContainer.offsetHeight) {
current_padding_top_for_main_content_area = presetContainer.offsetHeight;
}
mainContentArea.style.paddingTop = current_padding_top_for_main_content_area + 'px';
// 仅在“固定模式”下根据内容自适应高度;浮动/全屏不改动用户设置的尺寸
@ -451,7 +421,6 @@ function updateChatbotUI() {
} else {
const existingModelSelectorDiv = document.getElementById('chatbot-model-selector');
if (existingModelSelectorDiv) existingModelSelectorDiv.remove();
if (presetContainer) presetContainer.style.display = '';
if (chatBody) chatBody.style.display = '';
}
if (chatBody) {
@ -1221,7 +1190,7 @@ function initChatbotUI() {
</button>
</div>
<!-- 标题栏 (可拖拽移动窗口) -->
<div id="chatbot-title-bar" class="chatbot-draggable-header" style="padding:12px 24px;display:flex;align-items:center;gap:8px;border-bottom:1px dashed rgba(0,0,0,0.1);flex-shrink:0;">
<div id="chatbot-title-bar" class="chatbot-draggable-header" style="padding:12px 24px;display:flex;justify-content:center;align-items:center;gap:8px;border-bottom:1px dashed rgba(0,0,0,0.1);flex-shrink:0;">
<div style="width:32px;height:32px;border-radius:16px;background:linear-gradient(135deg,#3b82f6,#1d4ed8);display:flex;align-items:center;justify-content:center;display:none;">
<i class="fa-solid fa-robot" style="font-size: 14px; color: white;"></i>
</div>
@ -1234,7 +1203,7 @@ function initChatbotUI() {
</div>
<!-- 输入区域容器 (Refactored) -->
<div id="chatbot-input-container" class="chatbot-input-container">
<!-- 浮动高级选项将由JS插入此处 -->
<!-- 浮动高级选项将由JS插入chatbot-input-wrapper 后面 -->
<!-- 已选图片预览区 -->
<div id="chatbot-selected-images-preview" class="chatbot-image-preview-area">
{/* 图片预览由 ChatbotImageUtils.updateSelectedImagesPreview 更新 */}
@ -1254,10 +1223,10 @@ function initChatbotUI() {
<iconify-icon icon="carbon:document-pdf" width="20"></iconify-icon>
</button>
<!-- 文本输入框 -->
<input id="chatbot-input" type="text" placeholder="请输入问题..."
<textarea id="chatbot-input" type="text" placeholder="请输入问题..."
class="chatbot-input-field"
onkeydown="if(event.key==='Enter'){window.handleChatbotSend();}"
/>
></textarea>
<!-- 发送按钮 -->
<button id="chatbot-send-btn"
class="chatbot-input-btn chatbot-send-btn"
@ -1281,12 +1250,13 @@ function initChatbotUI() {
</button>
</div>
<!-- 免责声明 -->
<div class="chatbot-disclaimer">
<p style="margin:0;">AI助手可能会犯错请核实重要信息</p>
</div>
</div>
</div>
`;
// <div class="chatbot-disclaimer">
// <p style="margin:0;">AI助手可能会犯错。请核实重要信息。</p>
// </div>
document.body.appendChild(modal);
}
@ -1296,6 +1266,44 @@ function initChatbotUI() {
window.ChatbotFloatingOptionsUI.createBar(inputContainerElement, updateChatbotUI);
}
// 将输入栏的核心按钮移动到浮动选项栏中
// 目标容器由 ChatbotFloatingOptionsUI.createBar 创建:#chatbot-floating-options
(function movePrimaryButtonsIntoFloatingOptions() {
const floatingOptionsContainer = document.getElementById('chatbot-floating-options');
if (!floatingOptionsContainer) return;
const addImageBtn = document.getElementById('chatbot-add-image-btn');
const providePdfBtn = document.getElementById('chatbot-provide-pdf-btn');
const sendBtn = document.getElementById('chatbot-send-btn');
// 1) 先把“添加图片/提供PDF”放到浮动选项栏最前面
const prefixFragment = document.createDocumentFragment();
for (const el of [addImageBtn, providePdfBtn]) {
if (!el) continue;
if (el.parentElement === floatingOptionsContainer) continue;
prefixFragment.appendChild(el);
}
if (prefixFragment.childNodes.length) {
floatingOptionsContainer.insertBefore(prefixFragment, floatingOptionsContainer.firstChild);
}
// 2) 再把“发送”放到 chatbot-option-summarySource 后面
if (!sendBtn) return;
if (sendBtn.parentElement === floatingOptionsContainer) {
// 仍然允许重新定位appendChild 会自动移动节点)
}
const summarySourceOptionBtn = document.getElementById('chatbot-option-summarySource');
if (summarySourceOptionBtn && summarySourceOptionBtn.parentElement === floatingOptionsContainer) {
if (summarySourceOptionBtn.nextSibling) {
floatingOptionsContainer.insertBefore(sendBtn, summarySourceOptionBtn.nextSibling);
} else {
floatingOptionsContainer.appendChild(sendBtn);
}
} else {
floatingOptionsContainer.appendChild(sendBtn);
}
})();
// --- 核心控制按钮事件绑定 ---
// 浮动模式切换按钮点击事件
document.getElementById('chatbot-float-toggle-btn').onclick = function() {

View File

@ -71,10 +71,10 @@ document.addEventListener('DOMContentLoaded', function() {
const timeStr = `${timeObj.getMonth() + 1}/${timeObj.getDate()} ${String(timeObj.getHours()).padStart(2, '0')}:${String(timeObj.getMinutes()).padStart(2, '0')}`;
return `
<div class="group flex items-center gap-2 px-2 py-1.5 text-[13px] text-[#000f33] hover:bg-[#000f33] hover:text-[#ffffff] transition-colors cursor-pointer rounded-md mx-2 mb-0.5" onclick="showHistoryDetail('${safeId}')" title="${name}\n${timeObj.toLocaleString()}">
<div class="group h-[58px] flex flex-col px-[15px] py-[10px] text-[13px] text-[#666666] hover:bg-[#E6E7EB] transition-colors cursor-pointer rounded-md mb-0.5" onclick="showHistoryDetail('${safeId}')" title="${name}\n${timeObj.toLocaleString()}">
<span class="truncate flex-1">${name}</span>
<span class="text-[10px] text-slate-400 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">${timeStr}</span>
<span class="text-[12px] text-[#999999] flex-shrink-0 ">${timeStr}</span>
</div>
`;
}).join('');
@ -273,14 +273,24 @@ document.addEventListener('DOMContentLoaded', function() {
if (historyClearMode === 'record') {
if (pendingDeleteRecordId) {
removeFolderAssignmentForRecord(pendingDeleteRecordId);
await deleteResultFromDB(pendingDeleteRecordId);
// 使用 storageAdapter 删除(支持后端模式)
if (window.storageAdapter && typeof window.storageAdapter.deleteResultFromDB === 'function') {
await window.storageAdapter.deleteResultFromDB(pendingDeleteRecordId);
} else if (typeof deleteResultFromDB === 'function') {
await deleteResultFromDB(pendingDeleteRecordId);
}
await renderHistoryList();
if (typeof showNotification === 'function') {
showNotification('历史记录已删除。', 'success');
}
}
} else {
await clearAllResultsFromDB();
// 使用 storageAdapter 清空(支持后端模式)
if (window.storageAdapter && typeof window.storageAdapter.clearAllResultsFromDB === 'function') {
await window.storageAdapter.clearAllResultsFromDB();
} else if (typeof clearAllResultsFromDB === 'function') {
await clearAllResultsFromDB();
}
clearFolderAssignments();
historyUIState.activeFolder = 'all';
historyUIState.searchQuery = '';
@ -844,18 +854,18 @@ document.addEventListener('DOMContentLoaded', function() {
function renderFolderListItem({ id, name, count, system }) {
const isActive = historyUIState.activeFolder === id;
const baseClasses = 'group flex items-center justify-between px-2 py-1.5 rounded-lg border transition-colors';
const activeClasses = isActive ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-transparent hover:bg-gray-100 text-gray-700';
const baseClasses = 'group flex items-center justify-between px-2 py-1.5 rounded-[5px] border transition-colors';
const activeClasses = isActive ? ' bg-[#E4E6EA] text-[#000F33]' : 'border-transparent hover:bg-gray-100 text-[#666666]';
const title = escapeAttr(name || '未命名');
const countBadge = `<span class="ml-2 inline-flex min-w-[1.5rem] justify-center rounded-full bg-gray-100 px-1.5 py-0.5 text-[11px] font-medium text-gray-500">${typeof count === 'number' ? count : 0}</span>`;
const selectButton = `
<button type="button" class="flex-1 text-left text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-300 focus-visible:ring-offset-1" data-folder-action="select" data-folder-id="${escapeAttr(id)}" title="查看${title}">
<button type="button" class="flex items-center justify-between flex-1 text-left text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-300 focus-visible:ring-offset-1" data-folder-action="select" data-folder-id="${escapeAttr(id)}" title="查看${title}">
<span>${escapeHtml(name || '未命名')}</span>
${countBadge}
</button>`;
const actionButtons = system ? '' : `
<div class="ml-2 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<button type="button" class="rounded p-1 text-gray-400 hover:text-blue-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-300" data-folder-action="rename" data-folder-id="${escapeAttr(id)}" title="重命名">
<button type="button" class="rounded p-1 text-gray-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-300" data-folder-action="rename" data-folder-id="${escapeAttr(id)}" title="重命名">
<iconify-icon icon="carbon:edit" width="14"></iconify-icon>
</button>
<button type="button" class="rounded p-1 text-gray-400 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-300" data-folder-action="delete" data-folder-id="${escapeAttr(id)}" title="删除">
@ -1049,10 +1059,10 @@ document.addEventListener('DOMContentLoaded', function() {
preserve: 'preserve',
flat: 'flat'
};
const ICON_BUTTON_CLASS = 'inline-flex items-center justify-center w-9 h-9 rounded-full border border-slate-200 bg-white text-gray-500 hover:text-blue-600 hover:border-blue-200 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-300 focus:ring-offset-1';
const ICON_BUTTON_CLASS = 'inline-flex items-center justify-center w-9 h-9 text-gray-500 hover:text-[#000F33] hover:border-[#E4E6EA]transition-colors focus:outline-none focus:ring-2 focus:ring-blue-300 focus:ring-offset-1';
const ICON_BUTTON_DANGER_EXTRA = 'hover:text-red-500 hover:border-red-200 focus:ring-red-300';
const ICON_BUTTON_SUCCESS_EXTRA = 'hover:text-emerald-500 hover:border-emerald-200 focus:ring-emerald-300';
const ICON_BUTTON_SUCCESS_EXTRA = '';
// hover:text-emerald-500 hover:border-emerald-200 focus:ring-emerald-300
const historyListElement = document.getElementById('historyList');
if (historyListElement) {
historyListElement.addEventListener('click', handleHistoryListAction);
@ -1212,39 +1222,36 @@ document.addEventListener('DOMContentLoaded', function() {
const escapedRecordId = escapeAttr(record.id || '');
const exportBtnHtml = `
<button type="button" class="${ICON_BUTTON_CLASS}" data-history-action="open-record-export" data-record-id="${escapeAttr(record.id)}" data-target="${configId}" aria-label="配置导出" title="配置导出">
<iconify-icon icon="carbon:share" width="18"></iconify-icon>
导出
</button>`;
const downloadBtnHtml = `
<button type="button" class="${ICON_BUTTON_CLASS} ${ICON_BUTTON_SUCCESS_EXTRA}" onclick="downloadHistoryRecord('${escapedRecordId}')" aria-label="下载记录" title="下载记录">
<iconify-icon icon="carbon:download" width="18"></iconify-icon>
下载
</button>`;
const recordDisplayName = record.name || relativePathLabel || record.id || '历史记录';
const escapedRecordNameAttr = escapeAttr(recordDisplayName);
const deleteBtnHtml = withinBatch ? '' : `
<button type="button" class="${ICON_BUTTON_CLASS} ${ICON_BUTTON_DANGER_EXTRA}" data-history-action="delete-record" data-record-id="${escapedRecordId}" data-record-name="${escapedRecordNameAttr}" aria-label="删除记录" title="删除记录">
<iconify-icon icon="carbon:trash-can" width="18"></iconify-icon>
删除
</button>`;
const startReadingBtnHtml = `
<button type="button" class="inline-flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onclick="showHistoryDetail('${escapedRecordId}')">
<iconify-icon icon="carbon:document-view" width="18"></iconify-icon>
<button type="button" class="inline-flex items-center gap-2 px-5 py-2 bg-[#000F33] text-white rounded-md " onclick="showHistoryDetail('${escapedRecordId}')">
<span>开始阅读</span>
</button>`;
const containerClasses = withinBatch
? 'border border-slate-200 rounded-xl p-3 bg-white shadow-sm hover:border-blue-200 transition'
: 'border border-slate-200 rounded-xl p-4 bg-white shadow-sm hover:border-blue-200 hover:shadow-md transition';
? 'rounded-[20px] p-3 bg-[#F8F9FA] hover:border-blue-200 transition'
: 'rounded-[20px] p-4 bg-[#F8F9FA] hover:border-blue-200 transition';
return `
<div class="${containerClasses}" id="history-item-${safeId}" data-record-id="${escapeAttr(record.id)}">
<div class="flex flex-col gap-1">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between md:gap-4">
<div class="min-w-0">
<div class="text-sm font-semibold text-gray-800 flex flex-wrap items-center gap-2 break-all">
<div class="text-sm text-[#333333] flex flex-wrap items-center gap-2 break-all">
<span>${escapeHtml(record.name || '未命名')}</span> ${statusBadge}
</div>
<div class="text-xs text-gray-500 mt-1">
${timeLabel}${targetLang ? ` · 语言:${escapeHtml(targetLang)}` : ''}
</div>
${(ocrLabel || (transLabel && transLabel !== '未翻译')) ? `
<div class="text-xs text-gray-500">
${ocrLabel ? `OCR${ocrLabel}` : ''}
@ -1259,7 +1266,6 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
<div class="hidden md:flex flex-wrap gap-2 text-xs text-gray-600 justify-end md:text-sm items-center">
${exportBtnHtml}
${startReadingBtnHtml}
${downloadBtnHtml}
${deleteBtnHtml}
</div>
@ -1272,7 +1278,6 @@ document.addEventListener('DOMContentLoaded', function() {
</summary>
<div class="mt-2 flex flex-wrap gap-2 text-xs text-gray-600">
${exportBtnHtml}
${startReadingBtnHtml}
${downloadBtnHtml}
${deleteBtnHtml}
</div>
@ -1280,10 +1285,16 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
<div class="text-xs text-gray-600 break-words">OCR${ocrSnippet}</div>
<div class="text-xs text-gray-600 break-words">翻译${translationSnippet}</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-600 mt-2">
<div class="flex flex-wrap justify-between items-center text-xs text-gray-600">
<div class="text-xs text-gray-500 mt-1">
${timeLabel}${targetLang ? ` · 语言:${escapeHtml(targetLang)}` : ''}
</div>
<div class="flex flex-wrap justify-between items-center gap-3">
<button id="retry-failed-btn-${safeId}" onclick="retryTranslateRecord('${record.id}','failed')" class="px-2 py-1 border border-gray-200 rounded hover:bg-gray-100 ${retryDisabled}">重试失败段</button>
<button id="retry-all-btn-${safeId}" onclick="retryTranslateRecord('${record.id}','all')" class="px-2 py-1 border border-gray-200 rounded hover:bg-gray-100">重新翻译全部</button>
${startReadingBtnHtml}
<span id="retry-status-${safeId}" class="text-xs text-gray-500"></span>
</div>
</div>
${renderExportConfigPanel({
id: configId,
@ -1491,22 +1502,22 @@ document.addEventListener('DOMContentLoaded', function() {
function buildStatusBadge(status) {
if (!status || status.total === 0) {
return '<span class="ml-2 inline-block text-[11px] px-2 py-0.5 rounded bg-gray-100 text-gray-500">未分块</span>';
return '<span class="ml-2 inline-block text-[11px] px-2 py-0.5 rounded bg-[#ffffff] text-gray-500">未分块</span>';
}
// 若没有任何成功块0/total改为“预览中无翻译块”
if (status.success === 0) {
// 结构化翻译下将提示文案替换为“PDF对照”
if (status.isStructured) {
return '<span class="ml-2 inline-block text-[11px] px-2 py-0.5 rounded bg-blue-100 text-blue-700">PDF对照</span>';
return '<span class="ml-2 inline-block text-[11px] px-2 py-0.5 rounded bg-[#ffffff] text-blue-700">PDF对照</span>';
}
return '<span class="ml-2 inline-block text-[11px] px-2 py-0.5 rounded bg-gray-100 text-gray-600">预览中,无翻译块</span>';
return '<span class="ml-2 inline-block text-[11px] px-2 py-0.5 rounded bg-[#ffffff] text-gray-600">预览中,无翻译块</span>';
}
// 有成功也有失败 → 部分失败
if (status.failed > 0) {
return `<span class="ml-2 inline-block text-[11px] px-2 py-0.5 rounded bg-amber-100 text-amber-700">部分失败 ${status.success}/${status.total}</span>`;
return `<span class="ml-2 inline-block text-[11px] px-2 py-0.5 rounded bg-[#ffffff] text-amber-700">部分失败 ${status.success}/${status.total}</span>`;
}
// 全部成功
return `<span class="ml-2 inline-block text-[11px] px-2 py-0.5 rounded bg-green-100 text-green-700">完成 ${status.success}/${status.total}</span>`;
return `<span class="ml-2 inline-block text-[11px] px-2 py-0.5 rounded bg-[#ffffff] text-green-700">完成 ${status.success}/${status.total}</span>`;
}
function buildSnippetText(text) {
@ -2194,7 +2205,12 @@ document.addEventListener('DOMContentLoaded', function() {
const records = batchCache[batchId];
if (!records || !records.length) return;
for (const record of records) {
await deleteResultFromDB(record.id);
// 使用 storageAdapter 删除(支持后端模式)
if (window.storageAdapter && typeof window.storageAdapter.deleteResultFromDB === 'function') {
await window.storageAdapter.deleteResultFromDB(record.id);
} else if (typeof deleteResultFromDB === 'function') {
await deleteResultFromDB(record.id);
}
removeFolderAssignmentForRecord(record.id);
}
}

View File

@ -1,3 +1,157 @@
/**
* 检测文本是否主要为中文
* 通过统计中文字符占比来判断
* @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
@ -64,6 +218,25 @@ async function renderDetail() {
// 兼容嵌套的 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 &&
@ -72,6 +245,36 @@ async function renderDetail() {
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()}`;
@ -128,6 +331,10 @@ async function renderDetail() {
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) {
@ -139,6 +346,11 @@ async function renderDetail() {
!(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';
@ -149,6 +361,10 @@ async function renderDetail() {
data.translation && data.translation.trim() !== ""
) {
initialTab = 'chunk-compare';
// 如果是中文文档,不显示分块对比
if (isChinese) {
initialTab = 'ocr';
}
}
} else if (hasOriginalPdf) {
// 如果有原始文件,优先显示原始文件
@ -160,6 +376,10 @@ async function renderDetail() {
data.translation && data.translation.trim() !== ""
) {
initialTab = 'chunk-compare';
// 如果是中文文档,不显示分块对比
if (isChinese) {
initialTab = 'ocr';
}
}
// 现在,在批注肯定加载完毕后,才调用 showTab

View File

@ -743,7 +743,8 @@ async function triggerReprocessWithMinerU() {
settings.defaultUserPromptTemplate || '',
settings.useCustomPrompts || false,
null, // batchContext
() => {} // onFileSuccess
() => {}, // onFileSuccess
docId // existingDocId - 更新现有文档而非创建新记录
);
if (result.error) {
@ -765,12 +766,7 @@ async function triggerReprocessWithMinerU() {
Object.assign(window.data.metadata, result.metadata);
}
// 保存到数据库
if (window.storageAdapter && typeof window.storageAdapter.saveResultToDB === 'function') {
await window.storageAdapter.saveResultToDB(window.data);
} else if (typeof saveResultToDB === 'function') {
await saveResultToDB(window.data);
}
// 注意processSinglePdf 已经保存到数据库,无需重复保存
showToast('处理完成!正在加载 PDF 对照视图...', 'success');
@ -951,6 +947,18 @@ async function executeMinerUStructuredTranslation() {
addLog('翻译完成,保存数据...');
// 从 translatedContentList 生成合并的翻译文本
const translatedTextParts = [];
translatedContentList.forEach((item) => {
if (item && item.text && item.type === 'text') {
translatedTextParts.push(item.text);
}
});
const combinedTranslation = translatedTextParts.join('\n\n');
// 更新 dataObj 的翻译字段
dataObj.translation = combinedTranslation;
// 保存到 metadata
if (!dataObj.metadata) dataObj.metadata = {};
dataObj.metadata.translatedContentList = translatedContentList;
@ -971,10 +979,8 @@ async function executeMinerUStructuredTranslation() {
dataObj.metadata.failedStructuredItems = failedItems;
dataObj.metadata.structuredFailedCount = failedItems.length;
// 更新全局数据
if (typeof data !== 'undefined') {
data.metadata = dataObj.metadata;
}
// 更新全局数据dataObj 是 window.data 的引用,修改即生效)
// 注意:不再尝试更新可能为 null 的全局 data 变量
window.data = dataObj;
// 保存到数据库

View File

@ -112,7 +112,8 @@ async function processSinglePdf(
defaultUserPromptTemplateSetting,
useCustomPromptsSetting, // 新增参数
batchContext,
onFileSuccess
onFileSuccess,
existingDocId // 新增现有文档ID用于更新而非创建新记录
) {
let currentMarkdownContent = '';
let currentTranslationContent = '';
@ -823,8 +824,14 @@ const fileType = fileToProcess.name.split('.').pop().toLowerCase();
);
// 5. 保存结果
// 结构化翻译完成后:不生成常规译文,以免展示译文/分块对比标签
currentTranslationContent = '';
// 从 translatedContentList 生成合并的翻译文本用于数据库保存
const translatedTextParts = [];
(translatedContentList || []).forEach((item) => {
if (item && item.text && item.type === 'text') {
translatedTextParts.push(item.text);
}
});
currentTranslationContent = translatedTextParts.join('\n\n');
// 将翻译后的 JSON 保存在元数据中供未来使用
if (!ocrResult.metadata.translatedContentList) {
@ -1090,8 +1097,10 @@ const fileType = fileToProcess.name.split('.').pop().toLowerCase();
}
// 保存文档记录
// 如果提供了 existingDocId则更新现有文档UUID格式否则创建新记录
const docId = existingDocId || `${fileToProcess.name}_${fileToProcess.size}`;
const documentData = {
id: `${fileToProcess.name}_${fileToProcess.size}`,
id: docId,
name: fileToProcess.name,
size: fileToProcess.size,
time: processedAt,

View File

@ -25,6 +25,9 @@ let DEPLOYMENT_MODE = (getQueryModeOverride() || (window.ENV_DEPLOYMENT_MODE &&
const API_BASE_URL = window.ENV_API_BASE_URL || '/api';
// 后端认证状态(当 AUTH_DISABLED=true 时,前端也需要绕过认证检查)
let backendAuthDisabled = false;
async function autoDetectBackendAvailability(timeoutMs = 900) {
// file:// 明确无后端
if (window.location.protocol === 'file:') return false;
@ -37,7 +40,20 @@ async function autoDetectBackendAvailability(timeoutMs = 900) {
const id = setTimeout(() => controller.abort(), timeoutMs);
const res = await fetch(`${API_BASE_URL}/health`, { signal: controller.signal, cache: 'no-store' });
clearTimeout(id);
return res.ok;
if (res.ok) {
const data = await res.json();
// 如果后端禁用认证,设置假 token 并标记状态
// TODO: 生产环境必须移除此逻辑,强制要求用户登录认证
if (data.authDisabled) {
backendAuthDisabled = true;
// 设置假 token 让 AuthManager.isAuthenticated() 返回 true
if (!localStorage.getItem('auth_token')) {
localStorage.setItem('auth_token', 'dev-mode-no-auth');
}
}
return true;
}
return false;
} catch {
return false;
}
@ -253,6 +269,36 @@ class BackendStorage {
}
}
/**
* 保存 OSS URL 到文档 AI 助手使用
* @param {string} id - 文档 ID
* @param {string} ossUrl - OSS 公网访问 URL
* @param {string} ossKey - OSS 对象 key用于删除
*/
async saveOssUrl(id, ossUrl, ossKey) {
try {
if (!AuthManager.isAuthenticated()) {
// 前端模式:保存到 IndexedDB metadata
const doc = await this._fallbackTo('getResultFromDB', id);
if (doc) {
doc.metadata = doc.metadata || {};
doc.metadata.ossUrl = ossUrl;
doc.metadata.ossKey = ossKey;
await this._fallbackTo('saveResultToDB', doc);
}
return;
}
await this.fetchAPI(`/documents/${id}/oss`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ossUrl, ossKey })
});
} catch (error) {
console.error('Failed to save OSS URL to document:', error);
throw error;
}
}
async clearAllResultsFromDB() {
try {
if (!AuthManager.isAuthenticated()) {
@ -477,6 +523,20 @@ class StorageAdapterFactory {
getResultFromDB: window.getResultFromDB,
deleteResultFromDB: window.deleteResultFromDB,
clearAllResultsFromDB: window.clearAllResultsFromDB,
// OSS URL 保存(前端模式:保存到 IndexedDB metadata
saveOssUrl: async function(id, ossUrl, ossKey) {
try {
const doc = await window.getResultFromDB(id);
if (doc) {
doc.metadata = doc.metadata || {};
doc.metadata.ossUrl = ossUrl;
doc.metadata.ossKey = ossKey;
await window.saveResultToDB(doc);
}
} catch (e) {
console.warn('[StorageAdapter] saveOssUrl failed in frontend mode:', e);
}
},
loadGlossarySets: window.loadGlossarySets,
saveGlossarySets: window.saveGlossarySets,
saveAnnotationToDB: window.saveAnnotationToDB,

View File

@ -224,12 +224,12 @@
// 检查是否为移动端设备
if (window.innerWidth <= 700) {
console.warn('拒绝在移动端≤700px进入沉浸式布局');
// 即使不进入沉浸模式,也需要显示页面
// 移动端:显示普通布局
document.documentElement.classList.remove('immersive-pending');
document.documentElement.classList.add('immersive-ready');
document.body.classList.remove('immersive-pending');
document.body.classList.add('immersive-ready');
return;
return true; // 移动端算作成功
}
reQueryDynamicElements();
@ -256,12 +256,12 @@
immersiveChatbotArea: !!immersiveChatbotArea
});
// 【严格检查】如果元素缺失,返回 false 表示失败,不显示页面
// 调用者应该重试直到成功
if (missingElements.length > 0) {
console.warn('Immersive mode elements not found:', missingElements.join(', '));
// 元素缺失时也需要显示页面
document.body.classList.remove('immersive-pending');
document.body.classList.add('immersive-ready');
return;
console.error('[enterImmersiveMode] 严重错误:沉浸模式元素缺失:', missingElements.join(', '));
console.error('[enterImmersiveMode] 拒绝显示普通布局,等待重试...');
return false; // 返回失败,不显示页面
}
isImmersiveActive = true;
@ -430,128 +430,13 @@
initializeTocDockResizer();
localStorage.setItem(LS_IMMERSIVE_KEY, 'true');
document.dispatchEvent(new CustomEvent('immersiveModeEntered'));
return true; // 成功进入沉浸模式
}
// 禁用退出沉浸模式功能 - 页面始终保持在沉浸式布局
function exitImmersiveMode() {
reQueryDynamicElements();
isImmersiveActive = false;
// 添加退出动画
document.body.classList.add('immersive-exiting');
if (immersiveContainer) {
immersiveContainer.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
immersiveContainer.style.opacity = '0';
immersiveContainer.style.transform = 'scale(0.98)';
}
setTimeout(() => {
destroyTocDockResizer();
// Remove the TOC vs Dock resize handle
if (tocVsDockResizeHandle && tocVsDockResizeHandle.parentNode === immersiveTocArea) {
immersiveTocArea.removeChild(tocVsDockResizeHandle);
}
// 恢复元素位置的逻辑保持不变...
if (originalTocPopupParent && tocPopupElement && tocPopupElement.parentNode === immersiveTocArea) {
originalTocPopupParent.insertBefore(tocPopupElement, originalTocPopupNextSibling);
} else if (tocPopupElement && immersiveTocArea.contains(tocPopupElement)) {
immersiveTocArea.removeChild(tocPopupElement);
if (originalTocPopupParent) {
originalTocPopupParent.insertBefore(tocPopupElement, originalTocPopupNextSibling);
}
}
if (originalMainContainerParent && mainPageContainer && mainPageContainer.parentNode === immersiveMainArea) {
originalMainContainerParent.insertBefore(mainPageContainer, originalMainContainerNextSibling);
} else if (mainPageContainer && immersiveMainArea.contains(mainPageContainer)) {
immersiveMainArea.removeChild(mainPageContainer);
if (originalMainContainerParent) {
originalMainContainerParent.insertBefore(mainPageContainer, originalMainContainerNextSibling);
}
}
if (chatbotModalElement && originalChatbotModalParent && chatbotModalElement.parentNode === immersiveChatbotArea) {
originalChatbotModalParent.insertBefore(chatbotModalElement, originalChatbotModalNextSibling);
} else if (chatbotModalElement && immersiveChatbotArea.contains(chatbotModalElement)) {
immersiveChatbotArea.removeChild(chatbotModalElement);
if (originalChatbotModalParent) {
originalChatbotModalParent.insertBefore(chatbotModalElement, originalChatbotModalNextSibling);
}
} else if (!chatbotModalElement && originalChatbotModalParent) {
immersiveChatbotArea.innerHTML = '';
}
if (dockElement && originalDockElementParent) {
if (immersiveDockPlaceholderElement && immersiveDockPlaceholderElement.contains(dockElement)) {
immersiveDockPlaceholderElement.removeChild(dockElement);
}
originalDockElementParent.insertBefore(dockElement, originalDockElementNextSibling);
} else if (dockElement && immersiveDockPlaceholderElement && immersiveDockPlaceholderElement.contains(dockElement)){
immersiveDockPlaceholderElement.removeChild(dockElement);
}
document.body.classList.remove('immersive-active', 'no-scroll', 'immersive-exiting');
if (immersiveContainer) {
immersiveContainer.style.display = 'none';
immersiveContainer.style.transition = '';
immersiveContainer.style.opacity = '';
immersiveContainer.style.transform = '';
}
// 清理滚动容器标记 class
if (mainPageContainer) {
const tabContent = mainPageContainer.querySelector('.tab-content');
if (tabContent) {
tabContent.classList.remove('js-scroll-container');
console.log("[ImmersiveLayout] 已移除 tab-content 的 js-scroll-container 标记");
}
}
if (toggleBtn) {
toggleBtn.innerHTML = '<i class="fas fa-expand-alt"></i>';
toggleBtn.classList.remove('immersive-exit-btn-active');
toggleBtn.title = '进入沉浸式布局';
}
// 其他退出逻辑保持不变...
if (typeof window.refreshTocList === 'function') {
window.refreshTocList();
}
if (typeof window.docIdForLocalStorage !== 'undefined' && window.docIdForLocalStorage) {
const savedChatbotOpenState = localStorage.getItem(`chatbotOpenState_${window.docIdForLocalStorage}`);
if (savedChatbotOpenState === 'true') {
window.isChatbotOpen = true;
} else if (savedChatbotOpenState === 'false') {
window.isChatbotOpen = false;
}
}
if (window.ChatbotUI && typeof window.ChatbotUI.updateChatbotUI === 'function') {
window.ChatbotUI.updateChatbotUI();
}
if (window.DockLogic && typeof window.DockLogic.updateStats === 'function' && window.data && window.currentVisibleTabId) {
dockElement.style.display = '';
window.DockLogic.updateStats(window.data, window.currentVisibleTabId);
}
setTimeout(() => {
if (window.DockLogic) {
if (typeof window.DockLogic.unbindScrollForCurrentScrollable === 'function') {
console.log("[ImmersiveLayout] 退出沉浸模式前,先解绑旧的滚动事件");
window.DockLogic.unbindScrollForCurrentScrollable();
}
if (typeof window.DockLogic.forceUpdateReadingProgress === 'function') {
console.log("[ImmersiveLayout] 退出沉浸模式后,延迟调用 forceUpdateReadingProgress");
window.DockLogic.forceUpdateReadingProgress();
}
}
}, 300);
localStorage.setItem(LS_IMMERSIVE_KEY, 'false');
document.dispatchEvent(new CustomEvent('immersiveModeExited'));
}, 300);
// 已禁用:页面始终保持在沉浸式布局
console.log('[ImmersiveLayout] 退出沉浸模式功能已禁用');
}
function initResizeHandles() {
@ -630,235 +515,50 @@
function mainInit() {
if (!initializeDomElements()) {
console.warn('Immersive layout core static elements not found on DOMContentLoaded. Retrying shortly...');
setTimeout(mainInit, 500);
console.warn('[ImmersiveLayout] 核心静态元素未找到200ms 后重试...');
setTimeout(mainInit, 200);
return;
}
reQueryDynamicElements();
// 标准沉浸模式切换按钮
const standardToggleBtn = document.getElementById('toggle-immersive-btn');
// 简单沉浸模式切换按钮
const simpleToggleBtn = document.getElementById('toggle-simple-immersive-btn');
// 标准沉浸模式功能
if (standardToggleBtn) {
standardToggleBtn.addEventListener('click', () => {
// 检查是否为移动端设备屏幕宽度小于等于700px
if (window.innerWidth <= 700) {
console.warn('沉浸式布局在移动端≤700px不可用');
// 可选:显示提示消息
if (window.showToast) {
window.showToast('沉浸式布局在手机端不可用', 'warning');
} else {
alert('沉浸式布局在手机端不可用,请在更大的屏幕上使用');
}
return;
}
if (isImmersiveActive) {
exitImmersiveMode();
} else {
enterImmersiveMode();
}
});
}
// 简单沉浸模式功能
if (simpleToggleBtn) {
simpleToggleBtn.addEventListener('click', () => {
isSimpleImmersiveActive = !isSimpleImmersiveActive;
// 检查是否在"原始文件"标签页PDF查看器
const isOriginalFileTab = window.currentVisibleTabId === 'original-file';
const pdfIframeWrapper = document.getElementById('pdf-iframe-wrapper');
const pdfIframe = document.getElementById('pdf-viewer-iframe');
const mainContainer = document.querySelector('.container');
if (isSimpleImmersiveActive) {
// 如果是PDF查看器标签页进行特殊处理
if (isOriginalFileTab && pdfIframeWrapper && mainContainer) {
// 添加特殊类用于PDF全屏模式
document.body.classList.add('simple-immersive-pdf-mode');
// 移除container的padding
mainContainer.dataset.originalPadding = mainContainer.style.padding || '';
mainContainer.style.padding = '0 !important';
// 让iframe wrapper充满container接近全屏
pdfIframeWrapper.style.cssText = `
width: 100%;
height: calc(100vh - 20px);
min-height: calc(100vh - 20px);
position: relative;
background: #525659;
`;
if (pdfIframe) {
pdfIframe.style.cssText = `
width: 100%;
height: 100%;
border: none;
`;
}
// 隐藏其他UI元素
const elementsToHide = [
{ selector: '.tabs-container', el: document.querySelector('.tabs-container') },
{ selector: '.history-export-controls', el: document.querySelector('.history-export-controls') },
{ selector: '#bottom-left-dock', el: document.getElementById('bottom-left-dock') },
{ selector: '#toc-popup', el: document.getElementById('toc-popup') },
{ selector: '#fileName', el: document.getElementById('fileName') },
{ selector: '#fileMeta', el: document.getElementById('fileMeta') }
];
elementsToHide.forEach(item => {
if (item.el) {
item.el.dataset.originalDisplay = item.el.style.display || '';
item.el.style.display = 'none';
}
});
// 隐藏loading指示器
const loading = document.getElementById('pdf-viewer-loading');
if (loading) loading.style.display = 'none';
// 确保iframe显示
if (pdfIframe) pdfIframe.style.display = 'block';
} else {
// 非PDF查看器标签页使用原有的简单沉浸模式
document.body.classList.add('simple-immersive-pdf-mode');
}
simpleToggleBtn.innerHTML = '<i class="fas fa-eye-slash"></i>';
simpleToggleBtn.title = '退出简单沉浸模式';
localStorage.setItem(LS_SIMPLE_IMMERSIVE_KEY, 'true');
// 自动进入全屏
document.documentElement.requestFullscreen().catch(err => {
console.warn('无法进入全屏模式:', err);
});
} else {
// 退出简单沉浸模式
document.body.classList.remove('simple-immersive-pdf-mode');
document.body.classList.remove('simple-immersive-pdf-mode');
// 如果在全屏状态,退出全屏
if (document.fullscreenElement) {
document.exitFullscreen();
}
// 恢复container的padding
if (mainContainer && mainContainer.dataset.originalPadding !== undefined) {
mainContainer.style.padding = mainContainer.dataset.originalPadding;
delete mainContainer.dataset.originalPadding;
}
// 恢复所有被隐藏的元素
const elementsToRestore = [
{ selector: '.tabs-container', el: document.querySelector('.tabs-container') },
{ selector: '.history-export-controls', el: document.querySelector('.history-export-controls') },
{ selector: '#bottom-left-dock', el: document.getElementById('bottom-left-dock') },
{ selector: '#toc-popup', el: document.getElementById('toc-popup') },
{ selector: '#fileName', el: document.getElementById('fileName') },
{ selector: '#fileMeta', el: document.getElementById('fileMeta') }
];
elementsToRestore.forEach(item => {
if (item.el && item.el.dataset.originalDisplay !== undefined) {
item.el.style.display = item.el.dataset.originalDisplay;
delete item.el.dataset.originalDisplay;
}
});
// 恢复iframe wrapper的原始样式
if (pdfIframeWrapper) {
pdfIframeWrapper.style.cssText = `
width: 100%;
height: 100%;
min-height: 500px;
position: relative;
background: #525659;
`;
}
simpleToggleBtn.innerHTML = '<i class="fas fa-eye"></i>';
simpleToggleBtn.title = '进入简单沉浸模式';
localStorage.setItem(LS_SIMPLE_IMMERSIVE_KEY, 'false');
}
});
}
// 按钮已隐藏,退出功能已禁用,页面始终处于沉浸模式
// 不再需要按钮事件监听器
initResizeHandles();
// 监听窗口大小变化,如果变成移动端尺寸则自动退出沉浸模式
function handleWindowResize() {
if (window.innerWidth <= 700 && isImmersiveActive) {
console.log('检测到屏幕缩小到移动端尺寸,自动退出沉浸式布局');
exitImmersiveMode();
}
}
// 禁用:窗口大小变化时不再自动退出沉浸模式
// function handleWindowResize() {
// if (window.innerWidth <= 700 && isImmersiveActive) {
// console.log('检测到屏幕缩小到移动端尺寸,自动退出沉浸式布局');
// exitImmersiveMode();
// }
// }
// window.addEventListener('resize', handleWindowResize);
// 添加窗口大小变化监听器
window.addEventListener('resize', handleWindowResize);
// 【严格沉浸模式】必须成功进入沉浸模式才显示页面
console.log('[ImmersiveLayout] 强制进入沉浸模式');
// Restore immersive state from localStorage
const savedImmersiveState = localStorage.getItem(LS_IMMERSIVE_KEY);
// 历史详情页默认进入沉浸模式,只有用户明确退出过才不进入
// 注意null 表示首次访问,'true' 表示用户之前处于沉浸模式
// 只有 'false' 才表示用户主动退出过沉浸模式
const shouldEnterImmersive = savedImmersiveState !== 'false';
console.log('[ImmersiveLayout] savedImmersiveState:', savedImmersiveState, 'shouldEnterImmersive:', shouldEnterImmersive);
if (shouldEnterImmersive) {
// 检查是否为移动端,如果是则不恢复沉浸模式
if (window.innerWidth <= 700) {
console.log('检测到移动端设备,不进入沉浸式布局');
localStorage.setItem(LS_IMMERSIVE_KEY, 'false');
// 显示页面
document.documentElement.classList.remove('immersive-pending');
document.documentElement.classList.add('immersive-ready');
document.body.classList.remove('immersive-pending');
document.body.classList.add('immersive-ready');
} else {
// 立即进入沉浸模式(静默模式,无动画)
console.log('[ImmersiveLayout] 准备进入沉浸模式, isImmersiveActive:', isImmersiveActive);
enterImmersiveMode({ silent: true });
console.log('[ImmersiveLayout] enterImmersiveMode 调用完成, isImmersiveActive:', isImmersiveActive);
}
} else {
// 不需要沉浸模式,直接显示页面
// 检查是否为移动端,如果是则显示普通布局
if (window.innerWidth <= 700) {
console.log('[ImmersiveLayout] 检测到移动端设备,显示普通布局');
document.documentElement.classList.remove('immersive-pending');
document.documentElement.classList.add('immersive-ready');
document.body.classList.remove('immersive-pending');
document.body.classList.add('immersive-ready');
return;
}
// Restore simple immersive state from localStorage
const savedSimpleImmersiveState = localStorage.getItem(LS_SIMPLE_IMMERSIVE_KEY);
if (savedSimpleImmersiveState === 'true') {
// 检查是否为移动端,如果是则不启用简单沉浸模式
if (window.innerWidth <= 700) {
console.log('检测到移动端设备,不启用简单沉浸式布局状态');
localStorage.setItem(LS_SIMPLE_IMMERSIVE_KEY, 'false'); // 清除保存的状态
} else {
// 如果不在标准沉浸模式下,才启用简单沉浸模式
if (!isImmersiveActive) {
setTimeout(() => {
if (!isSimpleImmersiveActive) {
document.body.classList.add('simple-immersive-pdf-mode');
isSimpleImmersiveActive = true;
if (toggleBtn) {
toggleBtn.innerHTML = '<i class="fas fa-compress-alt"></i>';
toggleBtn.title = '退出简单沉浸模式';
}
}
}, 200);
}
}
// 尝试进入沉浸模式(静默模式,无动画)
const success = enterImmersiveMode({ silent: true });
if (success) {
console.log('[ImmersiveLayout] 成功进入沉浸模式');
} else {
// 失败时持续重试,不显示页面
console.error('[ImmersiveLayout] 进入沉浸模式失败300ms 后重试...');
setTimeout(mainInit, 300);
return;
}
console.log('Immersive layout logic initialized.');

View File

@ -63,22 +63,22 @@
<div class="flex items-center overflow-hidden mr-2">
<iconify-icon icon="${icon}" class="${iconColor} mr-2 flex-shrink-0" width="20"></iconify-icon>
<span class="flex flex-col overflow-hidden">
<span class="text-sm text-gray-800 truncate" title="${displayName}">${displayName}</span>
<span class="truncate text-[13px] text-[#999999]" title="${displayName} (${global.formatFileSize(file.size)})">${displayName} (${global.formatFileSize(file.size)})</span>
${displayPath && displayPath !== displayName ? `<span class="text-[11px] text-gray-500 truncate" title="${displayPath}">${displayPath}</span>` : ''}
</span>
${virtualBadge}
${isExcluded ? '<span class="ml-2 inline-block text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-600 flex-shrink-0">已排除</span>' : ''}
<span class="text-xs text-gray-500 ml-2 flex-shrink-0">(${global.formatFileSize(file.size)})</span>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button data-index="${index}" class="preview-file-btn text-gray-400 hover:text-blue-600 flex-shrink-0" title="预览">
<iconify-icon icon="carbon:search" width="16"></iconify-icon>
</button>
<button data-index="${index}" class="remove-file-btn text-gray-400 hover:text-red-600 flex-shrink-0" title="移除">
<iconify-icon icon="carbon:close" width="16"></iconify-icon>
</button>
</div>
`;
/* <button data-index="${index}" class="preview-file-btn text-gray-400 hover:text-blue-600 flex-shrink-0" title="">
<iconify-icon icon="carbon:search" width="16"></iconify-icon>
</button>*/
if (isExcluded) {
listItem.classList.add('opacity-60');
}

View File

@ -63,12 +63,14 @@ app.get('/health', async (req, res) => {
res.json({
status: 'ok',
database: 'connected',
authDisabled: process.env.AUTH_DISABLED === 'true',
timestamp: Date.now()
});
} catch (error) {
res.json({
status: 'ok',
database: 'disconnected',
authDisabled: process.env.AUTH_DISABLED === 'true',
timestamp: Date.now()
});
}

View File

@ -7,6 +7,7 @@ import fetch from 'node-fetch';
import { prisma } from '../db/client.js';
// 是否禁用认证(开发/测试模式)
// TODO: 生产环境必须移除此配置,强制要求认证
const AUTH_DISABLED = process.env.AUTH_DISABLED === 'true' || process.env.NODE_ENV === 'test';
// 默认认证 API URL
@ -17,6 +18,7 @@ const tokenCache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 分钟
// 测试用户(认证禁用时使用)
// TODO: 生产环境必须移除此测试用户
const TEST_USER = {
id: 'test-user-001',
username: 'testuser',
@ -114,6 +116,7 @@ export async function ensureUserExists(userId, name) {
*/
export async function requireAuth(req, res, next) {
// 认证禁用模式(开发/测试)
// TODO: 生产环境必须移除此分支,强制要求认证
if (AUTH_DISABLED) {
await ensureUserExists(TEST_USER.id, TEST_USER.nickname);
req.user = TEST_USER;

View File

@ -6,7 +6,10 @@
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
"dev": "node --watch server.js",
"prisma:migrate": "npx prisma migrate dev",
"prisma:generate": "npx prisma generate",
"prisma:studio": "npx prisma studio"
},
"keywords": [
"paper-burner",

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "documents" ADD COLUMN "ossKey" TEXT,
ADD COLUMN "ossUrl" TEXT;

View File

@ -141,6 +141,10 @@ model Document {
fileType String
filePath String? // 如果启用文件上传存储
// OSS 存储(供 AI 助手使用)
ossUrl String? // OSS 公网访问 URL
ossKey String? // OSS 对象 key用于删除
// 处理状态
status DocStatus @default(PENDING)

View File

@ -5,9 +5,54 @@
import express from 'express';
import { prisma } from '../db/client.js';
import OSS from 'ali-oss';
const router = express.Router();
// ==================== OSS 客户端初始化 ====================
let ossClient = null;
function initOssClient() {
const region = process.env.OSS_REGION;
const accessKeyId = process.env.OSS_ACCESS_KEY_ID;
const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET;
const bucket = process.env.OSS_BUCKET || process.env.OSS_BUCKET_NAME;
if (region && accessKeyId && accessKeySecret && bucket) {
let normalizedRegion = region;
if (!region.startsWith('oss-')) {
normalizedRegion = `oss-${region}`;
}
ossClient = new OSS({
region: normalizedRegion,
accessKeyId,
accessKeySecret,
bucket,
secure: true
});
console.log(`[Documents] OSS client initialized with bucket: ${bucket}`);
}
}
initOssClient();
/**
* 删除 OSS 文件
* @param {string} ossKey - OSS 对象 key
* @returns {Promise<boolean>}
*/
async function deleteOssFile(ossKey) {
if (!ossClient || !ossKey) return false;
try {
await ossClient.delete(ossKey);
console.log(`[OSS] Deleted file: ${ossKey}`);
return true;
} catch (error) {
console.error(`[OSS] Failed to delete file ${ossKey}:`, error.message);
return false;
}
}
// 允许的状态值白名单
const ALLOWED_STATUSES = ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED'];
@ -229,10 +274,11 @@ router.put('/:id', async (req, res, next) => {
translationModelName: 'translationModel'
};
// 应用字段映射
// 应用字段映射:复制到数据库字段名,然后删除前端字段名(避免进入 metadata
for (const [frontendField, dbField] of Object.entries(fieldMapping)) {
if (body[frontendField] !== undefined && body[dbField] === undefined) {
if (body[frontendField] !== undefined) {
body[dbField] = body[frontendField];
delete body[frontendField]; // 删除前端字段,避免进入 metadata
}
}
@ -312,6 +358,11 @@ router.delete('/:id', async (req, res, next) => {
});
if (document) {
// 删除 OSS 文件(如果有)
if (document.ossKey) {
await deleteOssFile(document.ossKey);
}
await prisma.document.delete({
where: { id }
});
@ -323,6 +374,40 @@ router.delete('/:id', async (req, res, next) => {
}
});
// 保存 OSS URL 到文档(供 AI 助手使用)
router.post('/:id/oss', async (req, res, next) => {
try {
const { id } = req.params;
const { ossUrl, ossKey } = req.body;
if (!ossUrl) {
return res.status(400).json({ error: 'ossUrl is required' });
}
// 验证文档所有权
const document = await prisma.document.findFirst({
where: {
id,
userId: req.user.id
}
});
if (!document) {
return res.status(404).json({ error: 'Document not found' });
}
// 更新文档的 OSS 信息
await prisma.document.update({
where: { id },
data: { ossUrl, ossKey }
});
res.json({ success: true, ossUrl, ossKey });
} catch (error) {
next(error);
}
});
// ==================== 标注管理 ====================
// 保存标注

View File

@ -820,6 +820,7 @@ async function handleOssUpload(req, res, origin) {
jsonResponse(res, {
success: true,
url: urlToReturn,
key: objectName, // 返回 ossKey 用于后续删除
file_name: filePart.filename
}, 200, origin);

View File

@ -97,6 +97,41 @@
opacity: 1;
transition: opacity 0.2s ease;
}
/* 严格沉浸模式:默认隐藏普通布局 */
body.immersive-pending .app-shell {
display: none !important;
}
/* 只有沉浸模式激活后才显示内容 */
body:not(.immersive-active) .app-shell {
display: none !important;
}
/* 沉浸模式容器默认隐藏,由 JS 控制 */
#immersive-layout-container {
display: none !important;
}
/* 沉浸模式激活后显示容器 */
body.immersive-active #immersive-layout-container {
display: flex !important;
}
/* 移动端例外:显示普通布局 */
@media (max-width: 700px) {
body.immersive-pending .app-shell,
body:not(.immersive-active) .app-shell {
display: flex !important;
}
body.immersive-ready .app-shell {
display: flex !important;
}
}
.title-container{
display: flex;
justify-content: center;
}
</style>
<script>
// 在 DOM 解析前就添加类,避免闪烁
@ -111,7 +146,7 @@
</button>
<!-- Simple Immersive Mode Toggle Button -->
<button id="toggle-simple-immersive-btn" class="tiny-round-btn" style="top: 70px !important;" title="进入简单沉浸模式">
<button id="toggle-simple-immersive-btn" class="tiny-round-btn hidden" style="display: none;" title="进入简单沉浸模式">
<i class="fas fa-eye"></i>
<!-- Icon for simple immersive mode -->
</button>
@ -402,7 +437,9 @@
<!-- 主内容容器 -->
<!-- ===================== -->
<div class="container">
<h2 id="fileName">历史详情</h2>
<div class="title-container">
<span id="fileName" style="display:block !important;">历史详情</span>
</div>
<div class="meta" id="fileMeta">
<div class="meta-info">
<!-- <span id="fileMetaTime"></span>
@ -420,6 +457,7 @@
<span>导出</span>
</button>
</div>
<hr style="margin: 0; height: 1px;" />
<!-- 标签页导航 -->
<div class="tabs-container">