feat: 集成 OSS 存储与 AI 助手 PDF 支持

- OSS 对象存储集成 - 文档上传后保存OSS URL 和 Key 到数据库,删除文档时同步清理 OSS 文件
- AI 助手 PDF 支持 - 历史详情页将 PDF base64 转换为 File 对象,供 AI 助手直接使用
- 认证状态同步 - 后端 /health 返回 authDisabled 状态,前端自动适配无认证模式
- 添加TODO: 生产环境必须移除的配置
This commit is contained in:
肖应宇 2026-03-25 14:00:30 +08:00
parent 96f136a9bc
commit 6c78f08769
15 changed files with 354 additions and 30 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

@ -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

@ -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

@ -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 = '';
@ -2194,7 +2204,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

@ -245,6 +245,30 @@ 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,

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,6 +430,8 @@
initializeTocDockResizer();
localStorage.setItem(LS_IMMERSIVE_KEY, 'true');
document.dispatchEvent(new CustomEvent('immersiveModeEntered'));
return true; // 成功进入沉浸模式
}
// 禁用退出沉浸模式功能 - 页面始终保持在沉浸式布局
function exitImmersiveMode() {
@ -513,8 +515,8 @@
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;
}
@ -534,25 +536,29 @@
// }
// window.addEventListener('resize', handleWindowResize);
// 强制始终进入沉浸模式(忽略 localStorage 状态)
// 页面默认沉浸式布局,且无法退出
const shouldEnterImmersive = true;
// 【严格沉浸模式】必须成功进入沉浸模式才显示页面
console.log('[ImmersiveLayout] 强制进入沉浸模式');
// 检查是否为移动端,如果是则不进入沉浸模式
// 检查是否为移动端,如果是则显示普通布局
if (window.innerWidth <= 700) {
console.log('检测到移动端设备,不进入沉浸式布局');
// 显示页面
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;
}
// 尝试进入沉浸模式(静默模式,无动画)
const success = enterImmersiveMode({ silent: true });
if (success) {
console.log('[ImmersiveLayout] 成功进入沉浸模式');
} else {
// 立即进入沉浸模式(静默模式,无动画)
console.log('[ImmersiveLayout] 准备进入沉浸模式, isImmersiveActive:', isImmersiveActive);
enterImmersiveMode({ silent: true });
console.log('[ImmersiveLayout] enterImmersiveMode 调用完成, isImmersiveActive:', isImmersiveActive);
// 失败时持续重试,不显示页面
console.error('[ImmersiveLayout] 进入沉浸模式失败300ms 后重试...');
setTimeout(mainInit, 300);
return;
}
console.log('Immersive layout logic initialized.');

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'];
@ -313,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 }
});
@ -324,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,37 @@
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;
}
}
</style>
<script>
// 在 DOM 解析前就添加类,避免闪烁