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

Files changed (3) hide show
  1. Dockerfile +12 -29
  2. entry.py +54 -66
  3. start.sh +15 -25
Dockerfile CHANGED
@@ -1,24 +1,23 @@
1
  FROM python:3.12-slim
2
 
3
- # System deps (Node.js 23 + build tools for native modules)
4
  RUN apt-get update && apt-get install -y --no-install-recommends \
5
- git curl gnupg2 fontconfig make g++ \
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
- # ── 1. Clone & install hermes-agent (Python gateway) ──
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:/usr/local/bin:$PATH"
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
- # ── 2. Build hermes-web-ui (Vue SPA + Node.js BFF) ──
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
- WEBUI_DIR = "/app/webui"
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 -> sk-or-v1-ab....wxyz"""
69
  if not key or len(key) < 10:
70
- return "........"
71
- return key[:7] + "...." + key[-4:]
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 + WebUI + API
224
  # ---------------------------------------------------------------------------
225
 
226
  class DashboardHandler(BaseHTTPRequestHandler):
227
- """Serves dashboard HTML, WebUI SPA, and REST API endpoints."""
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
- path = parsed.path
286
-
287
- # WebUI SPA routes (serve static files or fall back to index.html)
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 v5.0 — HuggingFace Space Entry ===")
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
- # ── Persistent storage ──
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 "Symlink: $dir -> /data/hermes/$dir"
13
  elif [ -L "$target" ]; then
14
  echo "Symlink exists: $dir"
15
  fi
16
  done
17
- echo "Persistent storage ready."
18
 
19
- # ── Start hermes gateway on port 8642 (background) ──
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
- # Wait for gateway to be ready (check port 8642)
28
- for i in $(seq 1 30); do
29
- if python3 -c "import socket; s=socket.socket(); s.settimeout(1); s.connect(('127.0.0.1', 8642)); s.close()" 2>/dev/null; then
30
- echo "Gateway ready on port 8642"
31
- break
32
- fi
33
- if [ $i -eq 30 ]; then
34
- echo "WARNING: Gateway did not start in time, continuing anyway"
35
- fi
36
- sleep 1
37
- done
38
 
39
- # ── Start Node.js webui server on port 7860 (foreground) ──
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