Spaces:
Running
Running
Z User commited on
Commit ·
f55a35b
1
Parent(s): b200516
revert: restore pure Python setup (remove Node.js build dependency)
Browse files- Revert Dockerfile to pure Python (no Node.js, no npm build)
- Restore original entry.py (dashboard + basic API)
- Restore original start.sh (single Python process)
- This fixes the BUILD_ERROR caused by npm/hermes-web-ui build failures
- WebUI integration will be re-attempted with pre-built assets
- Dockerfile +12 -29
- entry.py +54 -66
- start.sh +15 -25
Dockerfile
CHANGED
|
@@ -1,24 +1,23 @@
|
|
| 1 |
FROM python:3.12-slim
|
| 2 |
|
| 3 |
-
# System deps
|
| 4 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
-
git curl gnupg2 fontconfig
|
| 6 |
-
&& curl -fsSL https://deb.nodesource.com/setup_23.x | bash - \
|
| 7 |
-
&& apt-get install -y --no-install-recommends nodejs \
|
| 8 |
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
|
| 10 |
WORKDIR /app
|
| 11 |
|
| 12 |
-
#
|
| 13 |
RUN git clone --depth 1 https://github.com/NousResearch/hermes-agent.git /app/hermes-agent
|
| 14 |
|
|
|
|
| 15 |
RUN python3 -m venv /app/venv
|
| 16 |
-
ENV PATH="/app/venv/bin:
|
| 17 |
RUN pip install --quiet --upgrade pip && \
|
| 18 |
pip install --quiet psutil networkx && \
|
| 19 |
-
pip install --quiet -e "/app/hermes-agent[feishu,mcp,cron]" 2>&1 | tail -10
|
| 20 |
|
| 21 |
-
# Chinese font (Noto Sans SC, ~16MB)
|
| 22 |
RUN mkdir -p /usr/share/fonts/truetype/noto && \
|
| 23 |
curl -sL "https://github.com/googlefonts/noto-cjk/raw/main/Sans/SubsetOTF/SC/NotoSansSC-Regular.otf" \
|
| 24 |
-o /usr/share/fonts/truetype/noto/NotoSansSC-Regular.otf && \
|
|
@@ -26,42 +25,26 @@ RUN mkdir -p /usr/share/fonts/truetype/noto && \
|
|
| 26 |
-o /usr/share/fonts/truetype/noto/NotoSansSC-Bold.otf && \
|
| 27 |
fc-cache -f
|
| 28 |
|
| 29 |
-
#
|
| 30 |
-
# Use --ignore-scripts to skip 'prepare' (which tries npm run build without esbuild)
|
| 31 |
-
RUN git clone --depth 1 https://github.com/EKKOLearnAI/hermes-web-ui.git /tmp/hermes-web-ui && \
|
| 32 |
-
cd /tmp/hermes-web-ui && \
|
| 33 |
-
npm install --ignore-scripts --quiet 2>&1 | tail -5 && \
|
| 34 |
-
npm rebuild node-pty 2>&1 | tail -3 && \
|
| 35 |
-
npm install --save-dev esbuild --quiet 2>&1 | tail -3 && \
|
| 36 |
-
npm run build 2>&1 | tail -10 && \
|
| 37 |
-
mkdir -p /app/dist && \
|
| 38 |
-
cp -r dist/client /app/dist/client && \
|
| 39 |
-
cp dist/server/index.js /app/dist/server/ && \
|
| 40 |
-
rm -rf /tmp/hermes-web-ui && \
|
| 41 |
-
echo "WebUI build complete"
|
| 42 |
-
|
| 43 |
-
# ── 3. Cleanup build tools (save ~300MB) ──
|
| 44 |
-
RUN apt-get purge -y --auto-remove make g++ gnupg2 2>&1 | tail -3; \
|
| 45 |
-
rm -rf /var/lib/apt/lists/*
|
| 46 |
-
|
| 47 |
-
# ── 4. App config ──
|
| 48 |
RUN mkdir -p /root/.hermes/plugins/image_gen/pollinations
|
| 49 |
|
|
|
|
| 50 |
COPY config.yaml /root/.hermes/config.yaml
|
| 51 |
COPY SOUL.md /root/.hermes/SOUL.md
|
| 52 |
COPY .env /root/.hermes/.env
|
|
|
|
|
|
|
| 53 |
COPY plugins/pollinations/ /root/.hermes/plugins/image_gen/pollinations/
|
| 54 |
COPY scripts/ /app/scripts/
|
| 55 |
|
| 56 |
RUN chmod 600 /root/.hermes/.env
|
| 57 |
|
|
|
|
| 58 |
COPY start.sh /app/start.sh
|
| 59 |
RUN chmod +x /app/start.sh
|
| 60 |
|
| 61 |
EXPOSE 7860
|
| 62 |
|
| 63 |
ENV HERMES_ACCEPT_HOOKS=1
|
| 64 |
-
ENV PORT=7860
|
| 65 |
-
ENV UPSTREAM=http://127.0.0.1:8642
|
| 66 |
|
| 67 |
CMD ["/app/start.sh"]
|
|
|
|
| 1 |
FROM python:3.12-slim
|
| 2 |
|
| 3 |
+
# System deps
|
| 4 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
git curl gnupg2 fontconfig \
|
|
|
|
|
|
|
| 6 |
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
|
| 8 |
WORKDIR /app
|
| 9 |
|
| 10 |
+
# Clone hermes-agent
|
| 11 |
RUN git clone --depth 1 https://github.com/NousResearch/hermes-agent.git /app/hermes-agent
|
| 12 |
|
| 13 |
+
# Build venv
|
| 14 |
RUN python3 -m venv /app/venv
|
| 15 |
+
ENV PATH="/app/venv/bin:$PATH"
|
| 16 |
RUN pip install --quiet --upgrade pip && \
|
| 17 |
pip install --quiet psutil networkx && \
|
| 18 |
+
pip install --quiet -e "/app/hermes-agent[feishu,mcp,cron,pty]" 2>&1 | tail -10
|
| 19 |
|
| 20 |
+
# Chinese font (Noto Sans SC Regular + Bold, ~16MB)
|
| 21 |
RUN mkdir -p /usr/share/fonts/truetype/noto && \
|
| 22 |
curl -sL "https://github.com/googlefonts/noto-cjk/raw/main/Sans/SubsetOTF/SC/NotoSansSC-Regular.otf" \
|
| 23 |
-o /usr/share/fonts/truetype/noto/NotoSansSC-Regular.otf && \
|
|
|
|
| 25 |
-o /usr/share/fonts/truetype/noto/NotoSansSC-Bold.otf && \
|
| 26 |
fc-cache -f
|
| 27 |
|
| 28 |
+
# Create hermes home
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
RUN mkdir -p /root/.hermes/plugins/image_gen/pollinations
|
| 30 |
|
| 31 |
+
# Copy config files
|
| 32 |
COPY config.yaml /root/.hermes/config.yaml
|
| 33 |
COPY SOUL.md /root/.hermes/SOUL.md
|
| 34 |
COPY .env /root/.hermes/.env
|
| 35 |
+
COPY entry.py /app/entry.py
|
| 36 |
+
COPY dashboard.html /app/dashboard.html
|
| 37 |
COPY plugins/pollinations/ /root/.hermes/plugins/image_gen/pollinations/
|
| 38 |
COPY scripts/ /app/scripts/
|
| 39 |
|
| 40 |
RUN chmod 600 /root/.hermes/.env
|
| 41 |
|
| 42 |
+
# Startup script
|
| 43 |
COPY start.sh /app/start.sh
|
| 44 |
RUN chmod +x /app/start.sh
|
| 45 |
|
| 46 |
EXPOSE 7860
|
| 47 |
|
| 48 |
ENV HERMES_ACCEPT_HOOKS=1
|
|
|
|
|
|
|
| 49 |
|
| 50 |
CMD ["/app/start.sh"]
|
entry.py
CHANGED
|
@@ -3,8 +3,6 @@
|
|
| 3 |
|
| 4 |
Serves a real-time monitoring dashboard on port 7860 and runs the
|
| 5 |
Hermes Gateway (Feishu WebSocket bot) in a background thread.
|
| 6 |
-
|
| 7 |
-
v5.0+: Also serves hermes-web-ui (Vue SPA) at /webui
|
| 8 |
"""
|
| 9 |
|
| 10 |
import json
|
|
@@ -23,7 +21,6 @@ from pathlib import Path
|
|
| 23 |
from urllib.parse import urlparse, parse_qs
|
| 24 |
from queue import Queue, Empty
|
| 25 |
from io import BytesIO
|
| 26 |
-
import mimetypes
|
| 27 |
|
| 28 |
|
| 29 |
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
|
@@ -40,7 +37,7 @@ LOG_FILE = os.path.join(LOG_DIR, "gateway.log")
|
|
| 40 |
CONFIG_FILE = os.path.join(HERMES_HOME, "config.yaml")
|
| 41 |
ENV_FILE = os.path.join(HERMES_HOME, ".env")
|
| 42 |
DASHBOARD_HTML = "/app/dashboard.html"
|
| 43 |
-
|
| 44 |
|
| 45 |
# ---------------------------------------------------------------------------
|
| 46 |
# Logging
|
|
@@ -65,10 +62,10 @@ _log_tail_offset = 0
|
|
| 65 |
# ---------------------------------------------------------------------------
|
| 66 |
|
| 67 |
def _mask_key(key: str) -> str:
|
| 68 |
-
"""Mask API key for display: sk-or-v1-abc...xyz
|
| 69 |
if not key or len(key) < 10:
|
| 70 |
-
return "
|
| 71 |
-
return key[:7] + "
|
| 72 |
|
| 73 |
|
| 74 |
def _load_env() -> dict[str, str]:
|
|
@@ -95,6 +92,7 @@ def _load_config() -> dict:
|
|
| 95 |
with open(CONFIG_FILE) as f:
|
| 96 |
return yaml.safe_load(f) or {}
|
| 97 |
except ImportError:
|
|
|
|
| 98 |
cfg: dict = {}
|
| 99 |
try:
|
| 100 |
with open(CONFIG_FILE) as f:
|
|
@@ -115,6 +113,7 @@ def _load_config() -> dict:
|
|
| 115 |
|
| 116 |
|
| 117 |
def _get_sessions_count() -> int:
|
|
|
|
| 118 |
sessions_dir = os.path.join(HERMES_HOME, "sessions")
|
| 119 |
if not os.path.isdir(sessions_dir):
|
| 120 |
return 0
|
|
@@ -125,6 +124,7 @@ def _get_sessions_count() -> int:
|
|
| 125 |
|
| 126 |
|
| 127 |
def _get_session_list() -> list[dict]:
|
|
|
|
| 128 |
sessions_dir = os.path.join(HERMES_HOME, "sessions")
|
| 129 |
sessions = []
|
| 130 |
if not os.path.isdir(sessions_dir):
|
|
@@ -150,12 +150,14 @@ def _get_session_list() -> list[dict]:
|
|
| 150 |
|
| 151 |
|
| 152 |
# ---------------------------------------------------------------------------
|
| 153 |
-
# Log tailer
|
| 154 |
# ---------------------------------------------------------------------------
|
| 155 |
|
| 156 |
def _parse_log_line(line: str) -> dict | None:
|
|
|
|
| 157 |
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)
|
| 158 |
if not m:
|
|
|
|
| 159 |
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)
|
| 160 |
if not m:
|
| 161 |
return None
|
|
@@ -168,6 +170,7 @@ def _parse_log_line(line: str) -> dict | None:
|
|
| 168 |
|
| 169 |
|
| 170 |
def _log_tailer():
|
|
|
|
| 171 |
global _log_tail_offset
|
| 172 |
while True:
|
| 173 |
try:
|
|
@@ -175,6 +178,7 @@ def _log_tailer():
|
|
| 175 |
time.sleep(2)
|
| 176 |
continue
|
| 177 |
with open(LOG_FILE, "r", errors="replace") as f:
|
|
|
|
| 178 |
f.seek(_log_tail_offset)
|
| 179 |
new_lines = f.readlines()
|
| 180 |
_log_tail_offset = f.tell()
|
|
@@ -185,6 +189,7 @@ def _log_tailer():
|
|
| 185 |
if p:
|
| 186 |
parsed.append(p)
|
| 187 |
if parsed:
|
|
|
|
| 188 |
dead = []
|
| 189 |
for q in _log_subscribers:
|
| 190 |
try:
|
|
@@ -200,14 +205,17 @@ def _log_tailer():
|
|
| 200 |
|
| 201 |
|
| 202 |
# ---------------------------------------------------------------------------
|
| 203 |
-
# Persistent storage
|
| 204 |
# ---------------------------------------------------------------------------
|
| 205 |
|
| 206 |
def _ensure_persistent_storage():
|
|
|
|
| 207 |
for d in ("sessions", "memories", "uploads", "logs", "palace", "skills"):
|
| 208 |
os.makedirs(os.path.join(DATA_DIR, d), exist_ok=True)
|
|
|
|
| 209 |
hermes = Path(HERMES_HOME)
|
| 210 |
hermes.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 211 |
for d in ("sessions", "memories", "uploads", "logs", "palace", "skills"):
|
| 212 |
target = hermes / d
|
| 213 |
if not target.exists():
|
|
@@ -215,19 +223,20 @@ def _ensure_persistent_storage():
|
|
| 215 |
target.symlink_to(os.path.join(DATA_DIR, d))
|
| 216 |
logger.info("Symlink: %s -> %s", d, os.path.join(DATA_DIR, d))
|
| 217 |
except OSError:
|
|
|
|
| 218 |
target.mkdir(exist_ok=True)
|
| 219 |
logger.warning("Could not symlink %s, using local dir", d)
|
| 220 |
|
| 221 |
|
| 222 |
# ---------------------------------------------------------------------------
|
| 223 |
-
# HTTP Handler — Dashboard +
|
| 224 |
# ---------------------------------------------------------------------------
|
| 225 |
|
| 226 |
class DashboardHandler(BaseHTTPRequestHandler):
|
| 227 |
-
"""Serves dashboard HTML
|
| 228 |
|
| 229 |
def log_message(self, fmt, *args):
|
| 230 |
-
pass
|
| 231 |
|
| 232 |
def _send_json(self, data: dict, status=200):
|
| 233 |
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
@@ -250,70 +259,37 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|
| 250 |
except FileNotFoundError:
|
| 251 |
self.send_error(404)
|
| 252 |
|
| 253 |
-
def _send_file(self, filepath: str, content_type: str = None):
|
| 254 |
-
"""Serve a static file from the webui directory."""
|
| 255 |
-
try:
|
| 256 |
-
with open(filepath, "rb") as f:
|
| 257 |
-
body = f.read()
|
| 258 |
-
if content_type is None:
|
| 259 |
-
content_type = mimetypes.guess_type(filepath)[0] or "application/octet-stream"
|
| 260 |
-
self.send_response(200)
|
| 261 |
-
self.send_header("Content-Type", content_type)
|
| 262 |
-
self.send_header("Content-Length", str(len(body)))
|
| 263 |
-
self.send_header("Cache-Control", "public, max-age=31536000")
|
| 264 |
-
self.end_headers()
|
| 265 |
-
self.wfile.write(body)
|
| 266 |
-
except FileNotFoundError:
|
| 267 |
-
self.send_error(404)
|
| 268 |
-
|
| 269 |
def _read_body(self) -> bytes:
|
| 270 |
length = int(self.headers.get("Content-Length", 0))
|
| 271 |
return self.rfile.read(length) if length > 0 else b""
|
| 272 |
|
| 273 |
-
def _send_webui_html(self):
|
| 274 |
-
"""Serve WebUI SPA index.html."""
|
| 275 |
-
index_path = os.path.join(WEBUI_DIR, "index.html")
|
| 276 |
-
if os.path.isfile(index_path):
|
| 277 |
-
self._send_html(index_path)
|
| 278 |
-
else:
|
| 279 |
-
self.send_error(404, "WebUI not built")
|
| 280 |
-
|
| 281 |
# ── GET routes ──
|
| 282 |
|
| 283 |
def do_GET(self):
|
| 284 |
parsed = urlparse(self.path)
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
if path == "/webui" or path == "/webui/":
|
| 289 |
-
return self._send_webui_html()
|
| 290 |
-
|
| 291 |
-
if path.startswith("/webui/"):
|
| 292 |
-
# Try serving static file first
|
| 293 |
-
static_path = os.path.join(WEBUI_DIR, path[len("/webui/"):])
|
| 294 |
-
if os.path.isfile(static_path):
|
| 295 |
-
return self._send_file(static_path)
|
| 296 |
-
# SPA fallback: serve index.html for client-side routing
|
| 297 |
-
return self._send_webui_html()
|
| 298 |
-
|
| 299 |
-
# Legacy dashboard
|
| 300 |
-
if path in ("/", "/index.html"):
|
| 301 |
return self._send_html(DASHBOARD_HTML)
|
| 302 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
# SSE log stream
|
| 304 |
-
if path == "/api/logs/stream":
|
| 305 |
return self._handle_sse()
|
| 306 |
|
| 307 |
# Status
|
| 308 |
-
if path == "/api/status":
|
| 309 |
return self._send_json(self._get_status())
|
| 310 |
|
| 311 |
# Sessions
|
| 312 |
-
if path == "/api/sessions":
|
| 313 |
return self._send_json(_get_session_list())
|
| 314 |
|
| 315 |
-
# Log history
|
| 316 |
-
if path == "/api/logs":
|
| 317 |
return self._send_json(self._get_log_history(parsed.query))
|
| 318 |
|
| 319 |
self.send_error(404)
|
|
@@ -343,10 +319,12 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|
| 343 |
is_running = False
|
| 344 |
pid = "N/A"
|
| 345 |
|
|
|
|
| 346 |
if _gateway_process and _gateway_process.poll() is None:
|
| 347 |
is_running = True
|
| 348 |
pid = str(_gateway_process.pid)
|
| 349 |
else:
|
|
|
|
| 350 |
for proc in psutil.process_iter(["pid", "cmdline"]):
|
| 351 |
try:
|
| 352 |
cmdline = " ".join(proc.info.get("cmdline") or [])
|
|
@@ -368,7 +346,7 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|
| 368 |
"model": model,
|
| 369 |
"provider": provider,
|
| 370 |
"fallback_model": fallback_model,
|
| 371 |
-
"platform": "Feishu",
|
| 372 |
"platform_mode": "WebSocket",
|
| 373 |
"sessions": _get_sessions_count(),
|
| 374 |
"messages": 0,
|
|
@@ -379,14 +357,16 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|
| 379 |
"FEISHU_APP_ID": env.get("FEISHU_APP_ID", ""),
|
| 380 |
"FEISHU_APP_SECRET": _mask_key(env.get("FEISHU_APP_SECRET", "")),
|
| 381 |
"terminal": cfg.get("terminal", {}).get("backend", "local") if isinstance(cfg.get("terminal"), dict) else "local",
|
| 382 |
-
"timezone": cfg.get("timezone", "UTC+8"),
|
| 383 |
"max_turns": cfg.get("max_turns", "90"),
|
| 384 |
"memory": cfg.get("memory", {}).get("provider", "none") if isinstance(cfg.get("memory"), dict) else "none",
|
|
|
|
| 385 |
"compress": cfg.get("compress", {}).get("enabled", False) if isinstance(cfg.get("compress"), dict) else False,
|
| 386 |
},
|
| 387 |
}
|
| 388 |
|
| 389 |
def _handle_sse(self):
|
|
|
|
| 390 |
self.send_response(200)
|
| 391 |
self.send_header("Content-Type", "text/event-stream")
|
| 392 |
self.send_header("Cache-Control", "no-cache")
|
|
@@ -398,11 +378,13 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|
| 398 |
_log_subscribers.append(q)
|
| 399 |
|
| 400 |
try:
|
|
|
|
| 401 |
history = self._get_log_history_inner(limit=100)
|
| 402 |
if history:
|
| 403 |
self.wfile.write(f"data: {json.dumps(history, ensure_ascii=False)}\n\n".encode())
|
| 404 |
self.wfile.flush()
|
| 405 |
|
|
|
|
| 406 |
while True:
|
| 407 |
try:
|
| 408 |
lines = q.get(timeout=30)
|
|
@@ -410,6 +392,7 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|
| 410 |
self.wfile.write(payload.encode())
|
| 411 |
self.wfile.flush()
|
| 412 |
except Empty:
|
|
|
|
| 413 |
self.wfile.write(":heartbeat\n\n".encode())
|
| 414 |
self.wfile.flush()
|
| 415 |
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
@@ -424,6 +407,7 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|
| 424 |
return self._get_log_history_inner(limit=limit)
|
| 425 |
|
| 426 |
def _get_log_history_inner(self, limit: int = 100) -> list:
|
|
|
|
| 427 |
if not os.path.isfile(LOG_FILE):
|
| 428 |
return []
|
| 429 |
try:
|
|
@@ -439,6 +423,7 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|
| 439 |
return []
|
| 440 |
|
| 441 |
def _handle_restart(self):
|
|
|
|
| 442 |
global _gateway_process, _gateway_start_time
|
| 443 |
try:
|
| 444 |
if _gateway_process and _gateway_process.poll() is None:
|
|
@@ -463,12 +448,14 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|
| 463 |
self._send_json({"ok": False, "error": str(e)}, 500)
|
| 464 |
|
| 465 |
def _handle_change_model(self):
|
|
|
|
| 466 |
try:
|
| 467 |
body = json.loads(self._read_body())
|
| 468 |
model = body.get("model", "")
|
| 469 |
if not model:
|
| 470 |
return self._send_json({"ok": False, "error": "No model specified"})
|
| 471 |
|
|
|
|
| 472 |
config_path = CONFIG_FILE
|
| 473 |
if not os.path.isfile(config_path):
|
| 474 |
return self._send_json({"ok": False, "error": "config.yaml not found"})
|
|
@@ -476,6 +463,7 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|
| 476 |
with open(config_path, "r") as f:
|
| 477 |
content = f.read()
|
| 478 |
|
|
|
|
| 479 |
new_content = re.sub(
|
| 480 |
r"^model:.*$",
|
| 481 |
f"model: {model}",
|
|
@@ -496,24 +484,26 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|
| 496 |
# ---------------------------------------------------------------------------
|
| 497 |
|
| 498 |
def main():
|
| 499 |
-
logger.info("=== Hermes Agent
|
| 500 |
-
logger.info("WebUI available at /webui")
|
| 501 |
|
|
|
|
| 502 |
_ensure_persistent_storage()
|
| 503 |
logger.info("Persistent storage ready at %s", DATA_DIR)
|
| 504 |
|
|
|
|
| 505 |
tailer = threading.Thread(target=_log_tailer, daemon=True)
|
| 506 |
tailer.start()
|
| 507 |
logger.info("Log tailer started")
|
| 508 |
|
|
|
|
| 509 |
global _gateway_process, _gateway_start_time
|
| 510 |
_gateway_start_time = time.time()
|
| 511 |
env = os.environ.copy()
|
| 512 |
env["HERMES_ACCEPT_HOOKS"] = "1"
|
| 513 |
-
env["PYTHONUNBUFFERED"] = "1"
|
| 514 |
|
| 515 |
os.makedirs(LOG_DIR, exist_ok=True)
|
| 516 |
-
log_fh = open(LOG_FILE, "a", buffering=1)
|
| 517 |
|
| 518 |
_gateway_process = subprocess.Popen(
|
| 519 |
[sys.executable, "-u", "-m", "hermes_cli.main", "gateway", "run", "-v"],
|
|
@@ -524,11 +514,9 @@ def main():
|
|
| 524 |
)
|
| 525 |
logger.info("Gateway started (PID: %d)", _gateway_process.pid)
|
| 526 |
|
|
|
|
| 527 |
server = ThreadingHTTPServer(("0.0.0.0", 7860), DashboardHandler)
|
| 528 |
logger.info("Dashboard listening on :7860")
|
| 529 |
-
logger.info("Access URLs:")
|
| 530 |
-
logger.info(" Dashboard (old): https://jackken-hermes-bot.hf.space/")
|
| 531 |
-
logger.info(" WebUI (new): https://jackken-hermes-bot.hf.space/webui")
|
| 532 |
server.serve_forever()
|
| 533 |
|
| 534 |
|
|
|
|
| 3 |
|
| 4 |
Serves a real-time monitoring dashboard on port 7860 and runs the
|
| 5 |
Hermes Gateway (Feishu WebSocket bot) in a background thread.
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import json
|
|
|
|
| 21 |
from urllib.parse import urlparse, parse_qs
|
| 22 |
from queue import Queue, Empty
|
| 23 |
from io import BytesIO
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
|
|
|
| 37 |
CONFIG_FILE = os.path.join(HERMES_HOME, "config.yaml")
|
| 38 |
ENV_FILE = os.path.join(HERMES_HOME, ".env")
|
| 39 |
DASHBOARD_HTML = "/app/dashboard.html"
|
| 40 |
+
DEPLOY_HTML = "/app/deploy.html"
|
| 41 |
|
| 42 |
# ---------------------------------------------------------------------------
|
| 43 |
# Logging
|
|
|
|
| 62 |
# ---------------------------------------------------------------------------
|
| 63 |
|
| 64 |
def _mask_key(key: str) -> str:
|
| 65 |
+
"""Mask API key for display: sk-or-v1-abc...xyz → sk-or-v1-ab••••wxyz"""
|
| 66 |
if not key or len(key) < 10:
|
| 67 |
+
return "••••••••"
|
| 68 |
+
return key[:7] + "••••" + key[-4:]
|
| 69 |
|
| 70 |
|
| 71 |
def _load_env() -> dict[str, str]:
|
|
|
|
| 92 |
with open(CONFIG_FILE) as f:
|
| 93 |
return yaml.safe_load(f) or {}
|
| 94 |
except ImportError:
|
| 95 |
+
# Fallback: simple flat parser (no nested support)
|
| 96 |
cfg: dict = {}
|
| 97 |
try:
|
| 98 |
with open(CONFIG_FILE) as f:
|
|
|
|
| 113 |
|
| 114 |
|
| 115 |
def _get_sessions_count() -> int:
|
| 116 |
+
"""Count session files."""
|
| 117 |
sessions_dir = os.path.join(HERMES_HOME, "sessions")
|
| 118 |
if not os.path.isdir(sessions_dir):
|
| 119 |
return 0
|
|
|
|
| 124 |
|
| 125 |
|
| 126 |
def _get_session_list() -> list[dict]:
|
| 127 |
+
"""Get list of recent sessions."""
|
| 128 |
sessions_dir = os.path.join(HERMES_HOME, "sessions")
|
| 129 |
sessions = []
|
| 130 |
if not os.path.isdir(sessions_dir):
|
|
|
|
| 150 |
|
| 151 |
|
| 152 |
# ---------------------------------------------------------------------------
|
| 153 |
+
# Log tailer — reads log file and pushes to SSE subscribers
|
| 154 |
# ---------------------------------------------------------------------------
|
| 155 |
|
| 156 |
def _parse_log_line(line: str) -> dict | None:
|
| 157 |
+
"""Parse a log line like: 2026-04-27 22:19:12 [INFO] ..."""
|
| 158 |
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)
|
| 159 |
if not m:
|
| 160 |
+
# Try other format: [timestamp] [LEVEL] name: msg
|
| 161 |
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)
|
| 162 |
if not m:
|
| 163 |
return None
|
|
|
|
| 170 |
|
| 171 |
|
| 172 |
def _log_tailer():
|
| 173 |
+
"""Background thread: tails gateway log and pushes to subscribers."""
|
| 174 |
global _log_tail_offset
|
| 175 |
while True:
|
| 176 |
try:
|
|
|
|
| 178 |
time.sleep(2)
|
| 179 |
continue
|
| 180 |
with open(LOG_FILE, "r", errors="replace") as f:
|
| 181 |
+
# Seek to where we left off
|
| 182 |
f.seek(_log_tail_offset)
|
| 183 |
new_lines = f.readlines()
|
| 184 |
_log_tail_offset = f.tell()
|
|
|
|
| 189 |
if p:
|
| 190 |
parsed.append(p)
|
| 191 |
if parsed:
|
| 192 |
+
# Push to all subscribers
|
| 193 |
dead = []
|
| 194 |
for q in _log_subscribers:
|
| 195 |
try:
|
|
|
|
| 205 |
|
| 206 |
|
| 207 |
# ---------------------------------------------------------------------------
|
| 208 |
+
# Persistent storage setup
|
| 209 |
# ---------------------------------------------------------------------------
|
| 210 |
|
| 211 |
def _ensure_persistent_storage():
|
| 212 |
+
"""Create data dirs and symlinks."""
|
| 213 |
for d in ("sessions", "memories", "uploads", "logs", "palace", "skills"):
|
| 214 |
os.makedirs(os.path.join(DATA_DIR, d), exist_ok=True)
|
| 215 |
+
|
| 216 |
hermes = Path(HERMES_HOME)
|
| 217 |
hermes.mkdir(parents=True, exist_ok=True)
|
| 218 |
+
|
| 219 |
for d in ("sessions", "memories", "uploads", "logs", "palace", "skills"):
|
| 220 |
target = hermes / d
|
| 221 |
if not target.exists():
|
|
|
|
| 223 |
target.symlink_to(os.path.join(DATA_DIR, d))
|
| 224 |
logger.info("Symlink: %s -> %s", d, os.path.join(DATA_DIR, d))
|
| 225 |
except OSError:
|
| 226 |
+
# Symlink failed (maybe in Docker build), just copy the dir structure
|
| 227 |
target.mkdir(exist_ok=True)
|
| 228 |
logger.warning("Could not symlink %s, using local dir", d)
|
| 229 |
|
| 230 |
|
| 231 |
# ---------------------------------------------------------------------------
|
| 232 |
+
# HTTP Handler — Dashboard + API
|
| 233 |
# ---------------------------------------------------------------------------
|
| 234 |
|
| 235 |
class DashboardHandler(BaseHTTPRequestHandler):
|
| 236 |
+
"""Serves dashboard HTML and REST API endpoints."""
|
| 237 |
|
| 238 |
def log_message(self, fmt, *args):
|
| 239 |
+
pass # silence request logs
|
| 240 |
|
| 241 |
def _send_json(self, data: dict, status=200):
|
| 242 |
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
|
|
| 259 |
except FileNotFoundError:
|
| 260 |
self.send_error(404)
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
def _read_body(self) -> bytes:
|
| 263 |
length = int(self.headers.get("Content-Length", 0))
|
| 264 |
return self.rfile.read(length) if length > 0 else b""
|
| 265 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
# ── GET routes ──
|
| 267 |
|
| 268 |
def do_GET(self):
|
| 269 |
parsed = urlparse(self.path)
|
| 270 |
+
|
| 271 |
+
# Dashboard
|
| 272 |
+
if parsed.path in ("/", "/index.html"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
return self._send_html(DASHBOARD_HTML)
|
| 274 |
|
| 275 |
+
# Deploy overview
|
| 276 |
+
if parsed.path == "/deploy":
|
| 277 |
+
return self._send_html(DEPLOY_HTML)
|
| 278 |
+
|
| 279 |
# SSE log stream
|
| 280 |
+
if parsed.path == "/api/logs/stream":
|
| 281 |
return self._handle_sse()
|
| 282 |
|
| 283 |
# Status
|
| 284 |
+
if parsed.path == "/api/status":
|
| 285 |
return self._send_json(self._get_status())
|
| 286 |
|
| 287 |
# Sessions
|
| 288 |
+
if parsed.path == "/api/sessions":
|
| 289 |
return self._send_json(_get_session_list())
|
| 290 |
|
| 291 |
+
# Log history (REST, not SSE)
|
| 292 |
+
if parsed.path == "/api/logs":
|
| 293 |
return self._send_json(self._get_log_history(parsed.query))
|
| 294 |
|
| 295 |
self.send_error(404)
|
|
|
|
| 319 |
is_running = False
|
| 320 |
pid = "N/A"
|
| 321 |
|
| 322 |
+
# Check gateway process
|
| 323 |
if _gateway_process and _gateway_process.poll() is None:
|
| 324 |
is_running = True
|
| 325 |
pid = str(_gateway_process.pid)
|
| 326 |
else:
|
| 327 |
+
# Try to find hermes gateway process
|
| 328 |
for proc in psutil.process_iter(["pid", "cmdline"]):
|
| 329 |
try:
|
| 330 |
cmdline = " ".join(proc.info.get("cmdline") or [])
|
|
|
|
| 346 |
"model": model,
|
| 347 |
"provider": provider,
|
| 348 |
"fallback_model": fallback_model,
|
| 349 |
+
"platform": "飞书 Feishu",
|
| 350 |
"platform_mode": "WebSocket",
|
| 351 |
"sessions": _get_sessions_count(),
|
| 352 |
"messages": 0,
|
|
|
|
| 357 |
"FEISHU_APP_ID": env.get("FEISHU_APP_ID", ""),
|
| 358 |
"FEISHU_APP_SECRET": _mask_key(env.get("FEISHU_APP_SECRET", "")),
|
| 359 |
"terminal": cfg.get("terminal", {}).get("backend", "local") if isinstance(cfg.get("terminal"), dict) else "local",
|
| 360 |
+
"timezone": cfg.get("timezone", "北京时间 (UTC+8)"),
|
| 361 |
"max_turns": cfg.get("max_turns", "90"),
|
| 362 |
"memory": cfg.get("memory", {}).get("provider", "none") if isinstance(cfg.get("memory"), dict) else "none",
|
| 363 |
+
"mcp_servers": list(cfg.get("mcp_servers", {}).keys()) if isinstance(cfg.get("mcp_servers"), dict) else [],
|
| 364 |
"compress": cfg.get("compress", {}).get("enabled", False) if isinstance(cfg.get("compress"), dict) else False,
|
| 365 |
},
|
| 366 |
}
|
| 367 |
|
| 368 |
def _handle_sse(self):
|
| 369 |
+
"""Server-Sent Events for real-time log streaming."""
|
| 370 |
self.send_response(200)
|
| 371 |
self.send_header("Content-Type", "text/event-stream")
|
| 372 |
self.send_header("Cache-Control", "no-cache")
|
|
|
|
| 378 |
_log_subscribers.append(q)
|
| 379 |
|
| 380 |
try:
|
| 381 |
+
# First, send recent log history
|
| 382 |
history = self._get_log_history_inner(limit=100)
|
| 383 |
if history:
|
| 384 |
self.wfile.write(f"data: {json.dumps(history, ensure_ascii=False)}\n\n".encode())
|
| 385 |
self.wfile.flush()
|
| 386 |
|
| 387 |
+
# Then stream new logs
|
| 388 |
while True:
|
| 389 |
try:
|
| 390 |
lines = q.get(timeout=30)
|
|
|
|
| 392 |
self.wfile.write(payload.encode())
|
| 393 |
self.wfile.flush()
|
| 394 |
except Empty:
|
| 395 |
+
# Send heartbeat
|
| 396 |
self.wfile.write(":heartbeat\n\n".encode())
|
| 397 |
self.wfile.flush()
|
| 398 |
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
|
|
| 407 |
return self._get_log_history_inner(limit=limit)
|
| 408 |
|
| 409 |
def _get_log_history_inner(self, limit: int = 100) -> list:
|
| 410 |
+
"""Read last N lines from log file."""
|
| 411 |
if not os.path.isfile(LOG_FILE):
|
| 412 |
return []
|
| 413 |
try:
|
|
|
|
| 423 |
return []
|
| 424 |
|
| 425 |
def _handle_restart(self):
|
| 426 |
+
"""Restart the gateway process."""
|
| 427 |
global _gateway_process, _gateway_start_time
|
| 428 |
try:
|
| 429 |
if _gateway_process and _gateway_process.poll() is None:
|
|
|
|
| 448 |
self._send_json({"ok": False, "error": str(e)}, 500)
|
| 449 |
|
| 450 |
def _handle_change_model(self):
|
| 451 |
+
"""Change the LLM model in config.yaml."""
|
| 452 |
try:
|
| 453 |
body = json.loads(self._read_body())
|
| 454 |
model = body.get("model", "")
|
| 455 |
if not model:
|
| 456 |
return self._send_json({"ok": False, "error": "No model specified"})
|
| 457 |
|
| 458 |
+
# Update config.yaml
|
| 459 |
config_path = CONFIG_FILE
|
| 460 |
if not os.path.isfile(config_path):
|
| 461 |
return self._send_json({"ok": False, "error": "config.yaml not found"})
|
|
|
|
| 463 |
with open(config_path, "r") as f:
|
| 464 |
content = f.read()
|
| 465 |
|
| 466 |
+
# Replace model line
|
| 467 |
new_content = re.sub(
|
| 468 |
r"^model:.*$",
|
| 469 |
f"model: {model}",
|
|
|
|
| 484 |
# ---------------------------------------------------------------------------
|
| 485 |
|
| 486 |
def main():
|
| 487 |
+
logger.info("=== Hermes Agent — HuggingFace Space Entry ===")
|
|
|
|
| 488 |
|
| 489 |
+
# Setup persistent storage
|
| 490 |
_ensure_persistent_storage()
|
| 491 |
logger.info("Persistent storage ready at %s", DATA_DIR)
|
| 492 |
|
| 493 |
+
# Start log tailer thread
|
| 494 |
tailer = threading.Thread(target=_log_tailer, daemon=True)
|
| 495 |
tailer.start()
|
| 496 |
logger.info("Log tailer started")
|
| 497 |
|
| 498 |
+
# Start Hermes Gateway in subprocess (not thread, for isolation)
|
| 499 |
global _gateway_process, _gateway_start_time
|
| 500 |
_gateway_start_time = time.time()
|
| 501 |
env = os.environ.copy()
|
| 502 |
env["HERMES_ACCEPT_HOOKS"] = "1"
|
| 503 |
+
env["PYTHONUNBUFFERED"] = "1" # 关键:禁用输出缓冲,日志实时写入文件
|
| 504 |
|
| 505 |
os.makedirs(LOG_DIR, exist_ok=True)
|
| 506 |
+
log_fh = open(LOG_FILE, "a", buffering=1) # 行缓冲
|
| 507 |
|
| 508 |
_gateway_process = subprocess.Popen(
|
| 509 |
[sys.executable, "-u", "-m", "hermes_cli.main", "gateway", "run", "-v"],
|
|
|
|
| 514 |
)
|
| 515 |
logger.info("Gateway started (PID: %d)", _gateway_process.pid)
|
| 516 |
|
| 517 |
+
# Start dashboard HTTP server
|
| 518 |
server = ThreadingHTTPServer(("0.0.0.0", 7860), DashboardHandler)
|
| 519 |
logger.info("Dashboard listening on :7860")
|
|
|
|
|
|
|
|
|
|
| 520 |
server.serve_forever()
|
| 521 |
|
| 522 |
|
start.sh
CHANGED
|
@@ -1,42 +1,32 @@
|
|
| 1 |
#!/bin/bash
|
| 2 |
set -e
|
| 3 |
|
| 4 |
-
#
|
| 5 |
mkdir -p /data/hermes/{sessions,memories,uploads,logs,palace,skills}
|
| 6 |
|
|
|
|
| 7 |
HERMES_HOME="/root/.hermes"
|
| 8 |
for dir in sessions memories uploads logs palace skills; do
|
| 9 |
target="$HERMES_HOME/$dir"
|
| 10 |
if [ ! -L "$target" ] && [ ! -d "$target" ]; then
|
| 11 |
ln -sf "/data/hermes/$dir" "$target"
|
| 12 |
-
echo "
|
| 13 |
elif [ -L "$target" ]; then
|
| 14 |
echo "Symlink exists: $dir"
|
| 15 |
fi
|
| 16 |
done
|
| 17 |
-
echo "Persistent storage ready."
|
| 18 |
|
| 19 |
-
|
| 20 |
-
mkdir -p "$HERMES_HOME/logs"
|
| 21 |
-
echo "Starting hermes gateway..."
|
| 22 |
-
python3 -u -m hermes_cli.main gateway run -v \
|
| 23 |
-
>> "$HERMES_HOME/logs/gateway.log" 2>&1 &
|
| 24 |
-
GATEWAY_PID=$!
|
| 25 |
-
echo "Gateway PID: $GATEWAY_PID"
|
| 26 |
|
| 27 |
-
#
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
done
|
| 38 |
|
| 39 |
-
|
| 40 |
-
echo "Starting hermes-web-ui on port ${PORT:-7860}..."
|
| 41 |
-
export NODE_ENV=production
|
| 42 |
-
exec node /app/dist/server/index.js
|
|
|
|
| 1 |
#!/bin/bash
|
| 2 |
set -e
|
| 3 |
|
| 4 |
+
# Ensure persistent storage directories exist
|
| 5 |
mkdir -p /data/hermes/{sessions,memories,uploads,logs,palace,skills}
|
| 6 |
|
| 7 |
+
# Create symlinks from hermes home to persistent storage
|
| 8 |
HERMES_HOME="/root/.hermes"
|
| 9 |
for dir in sessions memories uploads logs palace skills; do
|
| 10 |
target="$HERMES_HOME/$dir"
|
| 11 |
if [ ! -L "$target" ] && [ ! -d "$target" ]; then
|
| 12 |
ln -sf "/data/hermes/$dir" "$target"
|
| 13 |
+
echo "Created symlink: $dir -> /data/hermes/$dir"
|
| 14 |
elif [ -L "$target" ]; then
|
| 15 |
echo "Symlink exists: $dir"
|
| 16 |
fi
|
| 17 |
done
|
|
|
|
| 18 |
|
| 19 |
+
echo "Persistent storage ready."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
# Initialize MemPalace if not already
|
| 22 |
+
PALACE_PATH="${MEMPALACE_PALACE_PATH:-/data/hermes/palace}"
|
| 23 |
+
if [ ! -f "$PALACE_PATH/.palace_initialized" ]; then
|
| 24 |
+
echo "Initializing MemPalace at $PALACE_PATH..."
|
| 25 |
+
mempalace init "$PALACE_PATH" 2>/dev/null || echo "MemPalace init skipped (may already exist)"
|
| 26 |
+
touch "$PALACE_PATH/.palace_initialized"
|
| 27 |
+
echo "MemPalace initialized."
|
| 28 |
+
else
|
| 29 |
+
echo "MemPalace already initialized."
|
| 30 |
+
fi
|
|
|
|
| 31 |
|
| 32 |
+
exec python3 /app/entry.py
|
|
|
|
|
|
|
|
|