/** * Media Proxy + BFF Reverse Proxy * * 在 BFF (hermes-web-ui) 前端增加一层轻量代理: * - /files/ → 列出所有媒体文件 (HTML 页面,支持分类/排序/分页) * - /files/ → 直接下载/预览媒体文件 * - 其他所有请求 → 透传给 BFF (含 WebSocket) * * 支持格式: * - 图片: png, jpg, jpeg, gif, webp, svg, bmp * - 视频: mp4, avi, mov, mkv, webm, m4v, 3gp * - 音频: mp3, wav, aac, ogg, flac, m4a, wma * - 文档: md, pdf, txt, json * * 端口: 7860 (HF Spaces 对外端口) * BFF: 7861 (内部端口, 仅本代理访问) * 媒体目录: /data/.hermes/image_cache (主目录) * /data/cover-image (baoyu-cover-image 输出目录) */ const http = require('http'); const fs = require('fs'); const path = require('path'); const net = require('net'); const BFF_HOST = '127.0.0.1'; const BFF_PORT = parseInt(process.env.BFF_PORT || '7861', 10); const LISTEN_PORT = parseInt(process.env.LISTEN_PORT || '7860', 10); const IMAGE_DIR = process.env.IMAGE_DIR || '/data/.hermes/image_cache'; const PAGE_SIZE = 20; // 每页默认显示文件数 const SESSIONS_DIR = '/data/.hermes/sessions'; // 额外的媒体搜索路径 const EXTRA_MEDIA_DIRS = [ '/data/cover-image', '/data/.hermes/image_cache', ]; // MIME 类型映射 const MIME_TYPES = { // 图片 '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', // 视频 '.mp4': 'video/mp4', '.avi': 'video/avi', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska', '.webm': 'video/webm', '.m4v': 'video/x-m4v', '.3gp': 'video/3gpp', // 音频 '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.wma': 'audio/x-ms-wma', // 文档 '.pdf': 'application/pdf', '.md': 'text/markdown', '.txt': 'text/plain', '.json': 'application/json', }; // 文件分类配置 const FILE_TYPES = { image: { exts: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'], label: '图片', icon: '🖼️', badgeColor: '#4CAF50' }, video: { exts: ['.mp4', '.avi', '.mov', '.mkv', '.webm', '.m4v', '.3gp'], label: '视频', icon: '🎬', badgeColor: '#FF5722' }, audio: { exts: ['.mp3', '.wav', '.aac', '.ogg', '.flac', '.m4a', '.wma'], label: '音频', icon: '🎵', badgeColor: '#9C27B0' }, document: { exts: ['.pdf', '.md', '.txt', '.json'], label: '文档', icon: '📄', badgeColor: '#2196F3' } }; // ==================== 辅助函数 ==================== function getFileCategory(filename) { const ext = path.extname(filename).toLowerCase(); for (const [cat, info] of Object.entries(FILE_TYPES)) { if (info.exts.includes(ext)) return cat; } return 'other'; } function getFileIcon(filename) { const cat = getFileCategory(filename); return FILE_TYPES[cat]?.icon || '📎'; } function getMimeType(filename) { const ext = path.extname(filename).toLowerCase(); return MIME_TYPES[ext] || 'application/octet-stream'; } function formatFileSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } function renderMedia(file) { const url = `/files/${encodeURIComponent(file.relPath)}`; const mimeType = getMimeType(file.name); switch (file.category) { case 'image': return `${file.name}`; case 'video': return ``; case 'audio': return `
🎵
`; case 'document': const ext = path.extname(file.name).toLowerCase(); if (ext === '.pdf') { return `
📖 在新窗口打开 PDF
`; } else if (ext === '.md') { return `
📝 Markdown
📖 预览文档
`; } else { return `
📄 ${ext.toUpperCase()}
⬇️ 下载文件
`; } default: return `
📎 ${path.extname(file.name) || '文件'}
⬇️ 下载
`; } } // ==================== 媒体文件列表服务 ==================== function serveImageList(req, res, query) { const allFiles = []; let dirsScanned = 0; const totalDirs = EXTRA_MEDIA_DIRS.length; function checkComplete() { dirsScanned++; if (dirsScanned < totalDirs) return; // 去重 const uniqueFiles = Array.from(new Map(allFiles.map(f => [f.path, f])).values()); // 排序(默认时间倒序) const sortBy = query.sort || 'time'; if (sortBy === 'time') { uniqueFiles.sort((a, b) => b.mtime - a.mtime); } else if (sortBy === 'name') { uniqueFiles.sort((a, b) => a.name.localeCompare(b.name)); } else if (sortBy === 'size') { uniqueFiles.sort((a, b) => b.size - a.size); } // 过滤 const category = query.category || 'all'; let filteredFiles = uniqueFiles; if (category !== 'all' && FILE_TYPES[category]) { filteredFiles = uniqueFiles.filter(f => f.category === category); } // 分页 const totalFiles = filteredFiles.length; const totalPages = Math.ceil(totalFiles / PAGE_SIZE) || 1; const currentPage = Math.min(Math.max(parseInt(query.page) || 1, 1), totalPages); const startIdx = (currentPage - 1) * PAGE_SIZE; const pageFiles = filteredFiles.slice(startIdx, startIdx + PAGE_SIZE); const html = buildMediaListHtml(pageFiles, { totalFiles, totalPages, currentPage, category, sortBy }); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); } EXTRA_MEDIA_DIRS.forEach(dir => { function scanDir(currentDir, relativePath, callback) { fs.readdir(currentDir, { withFileTypes: true }, (err, entries) => { if (err) { callback(); return; } let pending = entries.length; if (pending === 0) { callback(); return; } entries.forEach(entry => { const fullPath = path.join(currentDir, entry.name); const relPath = path.join(relativePath, entry.name); if (entry.isDirectory()) { scanDir(fullPath, relPath, () => { pending--; if (pending === 0) callback(); }); } else { const ext = path.extname(entry.name).toLowerCase(); const isMedia = Object.values(FILE_TYPES).some(t => t.exts.includes(ext)); if (isMedia) { try { const stat = fs.statSync(fullPath); allFiles.push({ name: entry.name, path: fullPath, relPath: relPath, dir: dir, size: stat.size, mtime: stat.mtime, category: getFileCategory(entry.name) }); } catch (e) {} } pending--; if (pending === 0) callback(); } }); }); } scanDir(dir, '', () => { checkComplete(); }); }); } function buildMediaListHtml(files, pagination) { const { totalFiles, totalPages, currentPage, category, sortBy } = pagination; // 构建分类过滤按钮 const filterButtons = [ { key: 'all', label: '全部', icon: '📁' }, { key: 'image', label: '图片', icon: '🖼️' }, { key: 'video', label: '视频', icon: '🎬' }, { key: 'audio', label: '音频', icon: '🎵' }, { key: 'document', label: '文档', icon: '📄' }, ].map(btn => { const active = category === btn.key ? 'active' : ''; return `${btn.icon} ${btn.label}`; }).join(''); // 构建排序选项 const sortOptions = [ { key: 'time', label: '时间' }, { key: 'name', label: '名称' }, { key: 'size', label: '大小' }, ].map(opt => { const active = sortBy === opt.key ? 'selected' : ''; return ``; }).join(''); // 构建分页控件 let paginationHtml = ''; if (totalPages > 1) { const prevClass = currentPage <= 1 ? 'disabled' : ''; const nextClass = currentPage >= totalPages ? 'disabled' : ''; let pageButtons = ''; const startPage = Math.max(1, currentPage - 2); const endPage = Math.min(totalPages, currentPage + 2); for (let i = startPage; i <= endPage; i++) { const active = i === currentPage ? 'active' : ''; pageButtons += `${i}`; } paginationHtml = `
⏮ 首页 ◀ 上一页
${pageButtons}
${currentPage} / ${totalPages} 页 (共 ${totalFiles} 个文件) 下一页 ▶ 末页 ⏭
`; } else { paginationHtml = `
共 ${totalFiles} 个文件
`; } const html = ` 📁 Media Cache - Hermes Agent

📁 Media Cache

支持图片、视频、音频和文档预览

${filterButtons}
${paginationHtml} ${files.length === 0 ? `

📭

暂无媒体文件

提示: 上传文件到 /data/.hermes/image_cache/ 或 /data/cover-image/

` : `
${files.map(f => { const sizeStr = formatFileSize(f.size); const mtime = f.mtime.toISOString().replace('T', ' ').slice(0, 19); const badgeClass = `badge-${f.category}`; const badgeLabel = FILE_TYPES[f.category]?.label || '其他'; const mediaTag = renderMedia(f); return `

${getFileIcon(f.name)} ${f.name} ${badgeLabel}

${f.relPath}
${mediaTag}
${sizeStr} · ${mtime}
`; }).join('\n')}
` } ${paginationHtml} `; return html; } // ==================== 单个媒体文件服务 ==================== function serveImage(urlPath, query, res) { const relativePath = decodeURIComponent(urlPath.slice('/files/'.length)); // 处理预览请求 if (query.preview === '1') { return servePreview(relativePath, res); } // Try each media directory in order function tryDir(index) { if (index >= EXTRA_MEDIA_DIRS.length) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not found'); return; } const dir = EXTRA_MEDIA_DIRS[index]; const filePath = path.join(dir, relativePath); const resolved = path.resolve(filePath); // Security: prevent directory traversal const mediaRoot = path.resolve(dir); if (!resolved.startsWith(mediaRoot + path.sep) && resolved !== mediaRoot) { tryDir(index + 1); return; } fs.stat(resolved, (err, stat) => { if (err || !stat.isFile()) { tryDir(index + 1); return; } const ext = path.extname(resolved).toLowerCase(); const contentType = MIME_TYPES[ext] || 'application/octet-stream'; res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': stat.size, 'Cache-Control': 'public, max-age=3600', 'Content-Disposition': `inline; filename="${path.basename(resolved)}"`, }); fs.createReadStream(resolved).pipe(res); }); } tryDir(0); } function servePreview(relativePath, res) { // 尝试找到文件 function tryDir(index) { if (index >= EXTRA_MEDIA_DIRS.length) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('File not found'); return; } const dir = EXTRA_MEDIA_DIRS[index]; const filePath = path.join(dir, relativePath); const resolved = path.resolve(filePath); const mediaRoot = path.resolve(dir); if (!resolved.startsWith(mediaRoot + path.sep) && resolved !== mediaRoot) { tryDir(index + 1); return; } fs.readFile(resolved, 'utf-8', (err, data) => { if (err) { tryDir(index + 1); return; } const ext = path.extname(resolved).toLowerCase(); if (ext === '.md') { // Markdown 预览 const html = ` 📖 ${path.basename(resolved)} ← 返回媒体列表

${path.basename(resolved)}


${data.replace(//g, '>')}
`; res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); } else { res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end(data); } }); } tryDir(0); } // ==================== HTTP 反向代理 ==================== function proxyHttpRequest(clientReq, clientRes) { const options = { hostname: BFF_HOST, port: BFF_PORT, path: clientReq.url, method: clientReq.method, headers: { ...clientReq.headers, host: `${BFF_HOST}:${BFF_PORT}` }, }; const bffReq = http.request(options, (bffRes) => { clientRes.writeHead(bffRes.statusCode, bffRes.headers); bffRes.pipe(clientRes, { end: true }); }); bffReq.on('error', () => { if (!clientRes.headersSent) { clientRes.writeHead(502, { 'Content-Type': 'text/plain' }); clientRes.end('Bad Gateway: BFF server unavailable'); } }); clientReq.pipe(bffReq, { end: true }); } // ==================== WebSocket 反向代理 ==================== function proxyWebSocket(clientReq, clientSocket, clientHead) { const bffSocket = net.connect(BFF_PORT, BFF_HOST, () => { let rawRequest = `${clientReq.method} ${clientReq.url} HTTP/${clientReq.httpVersion}\r\n`; for (let i = 0; i < clientReq.rawHeaders.length; i += 2) { rawRequest += `${clientReq.rawHeaders[i]}: ${clientReq.rawHeaders[i + 1]}\r\n`; } rawRequest += '\r\n'; bffSocket.write(rawRequest); if (clientHead && clientHead.length) { bffSocket.write(clientHead); } bffSocket.pipe(clientSocket); clientSocket.pipe(bffSocket); }); const cleanup = () => { try { bffSocket.destroy(); } catch (_) {} try { clientSocket.destroy(); } catch (_) {} }; bffSocket.on('error', cleanup); clientSocket.on('error', cleanup); clientSocket.on('close', () => { try { bffSocket.end(); } catch (_) {} }); bffSocket.on('close', () => { try { clientSocket.end(); } catch (_) {} }); } // ==================== 历史记录服务 ==================== function serveHistory(req, res, query) { const days = parseInt(query.days) || 10; const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000); fs.readdir(SESSIONS_DIR, { withFileTypes: true }, (err, entries) => { if (err) { res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('

错误

无法读取会话目录

'); return; } const sessions = []; let pending = 0; entries.forEach(entry => { if (!entry.isFile() || !entry.name.endsWith('.json')) return; pending++; const filePath = path.join(SESSIONS_DIR, entry.name); fs.readFile(filePath, 'utf8', (err, data) => { if (!err) { try { const session = JSON.parse(data); const mtime = fs.statSync(filePath).mtimeMs; const msgCount = Array.isArray(session.messages) ? session.messages.length : 0; // 过滤:只显示有真实消息的会话,且在规定时间内 if (mtime >= cutoff && msgCount > 0) { sessions.push({ // 使用文件名(不含扩展名)作为 ID,确保与磁盘文件一一对应 id: entry.name.replace('.json', ''), name: session.name || session.title || '未命名对话', model: session.model || session.original_model || '未知模型', platform: session.platform || session.channel || 'webchat', messages: msgCount, updated: new Date(mtime).toLocaleString('zh-CN'), mtime: mtime }); } } catch (e) {} } pending--; if (pending === 0) { // 按时间倒序 sessions.sort((a, b) => b.mtime - a.mtime); const html = buildHistoryHtml(sessions, days); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); } }); }); if (pending === 0) { const html = buildHistoryHtml([], days); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); } }); } function serveHistoryDetail(req, res, sessionId) { // 首先尝试直接匹配文件名 const directPath = path.join(SESSIONS_DIR, `${sessionId}.json`); fs.readFile(directPath, 'utf8', (err, data) => { if (!err) { try { const session = JSON.parse(data); const html = buildHistoryDetailHtml(session, sessionId); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); return; } catch (e) { res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('

错误

无法解析会话数据

'); return; } } // 直接匹配失败:扫描目录,查找包含该 session_id / id 的文件 fs.readdir(SESSIONS_DIR, { withFileTypes: true }, (readErr, entries) => { if (readErr) { res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('

错误

无法读取会话目录

'); return; } const jsonFiles = entries .filter(e => e.isFile() && e.name.endsWith('.json')) .map(e => e.name); let checked = 0; let found = false; if (jsonFiles.length === 0) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('

404

会话不存在

'); return; } jsonFiles.forEach(fileName => { const fp = path.join(SESSIONS_DIR, fileName); fs.readFile(fp, 'utf8', (readFileErr, fileData) => { if (found) return; // 已找到,忽略后续结果 checked++; if (!readFileErr) { try { const session = JSON.parse(fileData); if ( session.session_id === sessionId || session.id === sessionId || fileName.replace('.json', '') === sessionId ) { found = true; const html = buildHistoryDetailHtml(session, sessionId); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); return; } } catch (e) {} } if (checked === jsonFiles.length && !found) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('

404

会话不存在

'); } }); }); }); }); } function buildHistoryDetailHtml(session, sessionId) { const messages = (session.messages || []).map((msg, index) => { const role = msg.role || 'unknown'; const content = msg.content || ''; const isUser = role === 'user'; // Convert markdown image/media references to HTML let renderedContent = content .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => { const fileName = path.basename(src); const ext = path.extname(fileName).toLowerCase(); const url = src.startsWith('/') ? src : `/files/${encodeURIComponent(src)}`; if (['.png','.jpg','.jpeg','.gif','.webp','.svg','.bmp'].includes(ext)) { return `${alt}`; } else if (['.mp4','.avi','.mov','.mkv','.webm','.m4v','.3gp'].includes(ext)) { return ``; } else if (['.mp3','.wav','.aac','.ogg','.flac','.m4a','.wma'].includes(ext)) { return ``; } else { return `📎 ${alt || fileName}`; } }) .replace(/(https?:\/\/[^\s<]+)/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/\n/g, '
'); return `
${isUser ? '👤 用户' : '🤖 助手'} #${index + 1}
${renderedContent}
`; }).join(''); return ` ${session.name || '对话详情'} - Hermes Agent ← 返回历史记录

${session.name || '未命名对话'}

ID: ${sessionId}
模型: ${session.model || session.original_model || '未知'}
渠道: ${session.platform || session.channel || 'webchat'}
消息数: ${session.messages ? session.messages.length : 0}
${messages || '

暂无消息

'}
`; } function buildHistoryHtml(sessions, days) { const rows = sessions.map(s => { return ` ${s.name} ${s.platform} ${s.model} ${s.messages} ${s.updated} `; }).join(''); return ` 📜 历史记录 - Hermes Agent

📜 对话历史记录

显示近 ${days} 天的所有渠道对话记录(共 ${sessions.length} 条)

1天 7天 10天 30天
${sessions.length === 0 ? '

暂无历史记录

' : `${rows}
对话名称 渠道 模型 消息数 更新时间
`} `; } // ==================== 主服务器 ==================== const server = http.createServer((clientReq, clientRes) => { // 解析 URL 和查询参数 const urlParts = clientReq.url.split('?'); const pathname = urlParts[0]; const query = {}; if (urlParts[1]) { urlParts[1].split('&').forEach(pair => { const [k, v] = pair.split('=').map(decodeURIComponent); query[k] = v; }); } // 媒体文件列表服务 if (pathname === '/files' || pathname === '/files/') { return serveImageList(clientReq, clientRes, query); } // 媒体文件服务 if (pathname.startsWith('/files/')) { return serveImage(pathname, query, clientRes); } // 历史记录服务 if (pathname.startsWith('/history/') && pathname.length > 9) { const sessionId = pathname.slice(9); return serveHistoryDetail(clientReq, clientRes, sessionId); } if (pathname === '/history' || pathname === '/history/') { return serveHistory(clientReq, clientRes, query); } // 其他请求透传给 BFF proxyHttpRequest(clientReq, clientRes); }); // WebSocket 透传 server.on('upgrade', proxyWebSocket); server.listen(LISTEN_PORT, () => { console.log(`📁 Media proxy listening on :${LISTEN_PORT}`); console.log(`🌐 Open: http://localhost:${LISTEN_PORT}/files/`); console.log(`tunnel: http://${BFF_HOST}:${BFF_PORT}`); });