| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| 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',
|
| ];
|
|
|
|
|
| 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 `<img src="${url}" alt="${file.name}" loading="lazy" onclick="window.open('${url}', '_blank')" />`;
|
|
|
| case 'video':
|
| return `<video controls width="100%" preload="metadata">
|
| <source src="${url}" type="${mimeType}">
|
| 您的浏览器不支持视频播放
|
| </video>`;
|
|
|
| case 'audio':
|
| return `<div class="audio-player">
|
| <div class="audio-icon">🎵</div>
|
| <audio controls style="width:100%">
|
| <source src="${url}" type="${mimeType}">
|
| 您的浏览器不支持音频播放
|
| </audio>
|
| </div>`;
|
|
|
| case 'document':
|
| const ext = path.extname(file.name).toLowerCase();
|
| if (ext === '.pdf') {
|
| return `<embed src="${url}" type="application/pdf" width="100%" height="400px" />
|
| <div class="doc-fallback">
|
| <a href="${url}" target="_blank">📖 在新窗口打开 PDF</a>
|
| </div>`;
|
| } else if (ext === '.md') {
|
| return `<div class="doc-preview-box">
|
| <div class="doc-icon-large">📝 Markdown</div>
|
| <a href="${url}?preview=1" class="preview-btn" target="_blank">📖 预览文档</a>
|
| </div>`;
|
| } else {
|
| return `<div class="doc-preview-box">
|
| <div class="doc-icon-large">📄 ${ext.toUpperCase()}</div>
|
| <a href="${url}" download class="preview-btn">⬇️ 下载文件</a>
|
| </div>`;
|
| }
|
|
|
| default:
|
| return `<div class="file-preview-box">
|
| <div class="file-icon-large">📎 ${path.extname(file.name) || '文件'}</div>
|
| <a href="${url}" download class="preview-btn">⬇️ 下载</a>
|
| </div>`;
|
| }
|
| }
|
|
|
|
|
|
|
| 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 `<a href="?category=${btn.key}&sort=${sortBy}" class="filter-btn ${active}">${btn.icon} ${btn.label}</a>`;
|
| }).join('');
|
|
|
|
|
| const sortOptions = [
|
| { key: 'time', label: '时间' },
|
| { key: 'name', label: '名称' },
|
| { key: 'size', label: '大小' },
|
| ].map(opt => {
|
| const active = sortBy === opt.key ? 'selected' : '';
|
| return `<option value="${opt.key}" ${active}>${opt.label}</option>`;
|
| }).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 += `<a href="?page=${i}&category=${category}&sort=${sortBy}" class="page-num ${active}">${i}</a>`;
|
| }
|
|
|
| paginationHtml = `
|
| <div class="pagination-bar">
|
| <a href="?page=1&category=${category}&sort=${sortBy}" class="page-btn ${prevClass}">⏮ 首页</a>
|
| <a href="?page=${currentPage - 1}&category=${category}&sort=${sortBy}" class="page-btn ${prevClass}">◀ 上一页</a>
|
| <div class="page-numbers">${pageButtons}</div>
|
| <span class="page-info">${currentPage} / ${totalPages} 页 (共 ${totalFiles} 个文件)</span>
|
| <a href="?page=${currentPage + 1}&category=${category}&sort=${sortBy}" class="page-btn ${nextClass}">下一页 ▶</a>
|
| <a href="?page=${totalPages}&category=${category}&sort=${sortBy}" class="page-btn ${nextClass}">末页 ⏭</a>
|
| </div>
|
| `;
|
| } else {
|
| paginationHtml = `<div class="pagination-bar"><span class="page-info">共 ${totalFiles} 个文件</span></div>`;
|
| }
|
|
|
| const html = `<!DOCTYPE html>
|
| <html lang="zh-CN">
|
| <head>
|
| <meta charset="utf-8">
|
| <meta name="viewport" content="width=device-width, initial-scale=1">
|
| <title>📁 Media Cache - Hermes Agent</title>
|
| <style>
|
| * { box-sizing: border-box; margin: 0; padding: 0; }
|
| body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| background: #0f0f23; color: #e0e0e0; padding: 2em; min-height: 100vh; }
|
| h1 { color: #7eb8da; margin-bottom: 0.5em; font-size: 1.5em; }
|
| .subtitle { color: #888; font-size: 0.9em; margin-bottom: 1.5em; }
|
|
|
| /* 过滤器 */
|
| .filter-bar { display: flex; gap: 0.5em; margin-bottom: 1em; flex-wrap: wrap; align-items: center; }
|
| .filter-btn {
|
| color: #b0b0d0; text-decoration: none; padding: 0.5em 1em;
|
| border: 1px solid #3a3a6e; border-radius: 8px; font-size: 0.9em;
|
| transition: all .2s; background: #1a1a3e; display: inline-flex; align-items: center; gap: 0.3em;
|
| }
|
| .filter-btn:hover { background: #2a2a5e; border-color: #7eb8da; }
|
| .filter-btn.active { background: #7eb8da; color: #0f0f23; border-color: #7eb8da; }
|
|
|
| .sort-select {
|
| background: #1a1a3e; color: #b0b0d0; border: 1px solid #3a3a6e;
|
| padding: 0.5em 1em; border-radius: 8px; font-size: 0.9em; margin-left: auto;
|
| }
|
| .sort-select:focus { outline: none; border-color: #7eb8da; }
|
|
|
| /* 分页器 */
|
| .pagination-bar {
|
| display: flex; gap: 0.5em; margin: 1.5em 0; align-items: center;
|
| justify-content: center; flex-wrap: wrap;
|
| }
|
| .page-btn {
|
| color: #7eb8da; text-decoration: none; padding: 0.4em 0.8em;
|
| border: 1px solid #3a3a6e; border-radius: 6px; font-size: 0.85em;
|
| transition: all .2s;
|
| }
|
| .page-btn:hover:not(.disabled) { background: #7eb8da22; border-color: #7eb8da; }
|
| .page-btn.disabled { color: #555; border-color: #2a2a4e; pointer-events: none; }
|
| .page-num {
|
| color: #b0b0d0; text-decoration: none; padding: 0.4em 0.8em;
|
| border: 1px solid #3a3a6e; border-radius: 6px; font-size: 0.85em; min-width: 2em; text-align: center;
|
| }
|
| .page-num.active { background: #7eb8da; color: #0f0f23; border-color: #7eb8da; }
|
| .page-num:hover:not(.active) { background: #2a2a5e; }
|
| .page-info { color: #888; font-size: 0.85em; margin: 0 1em; }
|
|
|
| /* 卡片 */
|
| .card { background: #1a1a3e; border-radius: 12px; padding: 1.5em;
|
| margin-bottom: 1.5em; box-shadow: 0 4px 12px rgba(0,0,0,.3); }
|
| .card h3 { color: #9dd6e8; margin-bottom: 0.8em; font-size: 1.1em; display: flex; align-items: center; gap: 0.5em; }
|
| .card img { max-width: 100%; border-radius: 8px; cursor: pointer;
|
| transition: transform .2s; display: block; }
|
| .card img:hover { transform: scale(1.01); }
|
| .card video { max-width: 100%; border-radius: 8px; display: block; }
|
| .card audio { width: 100%; margin: 0.5em 0; }
|
|
|
| .audio-player { display: flex; align-items: center; gap: 1em; padding: 1em; background: #0f0f23; border-radius: 8px; }
|
| .audio-icon { font-size: 2em; }
|
|
|
| .doc-preview-box, .file-preview-box {
|
| background: #0f0f23; border-radius: 8px; padding: 2em; text-align: center;
|
| display: flex; flex-direction: column; align-items: center; gap: 1em;
|
| }
|
| .doc-icon-large { font-size: 3em; color: #7eb8da; }
|
| .file-icon-large { font-size: 3em; }
|
| .preview-btn {
|
| color: #7eb8da; text-decoration: none; padding: 0.5em 1.5em;
|
| border: 1px solid #7eb8da; border-radius: 6px; transition: background .2s;
|
| }
|
| .preview-btn:hover { background: #7eb8da22; }
|
| .doc-fallback { margin-top: 0.5em; text-align: center; }
|
|
|
| embed { border-radius: 8px; }
|
|
|
| .actions { margin-top: 0.8em; display: flex; gap: 1em; flex-wrap: wrap; }
|
| .actions a { color: #7eb8da; text-decoration: none; padding: 0.4em 1em;
|
| border: 1px solid #7eb8da; border-radius: 6px; font-size: 0.9em;
|
| transition: background .2s; }
|
| .actions a:hover { background: #7eb8da22; }
|
| .meta { color: #888; font-size: 0.85em; margin-top: 0.5em; }
|
| .path { color: #666; font-size: 0.8em; margin-top: 0.3em; margin-bottom: 0.8em; }
|
|
|
| .badge { display: inline-block; padding: 0.2em 0.6em; border-radius: 4px; font-size: 0.75em; font-weight: bold; }
|
| .badge-image { background: #4CAF50; color: #fff; }
|
| .badge-video { background: #FF5722; color: #fff; }
|
| .badge-audio { background: #9C27B0; color: #fff; }
|
| .badge-document { background: #2196F3; color: #fff; }
|
|
|
| .empty { text-align: center; padding: 3em; color: #888; }
|
| .empty p { margin-top: 1em; font-size: 0.95em; }
|
|
|
| .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5em; }
|
| </style>
|
| </head>
|
| <body>
|
| <h1>📁 Media Cache</h1>
|
| <p class="subtitle">支持图片、视频、音频和文档预览</p>
|
|
|
| <div class="filter-bar">
|
| ${filterButtons}
|
| <select class="sort-select" onchange="window.___location.href='?category=${category}&sort='+this.value">
|
| <option value="">排序方式...</option>
|
| ${sortOptions}
|
| </select>
|
| </div>
|
|
|
| ${paginationHtml}
|
|
|
| ${files.length === 0
|
| ? `<div class="empty"><p style="font-size:2em">📭</p><p>暂无媒体文件</p>
|
| <p>提示: 上传文件到 /data/.hermes/image_cache/ 或 /data/cover-image/</p></div>` |
| : `<div class="grid">${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 `<div class="card">
|
| <h3>
|
| <span>${getFileIcon(f.name)}</span>
|
| ${f.name}
|
| <span class="badge ${badgeClass}">${badgeLabel}</span>
|
| </h3>
|
| <div class="path">${f.relPath}</div>
|
| ${mediaTag}
|
| <div class="meta">${sizeStr} · ${mtime}</div>
|
| <div class="actions">
|
| <a href="/files/${encodeURIComponent(f.relPath)}" download="${f.name}">⬇️ 下载</a>
|
| <a href="/files/${encodeURIComponent(f.relPath)}" target="_blank">🔍 查看</a>
|
| </div>
|
| </div>`;
|
| }).join('\n')}</div>`
|
| }
|
|
|
| ${paginationHtml}
|
|
|
| <script>
|
| // 自动更新排序选择框的当前值
|
| document.querySelector('.sort-select').value = '${sortBy}';
|
| </script>
|
|
|
| </body></html>`;
|
| return html;
|
| }
|
|
|
|
|
|
|
| function serveImage(urlPath, query, res) {
|
| const relativePath = decodeURIComponent(urlPath.slice('/files/'.length));
|
|
|
|
|
| if (query.preview === '1') {
|
| return servePreview(relativePath, res);
|
| }
|
|
|
|
|
| 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);
|
|
|
|
|
| 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') {
|
|
|
| const html = `<!DOCTYPE html>
|
| <html lang="zh-CN">
|
| <head>
|
| <meta charset="utf-8">
|
| <meta name="viewport" content="width=device-width, initial-scale=1">
|
| <title>📖 ${path.basename(resolved)}</title>
|
| <style>
|
| body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| max-width: 900px; margin: 2em auto; padding: 0 2em; line-height: 1.6;
|
| background: #fafafa; color: #333; }
|
| h1, h2, h3 { color: #2c3e50; border-bottom: 2px solid #eee; padding-bottom: 0.3em; }
|
| code { background: #f0f0f0; padding: 0.2em 0.4em; border-radius: 3px; }
|
| pre { background: #f5f5f5; padding: 1em; border-radius: 8px; overflow-x: auto; }
|
| blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 1em; color: #666; }
|
| a { color: #3498db; }
|
| table { border-collapse: collapse; width: 100%; margin: 1em 0; }
|
| th, td { border: 1px solid #ddd; padding: 0.5em; text-align: left; }
|
| th { background: #f0f0f0; }
|
| .back { display: inline-block; margin-bottom: 1em; text-decoration: none;
|
| background: #3498db; color: white; padding: 0.5em 1em; border-radius: 4px; }
|
| </style>
|
| </head>
|
| <body>
|
| <a href="/files/" class="back">← 返回媒体列表</a>
|
| <h1>${path.basename(resolved)}</h1>
|
| <hr>
|
| <pre style="white-space: pre-wrap;">${data.replace(/</g, '<').replace(/>/g, '>')}</pre>
|
| </body></html>`;
|
| 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);
|
| }
|
|
|
|
|
|
|
| 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 });
|
| }
|
|
|
|
|
|
|
| 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('<h1>错误</h1><p>无法读取会话目录</p>');
|
| 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: 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('<h1>错误</h1><p>无法解析会话数据</p>');
|
| return;
|
| }
|
| }
|
|
|
|
|
| fs.readdir(SESSIONS_DIR, { withFileTypes: true }, (readErr, entries) => {
|
| if (readErr) {
|
| res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
| res.end('<h1>错误</h1><p>无法读取会话目录</p>');
|
| 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('<h1>404</h1><p>会话不存在</p>');
|
| 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('<h1>404</h1><p>会话不存在</p>');
|
| }
|
| });
|
| });
|
| });
|
| });
|
| }
|
|
|
| function buildHistoryDetailHtml(session, sessionId) {
|
| const messages = (session.messages || []).map((msg, index) => {
|
| const role = msg.role || 'unknown';
|
| const content = msg.content || '';
|
| const isUser = role === 'user';
|
|
|
|
|
| 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 `<img src="${url}" alt="${alt}" style="max-width:100%;border-radius:8px;margin:8px 0;" />`;
|
| } else if (['.mp4','.avi','.mov','.mkv','.webm','.m4v','.3gp'].includes(ext)) {
|
| return `<video controls width="100%" style="max-width:600px;border-radius:8px;margin:8px 0;">
|
| <source src="${url}" type="${getMimeType(fileName)}">
|
| 您的浏览器不支持视频播放
|
| </video>`;
|
| } else if (['.mp3','.wav','.aac','.ogg','.flac','.m4a','.wma'].includes(ext)) {
|
| return `<audio controls style="width:100%;max-width:400px;margin:8px 0;">
|
| <source src="${url}" type="${getMimeType(fileName)}">
|
| 您的浏览器不支持音频播放
|
| </audio>`;
|
| } else {
|
| return `<a href="${url}" target="_blank" style="color:#7eb8da;">📎 ${alt || fileName}</a>`;
|
| }
|
| })
|
| .replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" style="color:#7eb8da;">$1</a>')
|
| .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
| .replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
| .replace(/\n/g, '<br>');
|
|
|
| return `<div class="message ${isUser ? 'user' : 'assistant'}">
|
| <div class="message-header">
|
| <span class="role-badge ${isUser ? 'role-user' : 'role-assistant'}">${isUser ? '👤 用户' : '🤖 助手'}</span>
|
| <span class="msg-index">#${index + 1}</span>
|
| </div>
|
| <div class="message-content">${renderedContent}</div>
|
| </div>`;
|
| }).join('');
|
|
|
| return `<!DOCTYPE html>
|
| <html lang="zh-CN">
|
| <head>
|
| <meta charset="utf-8">
|
| <meta name="viewport" content="width=device-width, initial-scale=1">
|
| <title>${session.name || '对话详情'} - Hermes Agent</title>
|
| <style>
|
| * { box-sizing: border-box; margin: 0; padding: 0; }
|
| body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| background: #0f0f23; color: #e0e0e0; padding: 2em; min-height: 100vh; }
|
| h1 { color: #7eb8da; margin-bottom: 0.3em; }
|
| .back-link { color: #7eb8da; text-decoration: none; margin-bottom: 1em; display: inline-block; }
|
| .back-link:hover { text-decoration: underline; }
|
|
|
| .session-meta { background: #1a1a2e; padding: 1em; border-radius: 8px; margin-bottom: 1.5em; }
|
| .meta-row { margin: 0.3em 0; color: #aaa; font-size: 0.9em; }
|
| .meta-label { color: #7eb8da; font-weight: 500; }
|
|
|
| .messages { max-width: 900px; }
|
| .message { margin-bottom: 1.5em; padding: 1em; border-radius: 8px; }
|
| .message.user { background: #1a2332; border-left: 3px solid #2196F3; }
|
| .message.assistant { background: #1a2e1a; border-left: 3px solid #4CAF50; }
|
|
|
| .message-header { display: flex; justify-content: space-between; margin-bottom: 0.5em; }
|
| .role-badge { font-size: 0.85em; font-weight: 600; padding: 0.2em 0.6em; border-radius: 4px; }
|
| .role-user { background: #2196F3; color: white; }
|
| .role-assistant { background: #4CAF50; color: white; }
|
| .msg-index { color: #666; font-size: 0.8em; }
|
|
|
| .message-content { line-height: 1.6; }
|
| .message-content img { max-width: 100%; border-radius: 8px; margin: 8px 0; }
|
| .message-content video { max-width: 100%; border-radius: 8px; margin: 8px 0; }
|
| .message-content audio { width: 100%; max-width: 400px; margin: 8px 0; }
|
| </style>
|
| </head>
|
| <body>
|
| <a href="/history/" class="back-link">← 返回历史记录</a>
|
| <h1>${session.name || '未命名对话'}</h1>
|
|
|
| <div class="session-meta">
|
| <div class="meta-row"><span class="meta-label">ID:</span> ${sessionId}</div>
|
| <div class="meta-row"><span class="meta-label">模型:</span> ${session.model || session.original_model || '未知'}</div>
|
| <div class="meta-row"><span class="meta-label">渠道:</span> ${session.platform || session.channel || 'webchat'}</div>
|
| <div class="meta-row"><span class="meta-label">消息数:</span> ${session.messages ? session.messages.length : 0}</div>
|
| </div>
|
|
|
| <div class="messages">
|
| ${messages || '<p style="color:#666;text-align:center;padding:2em;">暂无消息</p>'}
|
| </div>
|
|
|
| </body></html>`;
|
| }
|
|
|
| function buildHistoryHtml(sessions, days) {
|
| const rows = sessions.map(s => {
|
| return `<tr>
|
| <td><a href="/history/${s.id}" style="color:#7eb8da;text-decoration:none;">${s.name}</a></td>
|
| <td><span class="badge platform-${s.platform}">${s.platform}</span></td>
|
| <td>${s.model}</td>
|
| <td>${s.messages}</td>
|
| <td>${s.updated}</td>
|
| </tr>`;
|
| }).join('');
|
|
|
| return `<!DOCTYPE html>
|
| <html lang="zh-CN">
|
| <head>
|
| <meta charset="utf-8">
|
| <meta name="viewport" content="width=device-width, initial-scale=1">
|
| <title>📜 历史记录 - Hermes Agent</title>
|
| <style>
|
| * { box-sizing: border-box; margin: 0; padding: 0; }
|
| body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| background: #0f0f23; color: #e0e0e0; padding: 2em; min-height: 100vh; }
|
| h1 { color: #7eb8da; margin-bottom: 0.5em; }
|
| .subtitle { color: #888; font-size: 0.9em; margin-bottom: 1.5em; }
|
|
|
| table { width: 100%; border-collapse: collapse; margin-top: 1em; }
|
| th { background: #1a1a2e; color: #7eb8da; padding: 0.8em; text-align: left;
|
| font-weight: 600; border-bottom: 2px solid #7eb8da; }
|
| td { padding: 0.8em; border-bottom: 1px solid #333; }
|
| tr:hover { background: #1a1a2e; }
|
|
|
| .badge { display: inline-block; padding: 0.2em 0.6em; border-radius: 4px;
|
| font-size: 0.85em; font-weight: 500; }
|
| .platform-webchat { background: #4CAF50; color: white; }
|
| .platform-wechat { background: #07C160; color: white; }
|
| .platform-discord { background: #5865F2; color: white; }
|
| .platform-telegram { background: #0088cc; color: white; }
|
| .platform-slack { background: #E01E5A; color: white; }
|
| .platform-default { background: #666; color: white; }
|
|
|
| .empty { text-align: center; padding: 3em; color: #666; }
|
| .filter-bar { margin-bottom: 1em; }
|
| .filter-bar a { color: #7eb8da; text-decoration: none; margin-right: 1em; }
|
| .filter-bar a.active { font-weight: bold; border-bottom: 2px solid #7eb8da; }
|
| </style>
|
| </head>
|
| <body>
|
| <h1>📜 对话历史记录</h1>
|
| <p class="subtitle">显示近 ${days} 天的所有渠道对话记录(共 ${sessions.length} 条)</p>
|
|
|
| <div class="filter-bar">
|
| <a href="?days=1" class="${days === 1 ? 'active' : ''}">1天</a>
|
| <a href="?days=7" class="${days === 7 ? 'active' : ''}">7天</a>
|
| <a href="?days=10" class="${days === 10 ? 'active' : ''}">10天</a>
|
| <a href="?days=30" class="${days === 30 ? 'active' : ''}">30天</a>
|
| </div>
|
|
|
| ${sessions.length === 0 ?
|
| '<div class="empty"><p>暂无历史记录</p></div>' :
|
| `<table>
|
| <thead>
|
| <tr>
|
| <th>对话名称</th>
|
| <th>渠道</th>
|
| <th>模型</th>
|
| <th>消息数</th>
|
| <th>更新时间</th>
|
| </tr>
|
| </thead>
|
| <tbody>${rows}</tbody>
|
| </table>`}
|
|
|
| </body></html>`;
|
| }
|
|
|
|
|
|
|
| const server = http.createServer((clientReq, clientRes) => {
|
|
|
| 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);
|
| }
|
|
|
|
|
| proxyHttpRequest(clientReq, clientRes);
|
| });
|
|
|
|
|
| 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}`);
|
| });
|
|
|