Z User commited on
Commit
a8cb5b7
·
1 Parent(s): 4390492

v5.0: Integrate hermes-web-ui (Vue 3 + Naive UI)

Browse files

- Dockerfile: Add Node.js 23 + hermes-web-ui build step
- entry.py: Add /webui route serving Vue SPA with client-side routing fallback
- WebUI features: Chat interface, session management, model selector, logs, usage analytics
- Theme: Hermes orange (#ff6b35) primary color, high-contrast dark mode
- i18n: Default language set to Chinese (zh), hardcoded English strings fixed
- Static assets served with cache headers, SPA fallback for client-side routing

Files changed (2) hide show
  1. Dockerfile +23 -6
  2. entry.py +66 -54
Dockerfile CHANGED
@@ -1,8 +1,13 @@
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
@@ -10,14 +15,14 @@ WORKDIR /app
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 (download 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,6 +30,19 @@ RUN mkdir -p /usr/share/fonts/truetype/noto && \
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
 
@@ -34,7 +52,6 @@ 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 deploy.html /app/deploy.html
38
  COPY plugins/pollinations/ /root/.hermes/plugins/image_gen/pollinations/
39
  COPY scripts/ /app/scripts/
40
 
 
1
  FROM python:3.12-slim
2
 
3
+ # System deps (git + Node.js 23 + build tools for node-pty)
4
  RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ git curl gnupg2 fontconfig make g++ \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ # Install Node.js 23
9
+ RUN curl -fsSL https://deb.nodesource.com/setup_23.x | bash - \
10
+ && apt-get install -y --no-install-recommends nodejs \
11
  && rm -rf /var/lib/apt/lists/*
12
 
13
  WORKDIR /app
 
15
  # Clone hermes-agent
16
  RUN git clone --depth 1 https://github.com/NousResearch/hermes-agent.git /app/hermes-agent
17
 
18
+ # Build Python venv
19
  RUN python3 -m venv /app/venv
20
+ ENV PATH="/app/venv/bin:/usr/local/bin:$PATH"
21
  RUN pip install --quiet --upgrade pip && \
22
  pip install --quiet psutil networkx && \
23
  pip install --quiet -e "/app/hermes-agent[feishu,mcp,cron,pty]" 2>&1 | tail -10
24
 
25
+ # Chinese font (Noto Sans SC Regular + Bold, ~16MB)
26
  RUN mkdir -p /usr/share/fonts/truetype/noto && \
27
  curl -sL "https://github.com/googlefonts/noto-cjk/raw/main/Sans/SubsetOTF/SC/NotoSansSC-Regular.otf" \
28
  -o /usr/share/fonts/truetype/noto/NotoSansSC-Regular.otf && \
 
30
  -o /usr/share/fonts/truetype/noto/NotoSansSC-Bold.otf && \
31
  fc-cache -f
32
 
33
+ # ── Build hermes-web-ui ──
34
+ RUN git clone --depth 1 https://github.com/EKKOLearnAI/hermes-web-ui.git /tmp/hermes-web-ui && \
35
+ cd /tmp/hermes-web-ui && \
36
+ npm install --quiet 2>&1 | tail -5 && \
37
+ npm run build 2>&1 | tail -10 && \
38
+ cp -r dist/client/. /app/webui/ && \
39
+ cp dist/server/index.js /app/webui-server/ && \
40
+ rm -rf /tmp/hermes-web-ui && \
41
+ echo "WebUI build complete"
42
+
43
+ # Cleanup build tools (save ~200MB)
44
+ RUN apt-get purge -y --auto-remove make g++ && rm -rf /var/lib/apt/lists/*
45
+
46
  # Create hermes home
47
  RUN mkdir -p /root/.hermes/plugins/image_gen/pollinations
48
 
 
52
  COPY .env /root/.hermes/.env
53
  COPY entry.py /app/entry.py
54
  COPY dashboard.html /app/dashboard.html
 
55
  COPY plugins/pollinations/ /root/.hermes/plugins/image_gen/pollinations/
56
  COPY scripts/ /app/scripts/
57
 
entry.py CHANGED
@@ -3,6 +3,8 @@
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,6 +23,7 @@ from pathlib import Path
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,7 +40,7 @@ LOG_FILE = os.path.join(LOG_DIR, "gateway.log")
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,10 +65,10 @@ _log_tail_offset = 0
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,7 +95,6 @@ def _load_config() -> dict:
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,7 +115,6 @@ def _load_config() -> dict:
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,7 +125,6 @@ def _get_sessions_count() -> int:
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,14 +150,12 @@ def _get_session_list() -> list[dict]:
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,7 +168,6 @@ def _parse_log_line(line: str) -> dict | 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,7 +175,6 @@ def _log_tailer():
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,7 +185,6 @@ def _log_tailer():
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,17 +200,14 @@ def _log_tailer():
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,20 +215,19 @@ def _ensure_persistent_storage():
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,37 +250,70 @@ class DashboardHandler(BaseHTTPRequestHandler):
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,12 +343,10 @@ class DashboardHandler(BaseHTTPRequestHandler):
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,7 +368,7 @@ class DashboardHandler(BaseHTTPRequestHandler):
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,16 +379,14 @@ class DashboardHandler(BaseHTTPRequestHandler):
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,13 +398,11 @@ class DashboardHandler(BaseHTTPRequestHandler):
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,7 +410,6 @@ class DashboardHandler(BaseHTTPRequestHandler):
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,7 +424,6 @@ class DashboardHandler(BaseHTTPRequestHandler):
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,7 +439,6 @@ class DashboardHandler(BaseHTTPRequestHandler):
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,14 +463,12 @@ class DashboardHandler(BaseHTTPRequestHandler):
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,7 +476,6 @@ class DashboardHandler(BaseHTTPRequestHandler):
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,26 +496,24 @@ class DashboardHandler(BaseHTTPRequestHandler):
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,9 +524,11 @@ def main():
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
 
 
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
  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
  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
  # ---------------------------------------------------------------------------
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
  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
 
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
 
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
 
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
 
169
 
170
  def _log_tailer():
 
171
  global _log_tail_offset
172
  while True:
173
  try:
 
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
  if p:
186
  parsed.append(p)
187
  if parsed:
 
188
  dead = []
189
  for q in _log_subscribers:
190
  try:
 
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
  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
  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
  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
  "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
  "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
  _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
  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
  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
  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
  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
  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
  # ---------------------------------------------------------------------------
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
  )
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