Hermes / image-proxy.js
GitHub Action
Auto deploy from GitHub 2026-05-10 16:32:58
bf076b8
/**
* Image Proxy + BFF Reverse Proxy
*
* 在 BFF (hermes-web-ui) 前端增加一层轻量代理:
* - /images/ → 列出所有已生成图片 (HTML 页面)
* - /images/<file> → 直接下载/预览图片
* - 其他所有请求 → 透传给 BFF (含 WebSocket)
*
* 端口: 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';
// 额外的图片搜索路径(baoyu-cover-image 等技能的输出目录)
const EXTRA_IMAGE_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',
'.txt': 'text/plain',
'.json': 'application/json',
'.md': 'text/markdown',
};
// ==================== 图片文件服务 ====================
function serveImageList(res) {
const allImageFiles = [];
let dirsScanned = 0;
const totalDirs = EXTRA_IMAGE_DIRS.length;
function checkComplete() {
dirsScanned++;
if (dirsScanned < totalDirs) return;
const uniqueFiles = Array.from(new Map(allImageFiles.map(f => [f.path, f])).values());
uniqueFiles.sort((a, b) => b.mtime - a.mtime);
const html = buildImageListHtml(uniqueFiles);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
}
EXTRA_IMAGE_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 if (/\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(entry.name)) {
try {
const stat = fs.statSync(fullPath);
allImageFiles.push({
name: entry.name,
path: fullPath,
relPath: relPath,
dir: dir,
size: stat.size,
mtime: stat.mtime
});
} catch (e) {}
pending--;
if (pending === 0) callback();
} else {
pending--;
if (pending === 0) callback();
}
});
});
}
scanDir(dir, '', () => {
checkComplete();
});
});
}
function buildImageListHtml(imageFiles) {
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>🖼️ Image 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; }
h1 { color: #7eb8da; margin-bottom: 1em; font-size: 1.5em; }
.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; }
.card img { max-width: 100%; border-radius: 8px; cursor: pointer;
transition: transform .2s; }
.card img:hover { transform: scale(1.02); }
.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; }
.empty { text-align: center; padding: 3em; color: #888; }
.empty p { margin-top: 1em; font-size: 0.95em; }
</style>
</head>
<body>
<h1>🖼️ Image Cache</h1>
${
imageFiles.length === 0
? `<div class="empty"><p style="font-size:2em">📭</p><p>暂无图片。让 agent 生成图片后将自动出现在此。</p>
<p>提示: 让 agent 使用 baoyu-imagine 技能,并将图片保存到 /data/.hermes/image_cache/</p></div>`
: imageFiles
.map((f) => {
const sizeMB = (f.size / 1024 / 1024).toFixed(2);
const mtime = f.mtime.toISOString().replace('T', ' ').slice(0, 19);
return `<div class="card">
<h3>${f.name}</h3>
<div class="path">${f.relPath}</div>
<img src="/images/${encodeURIComponent(f.relPath)}" alt="${f.name}" loading="lazy" />
<div class="meta">${sizeMB} MB · ${mtime}</div>
<div class="actions">
<a href="/images/${encodeURIComponent(f.relPath)}" download="${f.name}">⬇️ 下载</a>
<a href="/images/${encodeURIComponent(f.relPath)}" target="_blank">🔍 原始大小</a>
</div>
</div>`;
})
.join('\n')
}
</body></html>`;
return html;
}
function serveImage(urlPath, res) {
const relativePath = decodeURIComponent(urlPath.slice('/images/'.length));
// Try each image directory in order
function tryDir(index) {
if (index >= EXTRA_IMAGE_DIRS.length) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found');
return;
}
const dir = EXTRA_IMAGE_DIRS[index];
const filePath = path.join(dir, relativePath);
const resolved = path.resolve(filePath);
// Security: prevent directory traversal
const imageRoot = path.resolve(dir);
if (!resolved.startsWith(imageRoot + path.sep) && resolved !== imageRoot) {
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);
}
// ==================== 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, () => {
// 重新构造原始 HTTP Upgrade 请求发给 BFF
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);
}
// 双向管道: BFF ↔ Client
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 (_) {} });
}
// ==================== 主服务器 ====================
const server = http.createServer((clientReq, clientRes) => {
// 图片文件服务
if (clientReq.url === '/images' || clientReq.url === '/images/') {
return serveImageList(clientRes);
}
if (clientReq.url.startsWith('/images/')) {
return serveImage(clientReq.url, clientRes);
}
// 其他请求透传给 BFF
proxyHttpRequest(clientReq, clientRes);
});
// WebSocket 透传
server.on('upgrade', proxyWebSocket);
server.listen(LISTEN_PORT, () => {
console.log(`🖼️ Image proxy listening on :${LISTEN_PORT}`);
console.log(`📷 Images: http://localhost:${LISTEN_PORT}/images/`);
console.log(`tunnel: http://${BFF_HOST}:${BFF_PORT}`);
});