diff --git a/Dockerfile b/Dockerfile
index 4c0efd17c718819e88de0dc842563b90ea78be66..f276b71a325e02c9784baa94fef3710343c08b05 100755
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,8 @@
FROM python:3.12-slim
-# System deps
+# System deps (add build tools for node-pty native compilation)
RUN apt-get update && apt-get install -y --no-install-recommends \
- git curl gnupg2 fontconfig \
+ git curl gnupg2 fontconfig make g++ python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -17,7 +17,17 @@ RUN pip install --quiet --upgrade pip && \
pip install --quiet psutil networkx && \
pip install --quiet -e "/app/hermes-agent[feishu,mcp,cron,pty]" 2>&1 | tail -10
-# Chinese font (Noto Sans SC Regular + Bold, ~16MB)
+# Install Node.js 23
+RUN ARCH=$(dpkg --print-architecture) \
+ && if [ "$ARCH" = "amd64" ]; then NODE_ARCH="x64"; else NODE_ARCH="$ARCH"; fi \
+ && echo "Installing Node.js v23 for ${NODE_ARCH}" \
+ && curl -fsSL "https://nodejs.org/dist/v23.11.0/node-v23.11.0-linux-${NODE_ARCH}.tar.gz" \
+ -o /tmp/node.tar.gz \
+ && tar -xzf /tmp/node.tar.gz -C /usr/local --strip-components=1 \
+ && rm -f /tmp/node.tar.gz \
+ && node --version && npm --version
+
+# Chinese font (Noto Sans SC Regular + Bold)
RUN mkdir -p /usr/share/fonts/truetype/noto && \
curl -sL "https://github.com/googlefonts/noto-cjk/raw/main/Sans/SubsetOTF/SC/NotoSansSC-Regular.otf" \
-o /usr/share/fonts/truetype/noto/NotoSansSC-Regular.otf && \
@@ -25,6 +35,21 @@ RUN mkdir -p /usr/share/fonts/truetype/noto && \
-o /usr/share/fonts/truetype/noto/NotoSansSC-Bold.otf && \
fc-cache -f
+# Build hermes-web-ui (keep node_modules for runtime deps)
+RUN git clone --depth 1 https://github.com/EKKOLearnAI/hermes-web-ui.git /tmp/hermes-web-ui && \
+ cd /tmp/hermes-web-ui && \
+ npm install 2>&1 | tail -5 && \
+ npm run build 2>&1 | tail -10 && \
+ mkdir -p /app/webui-server && \
+ cp -r dist/server/* /app/webui-server/ && \
+ mkdir -p /app/webui-client && \
+ cp -r dist/client/* /app/webui-client/ && \
+ cp package.json /app/webui-server/package.json && \
+ npm prune --omit=dev --prefix /tmp/hermes-web-ui 2>&1 | tail -3 && \
+ cp -r node_modules /app/webui-server/node_modules && \
+ rm -rf /tmp/hermes-web-ui && \
+ echo "hermes-web-ui build done"
+
# Create hermes home
RUN mkdir -p /root/.hermes/plugins/image_gen/pollinations
@@ -36,7 +61,6 @@ COPY entry.py /app/entry.py
COPY dashboard.html /app/dashboard.html
COPY plugins/pollinations/ /root/.hermes/plugins/image_gen/pollinations/
COPY scripts/ /app/scripts/
-COPY webui/ /app/webui/
RUN chmod 600 /root/.hermes/.env
@@ -47,5 +71,7 @@ RUN chmod +x /app/start.sh
EXPOSE 7860
ENV HERMES_ACCEPT_HOOKS=1
+ENV NODE_ENV=production
+ENV AUTH_TOKEN=hermes-bot-2026
CMD ["/app/start.sh"]
diff --git a/config.yaml b/config.yaml
index b0b613d0f455c262424ed48cc1b4c5bab5719202..b03628ecceb43bcc4e5e046ffd27c1b66555326b 100755
--- a/config.yaml
+++ b/config.yaml
@@ -10,6 +10,12 @@ platforms:
extra:
domain: feishu
connection_mode: websocket
+ api_server:
+ enabled: true
+ extra:
+ host: 0.0.0.0
+ port: 8642
+ cors_origins: "*"
memory:
provider: holographic
auto_extract: true
diff --git a/deploy.html b/deploy.html
deleted file mode 100644
index 11f93f188ffa9492b604d679f0e71af0281fc36d..0000000000000000000000000000000000000000
--- a/deploy.html
+++ /dev/null
@@ -1,287 +0,0 @@
-
-
-
-
-
-
-
Hermes Bot
-
HuggingFace Spaces · 飞书 AI Agent · 7x24 在线
-
v3.0 HF Spaces · 2026.04
-
-
-
-
Free (免费)
-
HuggingFace Spaces 免费 CPU 实例 · OpenRouter 免费模型 · 零成本运行
-
-
-
-
-
主模型
-
Gemma 4 31B
-
google/gemma-4-31b-it
-
-
-
备用模型
-
Qwen3 Coder
-
qwen/qwen3-coder
-
-
-
记忆系统
-
Holographic
-
SQLite + FTS5
-
-
-
图片生成
-
Pollinations
-
Flux · 免费
-
-
-
-
-
🔒
-
- API 密钥已脱敏 — 共计 5 个敏感值在预览中全部打码。
- 完整版仅存储在 HF Space 容器内(.env),不对外暴露。
-
-
-
-
-
🏗 部署架构 HF Spaces
-
-
HuggingFace Spaces (Docker SDK)
-├── Space: Jackken/hermes-bot
-│ ├── Dockerfile ← Python 3.12-slim 基础镜像
-│ ├── entry.py ← 入口:Dashboard(:7860) + Gateway 子进程
-│ ├── dashboard.html ← 实时监控面板(SSE 日志流)
-│ ├── start.sh ← 启动脚本
-│ │
-│ ├── config.yaml ← 模型/平台/记忆/插件配置
-│ ├── SOUL.md ← 人格定义(12 章节 · 全中文)
-│ ├── .env ← API 密钥(chmod 600)
-│ │
-│ └── plugins/pollinations/ ← 图片生成插件(免费·无需 Key)
-│ ├── plugin.yaml
-│ └── __init__.py ← PollinationsImageGenProvider
-│
-├── Persistent Storage (/data) ← HF 持久化存储(重启不丢)
-│ ├── sessions/ ← 会话数据(每 24h 自动清理上下文)
-│ ├── memories/ ← Holographic 长期记忆(SQLite + FTS5)
-│ ├── uploads/ ← 用户上传文件缓存
-│ └── logs/ ← Gateway 运行日志
-│
-└── Free CPU Instance ← 2 vCPU · 16GB RAM · 50GB 存储
-
-OpenRouter ← 大模型 API(免费模型)
-├── google/gemma-4-31b-it ← 主模型(推理 + 工具调用)
-└── qwen/qwen3-coder ← 备用模型(主模型失败自动切换)
-
-Pollinations.ai ← 免费图片生成
-└── Flux 模型 ← landscape / square / portrait
-
-飞书 ← WebSocket 长连接
-└── App: Hermes ← 7x24 在线 · 流式回复 · 工具透明
-
-
-
-
-
🔨 修复与变更 v3.0
-
★ 新增 Pollinations.ai 图片生成
- 插件框架实现 ImageGenProvider,Flux 模型免费生成图片
- plugins.enabled 显式启用 image_gen/pollinations
- SOUL.md 添加图片生成使用指南 + MEDIA 标签发送
-
-★ 新增 Firecrawl 网页抓取
- FIRECRAWL_API_KEY 注入 .env,headless 浏览器 + stealth 反爬
- 用于 JS 渲染页面抓取(微信公众号除外)
-
-★ 新增 中文字体支持
- Dockerfile 下载 NotoSansSC-Regular.otf + Bold.otf (~16MB)
- 解决 PDF/图片中文渲染乱码问题
-
-★ 新增 微信公众号文章处理策略
- SOUL.md 五级降级链:Firecrawl → Jina Reader → 搜狗 → 转载搜索 → 用户截图
- 禁止直接 curl mp.weixin.qq.com(验证码拦截)
-
-✓ 修复 飞书文件发送机制
- SOUL.md 从错误的"手动调 send_document"改为 MEDIA:<path> 标签机制
- 网关自动扫描标签 → adapter.send_document() 上传
-
-✗ 移除 MemPalace 记忆系统
- chromadb + onnxruntime 吃 1-2GB 内存 → OOM 风险
- 改用 Holographic(SQLite + FTS5),内存占用几乎为零
- .env / Dockerfile / config.yaml / entry.py 同步清理
-
-√ 正常 Holographic 记忆
- 持久化存储 /data/hermes/memories/,重启/reset 均不丢失
- auto_extract: true 自动提取关键信息存入记忆
-
-√ 正常 上下文压缩
- 50 条消息触发,压缩至 20 条,保护最近 20 条不压缩
-
-√ 正常 全部 5 个 API 密钥
- *** 已脱敏 *** · 仅容器内可见
-
-
-
-
📄 脱敏预览 (.env)
-
OPENROUTER_API_KEY = "***MASKED***" # OpenRouter 大模型接口
-OPENAI_API_KEY = "***MASKED***" # 同 OpenRouter Key
-OPENAI_BASE_URL = "https://openrouter.ai/api/v1"
-FEISHU_APP_ID = "***MASKED***" # 飞书应用 App ID
-FEISHU_APP_SECRET = "***MASKED***" # 飞书应用 App Secret
-FIRECRAWL_API_KEY = "***MASKED***" # Firecrawl 网页抓取
-GATEWAY_ALLOW_ALL_USERS = true
-HERMES_ACCEPT_HOOKS = 1
-
-# 共计 5 个密钥已脱敏 · config.yaml: model = google/gemma-4-31b-it
-
-
-
-
📋 脱敏预览 (config.yaml)
-
model: google/gemma-4-31b-it # 主模型
-provider: openrouter
-fallback_model:
- provider: openrouter
- model: qwen/qwen3-coder # 备用模型
-max_turns: 90 # 单轮最大工具调用轮数
-platforms.feishu:
- enabled: true
- connection_mode: websocket # 长连接
-memory:
- provider: holographic # 持久记忆
- auto_extract: true
-compress:
- enabled: true # 长对话自动压缩
- threshold: 50 # 50条触发
- target_ratio: 20 # 压缩至20条
-image_gen:
- provider: pollinations # 免费图片生成
-plugins:
- enabled: [image_gen/pollinations]
-no_mcp: true # 禁用 MCP 节省内存
-terminal:
- backend: local
- timeout: 180
-timezone: Asia/Shanghai
-
-
-
-
🎯 部署管理
-
# Space 地址
-https://huggingface.co/spaces/Jackken/hermes-bot
-
-# 本地克隆(修改配置后 git push 自动重建)
-git clone https://huggingface.co/spaces/Jackken/hermes-bot
-cd hermes-bot
-# 修改 SOUL.md / config.yaml / .env 后...
-git add -A && git commit -m "update" && git push
-
-# 监控面板(实时日志 + 会话 + 重启)
-https://jackken-hermes-bot.hf.space/
-
-# 手动重启 Space(Factory Rebuild 会清空持久化存储!)
-在 Space 页面点击 "Factory Rebuild" 按钮
-
-
-
-
💡 常见问题
-
Q: 免费实例会休眠吗?
-A: HF 免费 CPU 实例在 48 小时无请求后休眠,下次请求自动唤醒(1-2 分钟)
- 飞书 WebSocket 长连接会保持活跃,通常不会休眠
-
-Q: 内存够用吗?
-A: 16GB RAM 当前使用约 84%。已移除 MemPalace(省 1-2GB)。
- 如果频繁 OOM:减少 max_turns / 禁用浏览器工具 / 清理日志
-
-Q: Factory Rebuild 会丢数据吗?
-A: 是的!Rebuild 会清空 /data 持久化存储(记忆/会话/日志全丢)。
- 正常 git push 只重建容器不丢数据,尽量用 git push 而非 Rebuild
-
-Q: 如何修改人格/记忆?
-A: SOUL.md 控制人格(12 章节),克隆仓库编辑后 git push
- 记忆系统自动运行,也可在飞书中发 /reset 清理上下文(记忆不丢)
-
-Q: 图片生成失败怎么办?
-A: 检查 config.yaml 中 plugins.enabled 是否包含 image_gen/pollinations
- Pollinations.ai 偶尔不稳定,生成需 10-20 秒,耐心等待
-
-Q: 微信公众号文章抓不到?
-A: 微信反爬极强(验证码),Firecrawl/Jina/curl 均无法突破
- 使用 SOUL.md 五级降级链:Firecrawl → Jina → 搜狗 → 搜索转载 → 截图
-
-
-
-
🔌 SOUL.md 人格概览
-
# 12 章节完整人格定义
-一、记忆系统(Holographic) # SQLite + FTS5 持久记忆
-二、任务分类与响应策略 # 8 种消息类型自动判断
-三、工具编排策略 # 4 条常用工具链
-四、错误恢复机制 # 8 种失败场景恢复链
-五、回复格式标准 # 飞书 Markdown + 篇幅控制
-六、上下文感知 # 时间 + 对话 + 跨会话
-七、主动行为 # 4 种主动触发场景
-八、飞书特化 # MEDIA 标签 / 文档 / 卡片
-九、图片生成 # Pollinations.ai + MEDIA 发送
-十、独有能力清单 # 14 项飞书独有能力
-十一、边界与诚实 # 不编造 / 不越界
-十二、效率原则 # 最少工具调用 / 并行执行
-
-
-
-
-
-
-
diff --git a/entry.py b/entry.py
index 6f6c7d35200b850e85976734facef6da1dc7262e..8f27a77c0a912ecbd230e95729ebf7c05f82872a 100755
--- a/entry.py
+++ b/entry.py
@@ -1,9 +1,11 @@
#!/usr/bin/env python3
-"""Hermes Agent — HuggingFace Space Entry Point.
+"""Hermes Bot — HuggingFace Space Entry Point.
-Serves a real-time monitoring dashboard on port 7860 and runs the
-Hermes Gateway (Feishu WebSocket bot) in a background thread.
-Also serves hermes-web-ui (Vue SPA) at /webui
+Serves as the main HTTP entry point on port 7860.
+Routes traffic to:
+ - hermes-web-ui BFF (Node.js on :6060) for the WebUI interface
+ - Original dashboard on :7860/ and /deploy
+ - Gateway health on /api/gateway-status
"""
import json
@@ -16,6 +18,9 @@ import time
import logging
import psutil
import mimetypes
+import urllib.request
+import urllib.error
+import urllib.parse
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
@@ -39,8 +44,11 @@ LOG_FILE = os.path.join(LOG_DIR, "gateway.log")
CONFIG_FILE = os.path.join(HERMES_HOME, "config.yaml")
ENV_FILE = os.path.join(HERMES_HOME, ".env")
DASHBOARD_HTML = "/app/dashboard.html"
-DEPLOY_HTML = "/app/deploy.html"
-WEBUI_DIR = "/app/webui"
+WEBUI_CLIENT_DIR = "/app/webui-client"
+
+# Backend URLs
+WEBUI_BFF_URL = "http://127.0.0.1:6060"
+GATEWAY_URL = "http://127.0.0.1:8642"
# ---------------------------------------------------------------------------
# Logging
@@ -54,8 +62,6 @@ logger = logging.getLogger("entry")
# ---------------------------------------------------------------------------
# Global state
# ---------------------------------------------------------------------------
-_gateway_thread = None
-_gateway_process = None
_gateway_start_time = time.time()
_log_subscribers: list[Queue] = []
_log_tail_offset = 0
@@ -65,14 +71,12 @@ _log_tail_offset = 0
# ---------------------------------------------------------------------------
def _mask_key(key: str) -> str:
- """Mask API key for display: sk-or-v1-abc...xyz → sk-or-v1-ab••••wxyz"""
if not key or len(key) < 10:
- return "••••••••"
- return key[:7] + "••••" + key[-4:]
+ return "····"
+ return key[:7] + "····" + key[-4:]
def _load_env() -> dict[str, str]:
- """Read .env file into dict."""
env = {}
try:
with open(ENV_FILE) as f:
@@ -89,13 +93,11 @@ def _load_env() -> dict[str, str]:
def _load_config() -> dict:
- """Read config.yaml into dict using PyYAML."""
try:
import yaml
with open(CONFIG_FILE) as f:
return yaml.safe_load(f) or {}
except ImportError:
- # Fallback: simple flat parser (no nested support)
cfg: dict = {}
try:
with open(CONFIG_FILE) as f:
@@ -116,7 +118,6 @@ def _load_config() -> dict:
def _get_sessions_count() -> int:
- """Count session files."""
sessions_dir = os.path.join(HERMES_HOME, "sessions")
if not os.path.isdir(sessions_dir):
return 0
@@ -127,7 +128,6 @@ def _get_sessions_count() -> int:
def _get_session_list() -> list[dict]:
- """Get list of recent sessions."""
sessions_dir = os.path.join(HERMES_HOME, "sessions")
sessions = []
if not os.path.isdir(sessions_dir):
@@ -152,15 +152,54 @@ def _get_session_list() -> list[dict]:
return sessions
+def _proxy_request(url: str, method: str = "GET", headers: dict = None,
+ body: bytes = None, timeout: int = 120) -> tuple[int, dict, bytes]:
+ """Proxy a request to a backend URL and return (status, response_headers, body)."""
+ req = urllib.request.Request(url, data=body, method=method)
+ if headers:
+ for k, v in headers.items():
+ try:
+ req.add_header(k, v)
+ except Exception:
+ pass
+
+ resp_body = b""
+ resp_headers = {}
+ resp_status = 502
+
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ resp_status = resp.status
+ resp_body = resp.read()
+ # Collect response headers (skip hop-by-hop headers)
+ skip = {"transfer-encoding", "connection", "keep-alive", "server", "date"}
+ for key, val in resp.getheaders():
+ if key.lower() not in skip:
+ resp_headers[key] = val
+ except urllib.error.HTTPError as e:
+ resp_status = e.code
+ try:
+ resp_body = e.read()
+ except Exception:
+ resp_body = b""
+ for key, val in list(e.headers.items()):
+ skip = {"transfer-encoding", "connection", "keep-alive", "server", "date"}
+ if key.lower() not in skip:
+ resp_headers[key] = val
+ except Exception as e:
+ logger.warning("Proxy error: %s -> %s", url, e)
+ resp_body = json.dumps({"error": str(e)}).encode()
+
+ return resp_status, resp_headers, resp_body
+
+
# ---------------------------------------------------------------------------
-# Log tailer — reads log file and pushes to SSE subscribers
+# Log tailer
# ---------------------------------------------------------------------------
def _parse_log_line(line: str) -> dict | None:
- """Parse a log line like: 2026-04-27 22:19:12 [INFO] ..."""
m = re.match(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s*\[?(INFO|WARN|WARNING|ERROR|DEBUG)\]?\s*(.*)", line)
if not m:
- # Try other format: [timestamp] [LEVEL] name: msg
m = re.match(r"\[?(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\]?\s*\[?(INFO|WARN|WARNING|ERROR|DEBUG)\]?.*?:\s*(.*)", line)
if not m:
return None
@@ -173,7 +212,6 @@ def _parse_log_line(line: str) -> dict | None:
def _log_tailer():
- """Background thread: tails gateway log and pushes to subscribers."""
global _log_tail_offset
while True:
try:
@@ -181,7 +219,6 @@ def _log_tailer():
time.sleep(2)
continue
with open(LOG_FILE, "r", errors="replace") as f:
- # Seek to where we left off
f.seek(_log_tail_offset)
new_lines = f.readlines()
_log_tail_offset = f.tell()
@@ -192,7 +229,6 @@ def _log_tailer():
if p:
parsed.append(p)
if parsed:
- # Push to all subscribers
dead = []
for q in _log_subscribers:
try:
@@ -212,7 +248,6 @@ def _log_tailer():
# ---------------------------------------------------------------------------
def _ensure_persistent_storage():
- """Create data dirs and symlinks."""
for d in ("sessions", "memories", "uploads", "logs", "palace", "skills"):
os.makedirs(os.path.join(DATA_DIR, d), exist_ok=True)
@@ -226,17 +261,16 @@ def _ensure_persistent_storage():
target.symlink_to(os.path.join(DATA_DIR, d))
logger.info("Symlink: %s -> %s", d, os.path.join(DATA_DIR, d))
except OSError:
- # Symlink failed (maybe in Docker build), just copy the dir structure
target.mkdir(exist_ok=True)
logger.warning("Could not symlink %s, using local dir", d)
# ---------------------------------------------------------------------------
-# HTTP Handler — Dashboard + API
+# HTTP Handler — Reverse Proxy + Dashboard
# ---------------------------------------------------------------------------
-class DashboardHandler(BaseHTTPRequestHandler):
- """Serves dashboard HTML and REST API endpoints."""
+class ProxyHandler(BaseHTTPRequestHandler):
+ """Routes traffic between WebUI BFF, Gateway, and Dashboard."""
def log_message(self, fmt, *args):
pass # silence request logs
@@ -267,7 +301,6 @@ class DashboardHandler(BaseHTTPRequestHandler):
return self.rfile.read(length) if length > 0 else b""
def _send_file(self, filepath: str, content_type: str = None):
- """Serve a static file (JS, CSS, images, etc.)."""
try:
with open(filepath, "rb") as f:
body = f.read()
@@ -282,72 +315,158 @@ class DashboardHandler(BaseHTTPRequestHandler):
except FileNotFoundError:
self.send_error(404)
- def _send_webui_html(self):
- """Serve WebUI SPA index.html."""
- index_path = os.path.join(WEBUI_DIR, "index.html")
- if os.path.isfile(index_path):
- self._send_html(index_path)
- else:
- self.send_error(404, "WebUI not installed")
+ def _send_proxy_response(self, status: int, headers: dict, body: bytes):
+ self.send_response(status)
+ # Always add CORS
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
+ self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Hermes-Session-Id, X-Hermes-Profile, X-Hermes-Session-Token")
+ content_type = headers.pop("Content-Type", headers.pop("content-type", "application/json"))
+ self.send_header("Content-Type", content_type)
+ content_length = headers.pop("Content-Length", headers.pop("content-length", None))
+ if content_length is None:
+ content_length = str(len(body))
+ self.send_header("Content-Length", content_length)
+ for k, v in headers.items():
+ self.send_header(k, v)
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _forward_to_webui(self, path: str, method: str):
+ """Forward request to WebUI BFF on :6060."""
+ url = f"{WEBUI_BFF_URL}{path}"
+ headers = {}
+ # Forward key headers
+ for h in ("Authorization", "Content-Type", "X-Hermes-Session-Id",
+ "X-Hermes-Profile", "X-Hermes-Session-Token"):
+ val = self.headers.get(h)
+ if val:
+ headers[h] = val
+
+ body = self._read_body() if method in ("POST", "PUT", "PATCH", "DELETE") else None
+ status, resp_headers, resp_body = _proxy_request(url, method, headers, body)
+ self._send_proxy_response(status, resp_headers, resp_body)
+
+ def _forward_to_gateway(self, path: str, method: str):
+ """Forward request to Gateway API on :8642."""
+ url = f"{GATEWAY_URL}{path}"
+ headers = {}
+ for h in ("Authorization", "Content-Type", "X-Hermes-Session-Id"):
+ val = self.headers.get(h)
+ if val:
+ headers[h] = val
+
+ body = self._read_body() if method in ("POST", "PUT", "PATCH", "DELETE") else None
+ status, resp_headers, resp_body = _proxy_request(url, method, headers, body)
+ self._send_proxy_response(status, resp_headers, resp_body)
+
+ # ── OPTIONS (CORS preflight) ──
+
+ def do_OPTIONS(self):
+ self.send_response(200)
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
+ self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Hermes-Session-Id, X-Hermes-Profile, X-Hermes-Session-Token")
+ self.send_header("Access-Control-Max-Age", "86400")
+ self.send_header("Content-Length", "0")
+ self.end_headers()
# ── GET routes ──
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
+ qs = parsed.query
- # ── WebUI SPA routes ──
- if path == "/webui" or path == "/webui/":
- return self._send_webui_html()
-
- if path.startswith("/webui/"):
- # Try serving static file first
- static_path = os.path.join(WEBUI_DIR, path[len("/webui/"):])
+ # ── WebUI client static assets ──
+ if path.startswith("/webui/assets/") or path.startswith("/webui/favicon"):
+ static_path = os.path.join(WEBUI_CLIENT_DIR, path[len("/webui/"):])
if os.path.isfile(static_path):
return self._send_file(static_path)
- # SPA fallback: serve index.html for client-side routing
- return self._send_webui_html()
+ self.send_error(404)
+ return
- # ── Dashboard ──
- if path in ("/", "/index.html"):
- return self._send_html(DASHBOARD_HTML)
+ # ── WebUI SPA ──
+ if path == "/webui" or path == "/webui/":
+ index_path = os.path.join(WEBUI_CLIENT_DIR, "index.html")
+ if os.path.isfile(index_path):
+ return self._send_html(index_path)
+ # Fallback: proxy to BFF
+ return self._forward_to_webui("/" + qs if qs else "/", "GET")
+
+ # ── All /api/* and /v1/* routes → proxy to WebUI BFF ──
+ if path.startswith("/api/") or path.startswith("/v1/"):
+ return self._forward_to_webui(self.path, "GET")
- # Deploy overview
+ # ── /health → Gateway health ──
+ if path == "/health":
+ return self._forward_to_gateway("/health", "GET")
+
+ # ── Deploy overview ──
if path == "/deploy":
- return self._send_html(DEPLOY_HTML)
+ return self._send_html("/app/deploy.html")
- # SSE log stream
+ # ── SSE log stream ──
if path == "/api/logs/stream":
return self._handle_sse()
- # Status
+ # ── Dashboard status ──
if path == "/api/status":
return self._send_json(self._get_status())
- # Sessions
+ # ── Sessions ──
if path == "/api/sessions":
return self._send_json(_get_session_list())
- # Log history (REST, not SSE)
+ # ── Log history ──
if path == "/api/logs":
return self._send_json(self._get_log_history(parsed.query))
+ # ── Default: Dashboard ──
+ if path in ("/", "/index.html"):
+ return self._send_html(DASHBOARD_HTML)
+
+ # ── Upload endpoint → proxy to BFF ──
+ if path == "/upload":
+ return self._forward_to_webui(self.path, "GET")
+
self.send_error(404)
# ── POST routes ──
def do_POST(self):
parsed = urlparse(self.path)
+ path = parsed.path
+
+ # All /api/* and /v1/* → proxy to WebUI BFF
+ if path.startswith("/api/") or path.startswith("/v1/"):
+ return self._forward_to_webui(self.path, "POST")
- if parsed.path == "/api/restart":
- return self._handle_restart()
+ # /upload → proxy to BFF
+ if path == "/upload":
+ return self._forward_to_webui(self.path, "POST")
- if parsed.path == "/api/model":
- return self._handle_change_model()
+ self.send_error(404)
- if parsed.path == "/api/reset":
- return self._send_json({"ok": False, "error": "Not implemented in Space mode"})
+ def do_PUT(self):
+ parsed = urlparse(self.path)
+ path = parsed.path
+ if path.startswith("/api/") or path.startswith("/v1/"):
+ return self._forward_to_webui(self.path, "PUT")
+ self.send_error(404)
+ def do_DELETE(self):
+ parsed = urlparse(self.path)
+ path = parsed.path
+ if path.startswith("/api/") or path.startswith("/v1/"):
+ return self._forward_to_webui(self.path, "DELETE")
+ self.send_error(404)
+
+ def do_PATCH(self):
+ parsed = urlparse(self.path)
+ path = parsed.path
+ if path.startswith("/api/") or path.startswith("/v1/"):
+ return self._forward_to_webui(self.path, "PATCH")
self.send_error(404)
# ── Handlers ──
@@ -359,21 +478,12 @@ class DashboardHandler(BaseHTTPRequestHandler):
is_running = False
pid = "N/A"
- # Check gateway process
- if _gateway_process and _gateway_process.poll() is None:
- is_running = True
- pid = str(_gateway_process.pid)
- else:
- # Try to find hermes gateway process
- for proc in psutil.process_iter(["pid", "cmdline"]):
- try:
- cmdline = " ".join(proc.info.get("cmdline") or [])
- if "hermes" in cmdline and "gateway" in cmdline:
- is_running = True
- pid = str(proc.info["pid"])
- break
- except (psutil.NoSuchProcess, psutil.AccessDenied):
- pass
+ # Check gateway health via HTTP
+ try:
+ status, _, body = _proxy_request(f"{GATEWAY_URL}/health", "GET", timeout=3)
+ is_running = (status == 200)
+ except Exception:
+ pass
model = cfg.get("model", env.get("LLM_MODEL", "unknown"))
provider = cfg.get("provider", "openrouter")
@@ -406,7 +516,6 @@ class DashboardHandler(BaseHTTPRequestHandler):
}
def _handle_sse(self):
- """Server-Sent Events for real-time log streaming."""
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
@@ -418,13 +527,11 @@ class DashboardHandler(BaseHTTPRequestHandler):
_log_subscribers.append(q)
try:
- # First, send recent log history
history = self._get_log_history_inner(limit=100)
if history:
self.wfile.write(f"data: {json.dumps(history, ensure_ascii=False)}\n\n".encode())
self.wfile.flush()
- # Then stream new logs
while True:
try:
lines = q.get(timeout=30)
@@ -432,7 +539,6 @@ class DashboardHandler(BaseHTTPRequestHandler):
self.wfile.write(payload.encode())
self.wfile.flush()
except Empty:
- # Send heartbeat
self.wfile.write(":heartbeat\n\n".encode())
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, OSError):
@@ -447,7 +553,6 @@ class DashboardHandler(BaseHTTPRequestHandler):
return self._get_log_history_inner(limit=limit)
def _get_log_history_inner(self, limit: int = 100) -> list:
- """Read last N lines from log file."""
if not os.path.isfile(LOG_FILE):
return []
try:
@@ -462,70 +567,15 @@ class DashboardHandler(BaseHTTPRequestHandler):
except Exception:
return []
- def _handle_restart(self):
- """Restart the gateway process."""
- global _gateway_process, _gateway_start_time
- try:
- if _gateway_process and _gateway_process.poll() is None:
- _gateway_process.terminate()
- _gateway_process.wait(timeout=10)
-
- _gateway_start_time = time.time()
- env = os.environ.copy()
- env["HERMES_ACCEPT_HOOKS"] = "1"
- env["PYTHONUNBUFFERED"] = "1"
-
- _gateway_process = subprocess.Popen(
- [sys.executable, "-u", "-m", "hermes_cli.main", "gateway", "run", "-v"],
- stdout=open(LOG_FILE, "a", buffering=1),
- stderr=subprocess.STDOUT,
- env=env,
- cwd="/app/hermes-agent",
- )
- logger.info("Gateway restarted (PID: %d)", _gateway_process.pid)
- self._send_json({"ok": True, "pid": _gateway_process.pid})
- except Exception as e:
- self._send_json({"ok": False, "error": str(e)}, 500)
-
- def _handle_change_model(self):
- """Change the LLM model in config.yaml."""
- try:
- body = json.loads(self._read_body())
- model = body.get("model", "")
- if not model:
- return self._send_json({"ok": False, "error": "No model specified"})
-
- # Update config.yaml
- config_path = CONFIG_FILE
- if not os.path.isfile(config_path):
- return self._send_json({"ok": False, "error": "config.yaml not found"})
-
- with open(config_path, "r") as f:
- content = f.read()
-
- # Replace model line
- new_content = re.sub(
- r"^model:.*$",
- f"model: {model}",
- content,
- flags=re.MULTILINE,
- )
- with open(config_path, "w") as f:
- f.write(new_content)
-
- logger.info("Model changed to: %s", model)
- self._send_json({"ok": True, "model": model})
- except Exception as e:
- self._send_json({"ok": False, "error": str(e)}, 500)
-
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
- logger.info("=== Hermes Agent — HuggingFace Space Entry ===")
- logger.info("WebUI at /webui (static preview)")
+ logger.info("=== Hermes Bot — HuggingFace Space Entry ===")
+ logger.info("WebUI at /webui (hermes-web-ui)")
+ logger.info("Dashboard at /")
# Setup persistent storage
_ensure_persistent_storage()
@@ -536,28 +586,33 @@ def main():
tailer.start()
logger.info("Log tailer started")
- # Start Hermes Gateway in subprocess (not thread, for isolation)
- global _gateway_process, _gateway_start_time
- _gateway_start_time = time.time()
- env = os.environ.copy()
- env["HERMES_ACCEPT_HOOKS"] = "1"
- env["PYTHONUNBUFFERED"] = "1" # 关键:禁用输出缓冲,日志实时写入文件
-
- os.makedirs(LOG_DIR, exist_ok=True)
- log_fh = open(LOG_FILE, "a", buffering=1) # 行缓冲
-
- _gateway_process = subprocess.Popen(
- [sys.executable, "-u", "-m", "hermes_cli.main", "gateway", "run", "-v"],
- stdout=log_fh,
- stderr=subprocess.STDOUT,
- env=env,
- cwd="/app/hermes-agent",
- )
- logger.info("Gateway started (PID: %d)", _gateway_process.pid)
-
- # Start dashboard HTTP server
- server = ThreadingHTTPServer(("0.0.0.0", 7860), DashboardHandler)
- logger.info("Dashboard listening on :7860")
+ # Check if backend services are reachable
+ for attempt in range(10):
+ try:
+ urllib.request.urlopen(f"{GATEWAY_URL}/health", timeout=2)
+ logger.info("Gateway reachable at %s", GATEWAY_URL)
+ break
+ except Exception:
+ logger.warning("Gateway not ready (attempt %d/10)", attempt + 1)
+ time.sleep(2)
+
+ for attempt in range(10):
+ try:
+ urllib.request.urlopen(f"{WEBUI_BFF_URL}/health", timeout=2)
+ logger.info("WebUI BFF reachable at %s", WEBUI_BFF_URL)
+ break
+ except Exception:
+ logger.warning("WebUI BFF not ready (attempt %d/10)", attempt + 1)
+ time.sleep(2)
+
+ # Start HTTP proxy server
+ server = ThreadingHTTPServer(("0.0.0.0", 7860), ProxyHandler)
+ logger.info("Proxy listening on :7860")
+ logger.info(" / → Dashboard")
+ logger.info(" /webui → hermes-web-ui")
+ logger.info(" /api/* → proxy to WebUI BFF (:6060)")
+ logger.info(" /v1/* → proxy to WebUI BFF (:6060)")
+
server.serve_forever()
diff --git a/start.sh b/start.sh
index 26428999c94ca4b597f7565f705040bb0daf05d3..5ade497d9be5424767593103b9b23cc4dcf4fb07 100755
--- a/start.sh
+++ b/start.sh
@@ -1,6 +1,8 @@
#!/bin/bash
set -e
+echo "=== Hermes Bot — HuggingFace Space Startup ==="
+
# Ensure persistent storage directories exist
mkdir -p /data/hermes/{sessions,memories,uploads,logs,palace,skills}
@@ -29,4 +31,53 @@ else
echo "MemPalace already initialized."
fi
+# Start Hermes Gateway (aiohttp API server on :8642 + Feishu platform)
+echo "Starting Hermes Gateway..."
+PYTHONUNBUFFERED=1 HERMES_ACCEPT_HOOKS=1 python3 -u -m hermes_cli.main gateway run -v \
+ >> /data/hermes/logs/gateway.log 2>&1 &
+GATEWAY_PID=$!
+echo "Gateway PID: $GATEWAY_PID"
+
+# Wait for gateway to be ready
+echo "Waiting for Gateway to start..."
+for i in $(seq 1 30); do
+ if curl -s http://127.0.0.1:8642/health > /dev/null 2>&1; then
+ echo "Gateway is ready on :8642"
+ break
+ fi
+ sleep 2
+done
+
+# Start hermes-web-ui Node.js BFF server on :6060
+echo "Starting hermes-web-ui BFF..."
+export PORT=6060
+export UPSTREAM=http://127.0.0.1:8642
+export HERMES_HOME=/root/.hermes
+export AUTH_TOKEN="${AUTH_TOKEN:-hermes-bot-2026}"
+export CORS_ORIGINS="*"
+export NODE_ENV=production
+cd /app/webui-server
+node index.js >> /data/hermes/logs/webui.log 2>&1 &
+WEBUI_PID=$!
+echo "WebUI BFF PID: $WEBUI_PID"
+
+# Wait for WebUI BFF to be ready
+echo "Waiting for WebUI BFF to start..."
+for i in $(seq 1 30); do
+ if curl -s http://127.0.0.1:6060/health > /dev/null 2>&1; then
+ echo "WebUI BFF is ready on :6060"
+ break
+ fi
+ sleep 2
+done
+
+echo ""
+echo "=== All services started ==="
+echo " Gateway: http://127.0.0.1:8642"
+echo " WebUI: http://127.0.0.1:6060"
+echo " Proxy: http://0.0.0.0:7860"
+echo " Auth Token: $AUTH_TOKEN"
+echo ""
+
+# Start Python proxy on :7860 (main HF Space port)
exec python3 /app/entry.py
diff --git a/webui/assets/Add-D070kowG.js b/webui/assets/Add-D070kowG.js
deleted file mode 100644
index e697b0dc332a9d639a6d72b998c38dd69d979f24..0000000000000000000000000000000000000000
--- a/webui/assets/Add-D070kowG.js
+++ /dev/null
@@ -1 +0,0 @@
-import{Fr as e,Lr as t}from"./index-Cl8-DFW_.js";var n=e({name:`Add`,render(){return t(`svg`,{width:`512`,height:`512`,viewBox:`0 0 512 512`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`},t(`path`,{d:`M256 112V400M400 256H112`,stroke:`currentColor`,"stroke-width":`32`,"stroke-linecap":`round`,"stroke-linejoin":`round`}))}});export{n as t};
\ No newline at end of file
diff --git a/webui/assets/ChannelsView-Br2GRhTp.js b/webui/assets/ChannelsView-Br2GRhTp.js
deleted file mode 100644
index 5c94fd18da8200263d7082017d462c7572074d4b..0000000000000000000000000000000000000000
--- a/webui/assets/ChannelsView-Br2GRhTp.js
+++ /dev/null
@@ -1 +0,0 @@
-import{t as e}from"./Switch-CDXAYhwk.js";import{Ar as t,Dr as n,Fr as r,Gr as i,I as a,Jr as o,N as s,Nr as c,Or as l,Pr as u,Qr as d,Ur as f,Wr as p,Yr as m,ai as h,c as g,ci as _,ei as v,jr as y,k as b,kr as x,lr as S,ni as C,oi as w,qr as T,u as E,wr as D,y as O}from"./index-Cl8-DFW_.js";import{a as k,i as A,n as j,r as M,t as N}from"./settings-Cxn8gJl1.js";import{t as P}from"./SettingRow-BACRFGe8.js";var F={class:`platform-info`},I=[`innerHTML`],L={class:`platform-name`},R={key:0,class:`platform-card-body`},z=g(r({__name:`PlatformCard`,props:{name:{},icon:{},config:{},credentials:{}},setup(e){let r=e,s=C(!0),{t:f}=S(),p=n(()=>{let e=r.credentials;if(!e)return!1;let t=[`token`,`api_key`,`app_id`,`client_id`,`secret`,`app_secret`,`client_secret`,`access_token`,`bot_id`,`account_id`,`enabled`];return[e,e.extra].filter(Boolean).some(e=>t.some(t=>{let n=e[t];return n!=null&&n!==``&&n!==!1}))});return(n,r)=>(i(),y(`div`,{class:w([`platform-card`,{configured:p.value}])},[l(`div`,{class:`platform-card-header`,onClick:r[0]||=e=>s.value=!s.value},[l(`div`,F,[l(`span`,{class:`platform-icon`,innerHTML:e.icon},null,8,I),l(`span`,L,_(e.name),1),u(h(a),{type:p.value?`success`:`default`,size:`small`,round:``},{default:d(()=>[c(_(p.value?h(f)(`common.configured`):h(f)(`common.notConfigured`)),1)]),_:1},8,[`type`])]),l(`span`,{class:w([`expand-icon`,{expanded:s.value}])},`▾`,2)]),s.value?(i(),y(`div`,R,[o(n.$slots,`default`,{},void 0,!0)])):t(``,!0)],2))}}),[[`__scopeId`,`data-v-cee18614`]]),B={class:`settings-section`},V={class:`weixin-qr-section`},H={key:1,class:`weixin-qr-loading`},U={key:2,class:`weixin-qr-hint`},W=g(r({__name:`PlatformSettings`,setup(n){let r=N(),a=O(),{t:o}=S(),f=v({});function g(e,t){return`${e}.${t}`}function w(e,t){return!!f[g(e,t)]}async function E(e,t,n){let r=g(e,t);f[r]=!0;try{await n(),a.success(o(`settings.saved`))}catch{a.error(o(`settings.saveFailed`))}finally{f[r]=!1}}async function F(e,t,n){E(e,t,()=>r.saveSection(e,n))}async function I(e,t,n){E(e,t,async()=>{await A(e,n),await r.fetchSettings()})}function L(e){return r.platforms[e]||{}}let R=C(``),W=C(``),G=C(`idle`),K=null;async function q(){G.value=`loading`,R.value=``,W.value=``,Y();try{let e=await j();W.value=e.qrcode,R.value=e.qrcode_url,window.open(e.qrcode_url,`_blank`),G.value=`waiting`,J()}catch(e){G.value=`error`,a.error(e.message||o(`platform.qrFetching`))}}function J(){W.value&&(K=setTimeout(async()=>{try{let e=await M(W.value);e.status===`wait`?J():e.status===`scaned`?(G.value=`scaned`,J()):e.status===`expired`?G.value=`expired`:e.status===`confirmed`&&(G.value=`confirmed`,await k({account_id:e.account_id,token:e.token,base_url:e.base_url}),await r.fetchSettings(),a.success(o(`settings.saved`)))}catch{J()}},3e3))}function Y(){K&&=(clearTimeout(K),null)}p(()=>{Y()});let X=[{key:`telegram`,name:`Telegram`,icon:`