Z User commited on
Commit
8f68434
·
1 Parent(s): 6ffc130

feat: dark-themed real-time dashboard with SSE logs, config panel, interactive controls

Browse files

- Full monitoring dashboard: status cards, real-time logs, config viewer
- SSE log streaming with level filtering and auto-scroll
- API key masking for security
- Model switcher with 8 free OpenRouter models
- Interactive buttons: restart, clear logs, reset
- Session list viewer
- System stats: RAM, PID, uptime, session count
- Dark cyberpunk theme with gradient accents

Files changed (3) hide show
  1. Dockerfile +3 -1
  2. dashboard.html +480 -0
  3. entry.py +464 -68
Dockerfile CHANGED
@@ -14,6 +14,7 @@ RUN git clone --depth 1 https://github.com/NousResearch/hermes-agent.git /app/he
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 -e "/app/hermes-agent[feishu,mcp,cron,pty]" 2>&1 | tail -5
18
 
19
  # Create hermes home
@@ -24,10 +25,11 @@ COPY config.yaml /root/.hermes/config.yaml
24
  COPY SOUL.md /root/.hermes/SOUL.md
25
  COPY .env /root/.hermes/.env
26
  COPY entry.py /app/entry.py
 
27
 
28
  RUN chmod 600 /root/.hermes/.env
29
 
30
- # Startup script: ensure persistent dirs exist, create symlinks, then run entry
31
  COPY start.sh /app/start.sh
32
  RUN chmod +x /app/start.sh
33
 
 
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 && \
18
  pip install --quiet -e "/app/hermes-agent[feishu,mcp,cron,pty]" 2>&1 | tail -5
19
 
20
  # Create hermes home
 
25
  COPY SOUL.md /root/.hermes/SOUL.md
26
  COPY .env /root/.hermes/.env
27
  COPY entry.py /app/entry.py
28
+ COPY dashboard.html /app/dashboard.html
29
 
30
  RUN chmod 600 /root/.hermes/.env
31
 
32
+ # Startup script
33
  COPY start.sh /app/start.sh
34
  RUN chmod +x /app/start.sh
35
 
dashboard.html ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Hermes Bot — Dashboard</title>
7
+ <style>
8
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
9
+ :root{
10
+ --bg:#0a0e17;--bg2:#111827;--bg3:#1a2332;--bg4:#232d3f;
11
+ --border:#1e293b;--border2:#334155;
12
+ --green:#00ff88;--blue:#00b4d8;--purple:#a78bfa;--red:#ff4757;--yellow:#fbbf24;--cyan:#22d3ee;--orange:#fb923c;
13
+ --text:#e2e8f0;--text2:#94a3b8;--text3:#64748b;
14
+ --mono:'JetBrains Mono','Fira Code','SF Mono',Monaco,Consolas,monospace;
15
+ --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Noto Sans SC',sans-serif;
16
+ --radius:12px;--radius-sm:8px;--radius-xs:6px;
17
+ }
18
+ html{font-size:14px}
19
+ body{font-family:var(--sans);background:var(--bg);color:var(--text);min-height:100vh;overflow-x:hidden}
20
+ ::-webkit-scrollbar{width:6px;height:6px}
21
+ ::-webkit-scrollbar-track{background:var(--bg2)}
22
+ ::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
23
+ ::-webkit-scrollbar-thumb:hover{background:var(--text3)}
24
+
25
+ /* ── Grid Layout ── */
26
+ .dashboard{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:auto 1fr;gap:16px;padding:16px;max-width:1400px;margin:0 auto;min-height:100vh}
27
+ @media(max-width:900px){.dashboard{grid-template-columns:1fr}}
28
+
29
+ /* ── Header ── */
30
+ .header{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;padding:16px 24px;background:linear-gradient(135deg,var(--bg2),var(--bg3));border:1px solid var(--border);border-radius:var(--radius);position:relative;overflow:hidden}
31
+ .header::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,var(--green),var(--blue),var(--purple))}
32
+ .header-left{display:flex;align-items:center;gap:16px}
33
+ .logo{font-size:1.8rem;font-weight:800;background:linear-gradient(135deg,var(--green),var(--blue));-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-0.5px}
34
+ .logo-sub{font-size:.75rem;color:var(--text3);margin-top:2px}
35
+ .status-badge{display:flex;align-items:center;gap:8px;padding:6px 14px;border-radius:20px;font-size:.8rem;font-weight:600}
36
+ .status-badge.online{background:rgba(0,255,136,.1);color:var(--green);border:1px solid rgba(0,255,136,.2)}
37
+ .status-badge.offline{background:rgba(255,71,87,.1);color:var(--red);border:1px solid rgba(255,71,87,.2)}
38
+ .pulse{width:8px;height:8px;border-radius:50%;background:currentColor;animation:pulse 2s infinite}
39
+ @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.5;transform:scale(1.5)}}
40
+ .header-right{display:flex;align-items:center;gap:12px}
41
+ .header-btn{padding:8px 16px;border-radius:var(--radius-xs);border:1px solid var(--border2);background:var(--bg3);color:var(--text2);font-size:.8rem;cursor:pointer;transition:all .2s;font-family:var(--sans);display:flex;align-items:center;gap:6px}
42
+ .header-btn:hover{border-color:var(--green);color:var(--green);background:rgba(0,255,136,.05)}
43
+ .header-btn.danger:hover{border-color:var(--red);color:var(--red);background:rgba(255,71,87,.05)}
44
+ .uptime{font-size:.75rem;color:var(--text3);font-family:var(--mono)}
45
+
46
+ /* ── Panel ── */
47
+ .panel{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column}
48
+ .panel-head{padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
49
+ .panel-title{font-size:.85rem;font-weight:600;color:var(--text);display:flex;align-items:center;gap:8px}
50
+ .panel-title .icon{font-size:1rem}
51
+ .panel-body{padding:16px;flex:1;overflow-y:auto}
52
+ .panel-body.no-pad{padding:0}
53
+
54
+ /* ── Status Cards ── */
55
+ .status-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px}
56
+ .stat-card{padding:14px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius-sm);transition:all .2s}
57
+ .stat-card:hover{border-color:var(--border2);transform:translateY(-1px)}
58
+ .stat-label{font-size:.7rem;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}
59
+ .stat-value{font-size:1.1rem;font-weight:700;font-family:var(--mono);display:flex;align-items:center;gap:6px}
60
+ .stat-value.green{color:var(--green)}
61
+ .stat-value.blue{color:var(--blue)}
62
+ .stat-value.purple{color:var(--purple)}
63
+ .stat-value.yellow{color:var(--yellow)}
64
+ .stat-value.cyan{color:var(--cyan)}
65
+ .stat-sub{font-size:.65rem;color:var(--text3);margin-top:4px}
66
+
67
+ /* ── Log Viewer ── */
68
+ .log-container{font-family:var(--mono);font-size:.78rem;line-height:1.7;background:var(--bg);border-radius:var(--radius-sm);overflow:hidden;height:100%}
69
+ .log-header{padding:8px 12px;background:var(--bg3);display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border)}
70
+ .log-controls{display:flex;gap:8px}
71
+ .log-btn{padding:4px 10px;border-radius:4px;border:1px solid var(--border);background:transparent;color:var(--text3);font-size:.7rem;cursor:pointer;font-family:var(--mono);transition:all .15s}
72
+ .log-btn:hover,.log-btn.active{color:var(--green);border-color:var(--green)}
73
+ .log-scroll{height:calc(100% - 40px);overflow-y:auto;padding:8px 12px}
74
+ .log-line{display:flex;gap:10px;padding:1px 0;white-space:pre-wrap;word-break:break-all}
75
+ .log-time{color:var(--text3);flex-shrink:0;user-select:none}
76
+ .log-level{font-weight:600;flex-shrink:0;width:52px;text-align:right;user-select:none}
77
+ .log-level.INFO{color:var(--green)}
78
+ .log-level.WARN,.log-level.WARNING{color:var(--yellow)}
79
+ .log-level.ERROR{color:var(--red)}
80
+ .log-level.DEBUG{color:var(--text3)}
81
+ .log-msg{color:var(--text)}
82
+ .log-msg.hl{color:var(--cyan)}
83
+ .log-msg.err{color:var(--red)}
84
+ .log-empty{color:var(--text3);text-align:center;padding:40px;font-style:italic}
85
+
86
+ /* ── Config Display ── */
87
+ .config-section{margin-bottom:16px}
88
+ .config-section:last-child{margin-bottom:0}
89
+ .config-section-title{font-size:.75rem;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;display:flex;align-items:center;gap:6px}
90
+ .config-row{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius-xs);margin-bottom:6px;font-size:.8rem}
91
+ .config-key{color:var(--text2);font-weight:500}
92
+ .config-val{color:var(--text);font-family:var(--mono);font-size:.78rem;max-width:60%;text-align:right;overflow:hidden;text-overflow:ellipsis}
93
+ .config-val.masked{color:var(--text3);letter-spacing:1px}
94
+ .config-val.green{color:var(--green)}
95
+ .config-val.red{color:var(--red)}
96
+ .config-val.yellow{color:var(--yellow)}
97
+
98
+ /* ── Session List ── */
99
+ .session-item{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius-xs);margin-bottom:6px;font-size:.8rem}
100
+ .session-dot{width:8px;height:8px;border-radius:50%;background:var(--green);flex-shrink:0}
101
+ .session-dot.idle{background:var(--text3)}
102
+ .session-info{flex:1;min-width:0}
103
+ .session-name{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
104
+ .session-meta{font-size:.7rem;color:var(--text3);margin-top:2px}
105
+ .session-actions{display:flex;gap:6px}
106
+
107
+ /* ── Model Selector ── */
108
+ .model-select{width:100%;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-xs);color:var(--text);font-size:.8rem;font-family:var(--mono);cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
109
+ .model-select:focus{outline:none;border-color:var(--blue)}
110
+ .model-select option{background:var(--bg2);color:var(--text)}
111
+
112
+ /* ── Buttons ── */
113
+ .btn{padding:8px 16px;border-radius:var(--radius-xs);border:1px solid var(--border2);font-size:.8rem;font-weight:500;cursor:pointer;transition:all .2s;font-family:var(--sans);display:inline-flex;align-items:center;gap:6px}
114
+ .btn-green{background:rgba(0,255,136,.1);color:var(--green);border-color:rgba(0,255,136,.3)}
115
+ .btn-green:hover{background:rgba(0,255,136,.2);box-shadow:0 0 20px rgba(0,255,136,.1)}
116
+ .btn-blue{background:rgba(0,180,216,.1);color:var(--blue);border-color:rgba(0,180,216,.3)}
117
+ .btn-blue:hover{background:rgba(0,180,216,.2)}
118
+ .btn-red{background:rgba(255,71,87,.1);color:var(--red);border-color:rgba(255,71,87,.3)}
119
+ .btn-red:hover{background:rgba(255,71,87,.2)}
120
+ .btn-sm{padding:5px 10px;font-size:.72rem}
121
+ .btn-group{display:flex;gap:8px;flex-wrap:wrap}
122
+
123
+ /* ── Empty state ── */
124
+ .empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;color:var(--text3);text-align:center}
125
+ .empty-state .icon{font-size:2rem;margin-bottom:12px;opacity:.5}
126
+
127
+ /* ── Tooltip ── */
128
+ .tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.65rem;font-weight:600;letter-spacing:.3px}
129
+ .tag-green{background:rgba(0,255,136,.1);color:var(--green)}
130
+ .tag-blue{background:rgba(0,180,216,.1);color:var(--blue)}
131
+ .tag-yellow{background:rgba(251,191,36,.1);color:var(--yellow)}
132
+ .tag-red{background:rgba(255,71,87,.1);color:var(--red)}
133
+ .tag-purple{background:rgba(167,139,250,.1);color:var(--purple)}
134
+
135
+ /* ── Progress ── */
136
+ .progress-bar{height:4px;background:var(--bg);border-radius:2px;overflow:hidden;margin-top:6px}
137
+ .progress-fill{height:100%;border-radius:2px;transition:width .5s ease}
138
+
139
+ /* ── Glow effect ── */
140
+ .glow-green{box-shadow:0 0 15px rgba(0,255,136,.1)}
141
+ .glow-blue{box-shadow:0 0 15px rgba(0,180,216,.1)}
142
+
143
+ /* ── Animations ── */
144
+ @keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
145
+ .fade-in{animation:fadeIn .3s ease}
146
+ @keyframes spin{to{transform:rotate(360deg)}}
147
+ .spin{animation:spin 1s linear infinite}
148
+
149
+ /* ── Toast ── */
150
+ .toast-container{position:fixed;top:20px;right:20px;z-index:1000;display:flex;flex-direction:column;gap:8px}
151
+ .toast{padding:12px 20px;border-radius:var(--radius-sm);font-size:.82rem;font-weight:500;animation:fadeIn .3s ease;display:flex;align-items:center;gap:8px;backdrop-filter:blur(10px)}
152
+ .toast.success{background:rgba(0,255,136,.15);border:1px solid rgba(0,255,136,.3);color:var(--green)}
153
+ .toast.error{background:rgba(255,71,87,.15);border:1px solid rgba(255,71,87,.3);color:var(--red)}
154
+ .toast.info{background:rgba(0,180,216,.15);border:1px solid rgba(0,180,216,.3);color:var(--blue)}
155
+ </style>
156
+ </head>
157
+ <body>
158
+ <div id="toast-container" class="toast-container"></div>
159
+ <div class="dashboard">
160
+
161
+ <!-- ═══ Header ═══ -->
162
+ <div class="header">
163
+ <div class="header-left">
164
+ <div>
165
+ <div class="logo">⚕ Hermes Bot</div>
166
+ <div class="logo-sub">HuggingFace Space · Real-time Dashboard / 实时监控面板</div>
167
+ </div>
168
+ </div>
169
+ <div style="display:flex;align-items:center;gap:16px">
170
+ <div id="status-badge" class="status-badge online"><span class="pulse"></span><span id="status-text">在线 Online</span></div>
171
+ <div class="uptime" id="uptime">⏱ Uptime: --</div>
172
+ </div>
173
+ <div class="header-right">
174
+ <button class="header-btn" onclick="restartGateway()">🔄 重启 Restart</button>
175
+ <button class="header-btn" onclick="clearLogs()">🗑 清除日志 Clear</button>
176
+ <button class="header-btn danger" onclick="factoryReset()">⚠ 重置 Reset</button>
177
+ </div>
178
+ </div>
179
+
180
+ <!-- ═══ Left Column ═══ -->
181
+ <div style="display:flex;flex-direction:column;gap:16px">
182
+
183
+ <!-- Status Cards -->
184
+ <div class="panel">
185
+ <div class="panel-head"><div class="panel-title"><span class="icon">📊</span>系统状态 System Status</div><span class="tag tag-green" id="update-time">--</span></div>
186
+ <div class="panel-body">
187
+ <div class="status-grid" id="status-grid">
188
+ <div class="stat-card"><div class="stat-label">平台 Platform</div><div class="stat-value green" id="s-platform">飞书 Feishu</div><div class="stat-sub" id="s-platform-sub">WebSocket</div></div>
189
+ <div class="stat-card"><div class="stat-label">模型 Model</div><div class="stat-value blue" id="s-model">--</div><div class="stat-sub" id="s-model-sub">OpenRouter</div></div>
190
+ <div class="stat-card"><div class="stat-label">会话 Sessions</div><div class="stat-value purple" id="s-sessions">0</div><div class="stat-sub" id="s-sessions-sub">活跃 Active</div></div>
191
+ <div class="stat-card"><div class="stat-label">消息 Messages</div><div class="stat-value cyan" id="s-messages">0</div><div class="stat-sub" id="s-messages-sub">总计 Total</div></div>
192
+ <div class="stat-card"><div class="stat-label">内存 RAM</div><div class="stat-value yellow" id="s-ram">--</div><div class="stat-sub" id="s-ram-sub">使用 Usage</div></div>
193
+ <div class="stat-card"><div class="stat-label">PID</div><div class="stat-value" style="color:var(--text)" id="s-pid">--</div><div class="stat-sub">Gateway 进程</div></div>
194
+ </div>
195
+ </div>
196
+ </div>
197
+
198
+ <!-- Config Panel -->
199
+ <div class="panel" style="flex:1">
200
+ <div class="panel-head"><div class="panel-title"><span class="icon">⚙</span>配置 Configuration</div></div>
201
+ <div class="panel-body" style="overflow-y:auto">
202
+ <div class="config-section">
203
+ <div class="config-section-title">🔑 API Keys(已脱敏 Masked)</div>
204
+ <div class="config-row"><span class="config-key">OPENROUTER_API_KEY</span><span class="config-val masked" id="c-openrouter">--</span></div>
205
+ <div class="config-row"><span class="config-key">FEISHU_APP_ID</span><span class="config-val" id="c-feishu-id">--</span></div>
206
+ <div class="config-row"><span class="config-key">FEISHU_APP_SECRET</span><span class="config-val masked" id="c-feishu-secret">--</span></div>
207
+ </div>
208
+ <div class="config-section">
209
+ <div class="config-section-title">🤖 模型 Model</div>
210
+ <select class="model-select" id="model-select" onchange="changeModel(this.value)">
211
+ <option value="inclusionai/ling-2.6-1t:free">inclusionai/ling-2.6-1t:free</option>
212
+ <option value="inclusionai/ling-2.6-flash:free">inclusionai/ling-2.6-flash:free</option>
213
+ <option value="google/gemma-4-31b-it:free">google/gemma-4-31b-it:free</option>
214
+ <option value="qwen/qwen3-coder:free">qwen/qwen3-coder:free</option>
215
+ <option value="qwen/qwen3-next-80b-a3b-instruct:free">qwen/qwen3-next-80b-a3b-instruct:free</option>
216
+ <option value="openai/gpt-oss-120b:free">openai/gpt-oss-120b:free</option>
217
+ <option value="z-ai/glm-4.5-air:free">z-ai/glm-4.5-air:free</option>
218
+ <option value="nvidia/nemotron-3-super-120b-a12b:free">nvidia/nemotron-3-super-120b-a12b:free</option>
219
+ </select>
220
+ <div style="margin-top:8px" class="btn-group">
221
+ <button class="btn btn-green btn-sm" onclick="changeModel(document.getElementById('model-select').value)">�� 切换模型 Switch</button>
222
+ </div>
223
+ </div>
224
+ <div class="config-section">
225
+ <div class="config-section-title">🧠 记忆 Memory</div>
226
+ <div class="config-row"><span class="config-key">Provider</span><span class="config-val" id="c-memory">none</span></div>
227
+ <div class="config-row"><span class="config-key">Sessions</span><span class="config-val" id="c-sessions-dir">/data/hermes/sessions</span></div>
228
+ <div class="config-row"><span class="config-key">Memories</span><span class="config-val" id="c-memories-dir">/data/hermes/memories</span></div>
229
+ </div>
230
+ <div class="config-section">
231
+ <div class="config-section-title">🔧 高级 Advanced</div>
232
+ <div class="config-row"><span class="config-key">Terminal</span><span class="config-val" id="c-terminal">local</span></div>
233
+ <div class="config-row"><span class="config-key">Timezone</span><span class="config-val" id="c-tz">Asia/Shanghai</span></div>
234
+ <div class="config-row"><span class="config-key">Max Turns</span><span class="config-val" id="c-turns">90</span></div>
235
+ <div class="config-row"><span class="config-key">MCP</span><span class="config-val" id="c-mcp">disabled</span></div>
236
+ <div class="config-row"><span class="config-key">Compress</span><span class="config-val green" id="c-compress">enabled</span></div>
237
+ </div>
238
+ </div>
239
+ </div>
240
+ </div>
241
+
242
+ <!-- ═══ Right Column ═══ -->
243
+ <div style="display:flex;flex-direction:column;gap:16px">
244
+
245
+ <!-- Log Viewer -->
246
+ <div class="panel" style="flex:1;min-height:400px">
247
+ <div class="panel-head">
248
+ <div class="panel-title"><span class="icon">📜</span>实时日志 Live Logs</div>
249
+ <div class="log-controls">
250
+ <button class="log-btn active" id="btn-auto" onclick="toggleAutoScroll()">⏬ Auto</button>
251
+ <button class="log-btn" id="btn-info" onclick="toggleLevel('INFO')">INFO</button>
252
+ <button class="log-btn active" id="btn-warn" onclick="toggleLevel('WARN')">WARN</button>
253
+ <button class="log-btn active" id="btn-error" onclick="toggleLevel('ERROR')">ERR</button>
254
+ </div>
255
+ </div>
256
+ <div class="panel-body no-pad" style="flex:1;display:flex;flex-direction:column">
257
+ <div class="log-container" style="flex:1;display:flex;flex-direction:column">
258
+ <div class="log-scroll" id="log-scroll">
259
+ <div class="log-empty">等待日志... Waiting for logs...</div>
260
+ </div>
261
+ </div>
262
+ </div>
263
+ </div>
264
+
265
+ <!-- Sessions -->
266
+ <div class="panel" style="max-height:250px">
267
+ <div class="panel-head"><div class="panel-title"><span class="icon">💬</span>会话 Sessions</div><button class="btn btn-blue btn-sm" onclick="loadSessions()">↻ 刷新 Refresh</button></div>
268
+ <div class="panel-body" style="overflow-y:auto;max-height:180px" id="sessions-list">
269
+ <div class="empty-state"><div class="icon">💬</div><div>暂无会话 No sessions yet</div></div>
270
+ </div>
271
+ </div>
272
+ </div>
273
+
274
+ </div>
275
+
276
+ <script>
277
+ const API = '';
278
+ let autoScroll = true;
279
+ let visibleLevels = { INFO: true, WARN: true, ERROR: true, DEBUG: false, WARNING: true };
280
+ let logBuffer = [];
281
+ const MAX_LOGS = 500;
282
+ let startTs = Date.now();
283
+
284
+ // ── Toast ──
285
+ function toast(msg, type='info') {
286
+ const el = document.createElement('div');
287
+ el.className = 'toast ' + type;
288
+ el.textContent = msg;
289
+ document.getElementById('toast-container').appendChild(el);
290
+ setTimeout(() => el.remove(), 4000);
291
+ }
292
+
293
+ // ── Mask key ──
294
+ function maskKey(key) {
295
+ if (!key || key.length < 8) return '••••••••';
296
+ return key.substring(0, 6) + '••••' + key.substring(key.length - 4);
297
+ }
298
+
299
+ // ── Format uptime ──
300
+ function fmtUptime(ms) {
301
+ const s = Math.floor(ms / 1000);
302
+ const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
303
+ return d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}h ${m}m` : `${m}m ${s % 60}s`;
304
+ }
305
+
306
+ // ── SSE Logs ──
307
+ function connectLogs() {
308
+ const es = new EventSource(API + '/api/logs/stream');
309
+ es.onmessage = (e) => {
310
+ try {
311
+ const lines = JSON.parse(e.data);
312
+ if (!Array.isArray(lines)) return;
313
+ lines.forEach(l => appendLog(l));
314
+ } catch(err) {}
315
+ };
316
+ es.onerror = () => setTimeout(connectLogs, 3000);
317
+ }
318
+
319
+ function appendLog(l) {
320
+ const scroll = document.getElementById('log-scroll');
321
+ if (logBuffer.length === 0) scroll.innerHTML = '';
322
+ logBuffer.push(l);
323
+ if (logBuffer.length > MAX_LOGS) logBuffer.shift();
324
+
325
+ const time = l.time || '';
326
+ const level = l.level || 'INFO';
327
+ const msg = l.msg || '';
328
+ const hl = l.hl || false;
329
+ const err = level === 'ERROR';
330
+
331
+ const div = document.createElement('div');
332
+ div.className = 'log-line';
333
+ div.dataset.level = level;
334
+ if (!visibleLevels[level]) div.style.display = 'none';
335
+ div.innerHTML = `<span class="log-time">${escHtml(time)}</span><span class="log-level ${level}">${level.padEnd(5)}</span><span class="log-msg${hl ? ' hl' : ''}${err ? ' err' : ''}">${escHtml(msg)}</span>`;
336
+ scroll.appendChild(div);
337
+
338
+ while (scroll.children.length > MAX_LOGS) scroll.removeChild(scroll.firstChild);
339
+ if (autoScroll) scroll.scrollTop = scroll.scrollHeight;
340
+ }
341
+
342
+ function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
343
+
344
+ function toggleAutoScroll() {
345
+ autoScroll = !autoScroll;
346
+ document.getElementById('btn-auto').classList.toggle('active', autoScroll);
347
+ }
348
+
349
+ function toggleLevel(level) {
350
+ visibleLevels[level] = !visibleLevels[level];
351
+ document.getElementById('btn-' + level.toLowerCase()).classList.toggle('active', visibleLevels[level]);
352
+ document.querySelectorAll('#log-scroll .log-line').forEach(el => {
353
+ el.style.display = visibleLevels[el.dataset.level] ? '' : 'none';
354
+ });
355
+ }
356
+
357
+ // ── Status Polling ──
358
+ async function pollStatus() {
359
+ try {
360
+ const r = await fetch(API + '/api/status');
361
+ const d = await r.json();
362
+ updateUI(d);
363
+ } catch(e) {}
364
+ }
365
+
366
+ function updateUI(d) {
367
+ // Status badge
368
+ const badge = document.getElementById('status-badge');
369
+ const stxt = document.getElementById('status-text');
370
+ if (d.running) {
371
+ badge.className = 'status-badge online';
372
+ stxt.textContent = '在线 Online';
373
+ } else {
374
+ badge.className = 'status-badge offline';
375
+ stxt.textContent = '离线 Offline';
376
+ }
377
+
378
+ document.getElementById('s-model').textContent = (d.model || '--').split('/').pop().substring(0, 28);
379
+ document.getElementById('s-model-sub').textContent = d.provider || 'OpenRouter';
380
+ document.getElementById('s-sessions').textContent = d.sessions || 0;
381
+ document.getElementById('s-messages').textContent = d.messages || 0;
382
+ document.getElementById('s-ram').textContent = d.ram || '--';
383
+ document.getElementById('s-pid').textContent = d.pid || '--';
384
+ document.getElementById('s-platform').textContent = d.platform || '飞书 Feishu';
385
+
386
+ if (d.platform_mode) document.getElementById('s-platform-sub').textContent = d.platform_mode;
387
+ if (d.uptime_ms) document.getElementById('uptime').textContent = '⏱ Uptime: ' + fmtUptime(d.uptime_ms);
388
+
389
+ document.getElementById('update-time').textContent = new Date().toLocaleTimeString('zh-CN');
390
+
391
+ // Config
392
+ if (d.config) {
393
+ document.getElementById('c-openrouter').textContent = maskKey(d.config.OPENROUTER_API_KEY);
394
+ document.getElementById('c-feishu-id').textContent = d.config.FEISHU_APP_ID || '--';
395
+ document.getElementById('c-feishu-secret').textContent = maskKey(d.config.FEISHU_APP_SECRET);
396
+ document.getElementById('c-terminal').textContent = d.config.terminal || 'local';
397
+ document.getElementById('c-tz').textContent = d.config.timezone || 'Asia/Shanghai';
398
+ document.getElementById('c-turns').textContent = d.config.max_turns || '90';
399
+ document.getElementById('c-memory').textContent = d.config.memory || 'none';
400
+ if (d.config.no_mcp) document.getElementById('c-mcp').textContent = 'disabled';
401
+ if (d.config.compress) document.getElementById('c-compress').textContent = 'enabled';
402
+ if (d.model) {
403
+ const sel = document.getElementById('model-select');
404
+ sel.value = d.model;
405
+ const opt = sel.querySelector(`option[value="${d.model}"]`);
406
+ if (!opt) {
407
+ const o = document.createElement('option');
408
+ o.value = d.model; o.textContent = d.model;
409
+ sel.prepend(o); sel.value = d.model;
410
+ }
411
+ }
412
+ }
413
+ }
414
+
415
+ // ── Sessions ──
416
+ async function loadSessions() {
417
+ try {
418
+ const r = await fetch(API + '/api/sessions');
419
+ const list = await r.json();
420
+ const el = document.getElementById('sessions-list');
421
+ if (!list || list.length === 0) {
422
+ el.innerHTML = '<div class="empty-state"><div class="icon">💬</div><div>暂无会话 No sessions yet</div></div>';
423
+ return;
424
+ }
425
+ el.innerHTML = list.map(s => `
426
+ <div class="session-item fade-in">
427
+ <span class="session-dot ${s.active ? '' : 'idle'}"></span>
428
+ <div class="session-info">
429
+ <div class="session-name">${escHtml(s.name || s.id)}</div>
430
+ <div class="session-meta">${escHtml(s.platform || '')} · ${s.messages || 0} msgs · ${escHtml(s.last || '')}</div>
431
+ </div>
432
+ </div>`).join('');
433
+ } catch(e) {}
434
+ }
435
+
436
+ // ── Actions ──
437
+ async function restartGateway() {
438
+ toast('正在重启... Restarting...', 'info');
439
+ try {
440
+ const r = await fetch(API + '/api/restart', { method: 'POST' });
441
+ const d = await r.json();
442
+ toast(d.ok ? '✅ 重启成功!Restarted!' : '❌ ' + (d.error || 'Failed'), d.ok ? 'success' : 'error');
443
+ } catch(e) { toast('❌ Request failed', 'error'); }
444
+ }
445
+
446
+ async function clearLogs() {
447
+ logBuffer = [];
448
+ document.getElementById('log-scroll').innerHTML = '<div class="log-empty">已清除 Cleared</div>';
449
+ toast('🗑 日志已清除 Logs cleared', 'success');
450
+ }
451
+
452
+ async function changeModel(model) {
453
+ toast('切换模型: ' + model, 'info');
454
+ try {
455
+ const r = await fetch(API + '/api/model', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ model }) });
456
+ const d = await r.json();
457
+ toast(d.ok ? '✅ 模型已切换 Model switched!' : '❌ ' + (d.error || 'Failed'), d.ok ? 'success' : 'error');
458
+ if (d.ok) setTimeout(pollStatus, 1000);
459
+ } catch(e) { toast('❌ Request failed', 'error'); }
460
+ }
461
+
462
+ async function factoryReset() {
463
+ if (!confirm('⚠ 确认重置?This will reset configuration.\n所有配置将被恢复默认。All configs will be reset.')) return;
464
+ toast('正在重置... Resetting...', 'info');
465
+ try {
466
+ const r = await fetch(API + '/api/reset', { method: 'POST' });
467
+ const d = await r.json();
468
+ toast(d.ok ? '✅ 已重置!Reset done!' : '❌ ' + (d.error || 'Failed'), d.ok ? 'success' : 'error');
469
+ } catch(e) { toast('❌ Request failed', 'error'); }
470
+ }
471
+
472
+ // ── Init ──
473
+ pollStatus();
474
+ setInterval(pollStatus, 5000);
475
+ setInterval(loadSessions, 30000);
476
+ loadSessions();
477
+ connectLogs();
478
+ </script>
479
+ </body>
480
+ </html>
entry.py CHANGED
@@ -1,18 +1,40 @@
1
  #!/usr/bin/env python3
2
- """Hermes Agent entry point for HuggingFace Space.
3
 
4
- - Starts a trivial HTTP server on port 7860 (HF health check).
5
- - Launches the Hermes Gateway in a background thread.
6
- - Keeps running until interrupted.
7
  """
8
 
 
9
  import os
 
 
10
  import sys
11
  import threading
12
  import time
13
  import logging
 
 
14
  from http.server import HTTPServer, BaseHTTPRequestHandler
 
 
 
 
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  logging.basicConfig(
17
  level=logging.INFO,
18
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
@@ -20,62 +42,428 @@ logging.basicConfig(
20
  logger = logging.getLogger("entry")
21
 
22
  # ---------------------------------------------------------------------------
23
- # Health-check server (HF requires port 7860 to be listening)
24
  # ---------------------------------------------------------------------------
 
 
 
 
 
25
 
26
- class _HealthHandler(BaseHTTPRequestHandler):
27
- def do_GET(self):
28
- self.send_response(200)
29
- self.send_header("Content-Type", "text/plain")
30
- self.end_headers()
31
- self.wfile.write(b"Hermes Gateway is running")
32
 
33
- def log_message(self, fmt, *args):
34
- pass # silence health-check logs
 
 
 
35
 
36
 
37
- def _start_health_server(port=7860):
38
- server = HTTPServer(("0.0.0.0", port), _HealthHandler)
39
- logger.info("Health-check server listening on :%d", port)
40
- server.serve_forever()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
 
43
  # ---------------------------------------------------------------------------
44
- # Memory health check
45
  # ---------------------------------------------------------------------------
46
 
47
- def check_memory_health():
48
- """Verify persistent storage symlinks are intact."""
49
- hermes_home = os.path.expanduser("~/.hermes")
50
- issues = []
51
- for subdir in ("sessions", "memories", "uploads"):
52
- target = os.path.join(hermes_home, subdir)
53
- if os.path.islink(target):
54
- real = os.path.realpath(target)
55
- if not os.path.isdir(real):
56
- issues.append(f"{subdir} -> {real} (target missing)")
57
- else:
58
- logger.info("Symlink OK: %s -> %s", subdir, real)
59
- elif os.path.isdir(target):
60
- logger.warning("NOT a symlink: %s (data may be lost on rebuild)", target)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  else:
62
- issues.append(f"{subdir} missing")
 
 
 
 
 
 
 
 
 
63
 
64
- # Check persistent volume writable
65
- test_path = "/data/hermes/logs/write_test"
66
- try:
67
- os.makedirs(os.path.dirname(test_path), exist_ok=True)
68
- with open(test_path, "w") as f:
69
- f.write("ok")
70
- os.remove(test_path)
71
- logger.info("Persistent storage /data/hermes is writable")
72
- except Exception as e:
73
- issues.append(f"Cannot write to /data/hermes: {e}")
74
 
75
- if issues:
76
- logger.warning("Memory health issues: %s", "; ".join(issues))
77
- else:
78
- logger.info("Memory health check PASSED")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
 
81
  # ---------------------------------------------------------------------------
@@ -85,30 +473,38 @@ def check_memory_health():
85
  def main():
86
  logger.info("=== Hermes Agent — HuggingFace Space Entry ===")
87
 
88
- # Check persistent storage
89
- check_memory_health()
 
90
 
91
- # Start health-check server in background
92
- health_thread = threading.Thread(target=_start_health_server, daemon=True)
93
- health_thread.start()
 
94
 
95
- # Launch Hermes Gateway
96
- from hermes_cli.main import main as hermes_main
97
- logger.info("Launching Hermes Gateway...")
 
 
 
98
 
99
- # The gateway blocks, so we run it in the main thread
100
- try:
101
- sys.argv = ["hermes", "gateway", "run", "-v"]
102
- hermes_main()
103
- except KeyboardInterrupt:
104
- logger.info("Shutting down...")
105
- except SystemExit as e:
106
- if e.code != 0:
107
- logger.error("Gateway exited with code %s", e.code)
108
- raise
109
- except Exception as e:
110
- logger.error("Gateway crashed: %s", e, exc_info=True)
111
- raise
 
 
 
112
 
113
 
114
  if __name__ == "__main__":
 
1
  #!/usr/bin/env python3
2
+ """Hermes Agent HuggingFace Space Entry Point.
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
9
  import os
10
+ import re
11
+ import subprocess
12
  import sys
13
  import threading
14
  import time
15
  import logging
16
+ import psutil
17
+ from datetime import datetime, timezone
18
  from http.server import HTTPServer, BaseHTTPRequestHandler
19
+ from pathlib import Path
20
+ from urllib.parse import urlparse, parse_qs
21
+ from queue import Queue, Empty
22
+ from io import BytesIO
23
 
24
+ # ---------------------------------------------------------------------------
25
+ # Paths
26
+ # ---------------------------------------------------------------------------
27
+ HERMES_HOME = os.path.expanduser("~/.hermes")
28
+ DATA_DIR = "/data/hermes"
29
+ LOG_DIR = os.path.join(HERMES_HOME, "logs")
30
+ LOG_FILE = os.path.join(LOG_DIR, "gateway.log")
31
+ CONFIG_FILE = os.path.join(HERMES_HOME, "config.yaml")
32
+ ENV_FILE = os.path.join(HERMES_HOME, ".env")
33
+ DASHBOARD_HTML = "/app/dashboard.html"
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Logging
37
+ # ---------------------------------------------------------------------------
38
  logging.basicConfig(
39
  level=logging.INFO,
40
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
 
42
  logger = logging.getLogger("entry")
43
 
44
  # ---------------------------------------------------------------------------
45
+ # Global state
46
  # ---------------------------------------------------------------------------
47
+ _gateway_thread = None
48
+ _gateway_process = None
49
+ _gateway_start_time = time.time()
50
+ _log_subscribers: list[Queue] = []
51
+ _log_tail_offset = 0
52
 
53
+ # ---------------------------------------------------------------------------
54
+ # Helpers
55
+ # ---------------------------------------------------------------------------
 
 
 
56
 
57
+ def _mask_key(key: str) -> str:
58
+ """Mask API key for display: sk-or-v1-abc...xyz → sk-or-v1-ab••••wxyz"""
59
+ if not key or len(key) < 10:
60
+ return "••••••••"
61
+ return key[:7] + "••••" + key[-4:]
62
 
63
 
64
+ def _load_env() -> dict[str, str]:
65
+ """Read .env file into dict."""
66
+ env = {}
67
+ try:
68
+ with open(ENV_FILE) as f:
69
+ for line in f:
70
+ line = line.strip()
71
+ if not line or line.startswith("#"):
72
+ continue
73
+ if "=" in line:
74
+ k, _, v = line.partition("=")
75
+ env[k.strip()] = v.strip().strip("\"'")
76
+ except FileNotFoundError:
77
+ pass
78
+ return env
79
+
80
+
81
+ def _load_config() -> dict:
82
+ """Read config.yaml into dict (simple parser)."""
83
+ cfg: dict = {}
84
+ try:
85
+ with open(CONFIG_FILE) as f:
86
+ current_section = None
87
+ for line in f:
88
+ stripped = line.strip()
89
+ if not stripped or stripped.startswith("#"):
90
+ continue
91
+ # Check for section headers
92
+ if ":" in stripped and not line.startswith(" "):
93
+ k, _, v = stripped.partition(":")
94
+ k = k.strip()
95
+ v = v.strip()
96
+ if v:
97
+ if current_section:
98
+ cfg[current_section + "." + k] = v
99
+ else:
100
+ cfg[k] = v
101
+ # Check for nested section
102
+ if stripped.endswith(":") and not stripped.startswith(" ") and ":" not in stripped[:-1]:
103
+ current_section = stripped[:-1].strip()
104
+ except FileNotFoundError:
105
+ pass
106
+ return cfg
107
+
108
+
109
+ def _get_sessions_count() -> int:
110
+ """Count session files."""
111
+ sessions_dir = os.path.join(HERMES_HOME, "sessions")
112
+ if not os.path.isdir(sessions_dir):
113
+ return 0
114
+ count = 0
115
+ for _ in Path(sessions_dir).rglob("*.json"):
116
+ count += 1
117
+ return count
118
+
119
+
120
+ def _get_session_list() -> list[dict]:
121
+ """Get list of recent sessions."""
122
+ sessions_dir = os.path.join(HERMES_HOME, "sessions")
123
+ sessions = []
124
+ if not os.path.isdir(sessions_dir):
125
+ return sessions
126
+ for f in sorted(Path(sessions_dir).rglob("*.json"), key=os.path.getmtime, reverse=True)[:20]:
127
+ try:
128
+ data = json.loads(f.read_text())
129
+ name = f.stem
130
+ msgs = len(data.get("trajectory", data.get("messages", [])))
131
+ mtime = datetime.fromtimestamp(f.stat().st_mtime).strftime("%m-%d %H:%M")
132
+ platform = data.get("platform", data.get("channel", ""))
133
+ sessions.append({
134
+ "id": f.stem,
135
+ "name": name[:40],
136
+ "messages": msgs,
137
+ "last": mtime,
138
+ "platform": platform,
139
+ "active": (time.time() - f.stat().st_mtime) < 3600,
140
+ })
141
+ except Exception:
142
+ sessions.append({"id": f.stem, "name": f.stem[:40], "messages": 0, "last": "--", "platform": "", "active": False})
143
+ return sessions
144
 
145
 
146
  # ---------------------------------------------------------------------------
147
+ # Log tailer — reads log file and pushes to SSE subscribers
148
  # ---------------------------------------------------------------------------
149
 
150
+ def _parse_log_line(line: str) -> dict | None:
151
+ """Parse a log line like: 2026-04-27 22:19:12 [INFO] ..."""
152
+ 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)
153
+ if not m:
154
+ # Try other format: [timestamp] [LEVEL] name: msg
155
+ 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)
156
+ if not m:
157
+ return None
158
+ return {
159
+ "time": m.group(1),
160
+ "level": m.group(2).upper(),
161
+ "msg": m.group(3).strip(),
162
+ "hl": "feishu" in m.group(3).lower() or "connected" in m.group(3).lower(),
163
+ }
164
+
165
+
166
+ def _log_tailer():
167
+ """Background thread: tails gateway log and pushes to subscribers."""
168
+ global _log_tail_offset
169
+ while True:
170
+ try:
171
+ if not os.path.isfile(LOG_FILE):
172
+ time.sleep(2)
173
+ continue
174
+ with open(LOG_FILE, "r", errors="replace") as f:
175
+ # Seek to where we left off
176
+ f.seek(_log_tail_offset)
177
+ new_lines = f.readlines()
178
+ _log_tail_offset = f.tell()
179
+ if new_lines:
180
+ parsed = []
181
+ for line in new_lines:
182
+ p = _parse_log_line(line.strip())
183
+ if p:
184
+ parsed.append(p)
185
+ if parsed:
186
+ # Push to all subscribers
187
+ dead = []
188
+ for q in _log_subscribers:
189
+ try:
190
+ q.put_nowait(parsed)
191
+ except Exception:
192
+ dead.append(q)
193
+ for q in dead:
194
+ _log_subscribers.remove(q)
195
+ time.sleep(0.5)
196
+ except Exception as e:
197
+ logger.debug("Log tailer error: %s", e)
198
+ time.sleep(1)
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Persistent storage setup
203
+ # ---------------------------------------------------------------------------
204
+
205
+ def _ensure_persistent_storage():
206
+ """Create data dirs and symlinks."""
207
+ for d in ("sessions", "memories", "uploads", "logs", "palace", "skills"):
208
+ os.makedirs(os.path.join(DATA_DIR, d), exist_ok=True)
209
+
210
+ hermes = Path(HERMES_HOME)
211
+ hermes.mkdir(parents=True, exist_ok=True)
212
+
213
+ for d in ("sessions", "memories", "uploads", "logs", "palace", "skills"):
214
+ target = hermes / d
215
+ if not target.exists():
216
+ try:
217
+ target.symlink_to(os.path.join(DATA_DIR, d))
218
+ logger.info("Symlink: %s -> %s", d, os.path.join(DATA_DIR, d))
219
+ except OSError:
220
+ # Symlink failed (maybe in Docker build), just copy the dir structure
221
+ target.mkdir(exist_ok=True)
222
+ logger.warning("Could not symlink %s, using local dir", d)
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # HTTP Handler — Dashboard + API
227
+ # ---------------------------------------------------------------------------
228
+
229
+ class DashboardHandler(BaseHTTPRequestHandler):
230
+ """Serves dashboard HTML and REST API endpoints."""
231
+
232
+ def log_message(self, fmt, *args):
233
+ pass # silence request logs
234
+
235
+ def _send_json(self, data: dict, status=200):
236
+ body = json.dumps(data, ensure_ascii=False).encode("utf-8")
237
+ self.send_response(status)
238
+ self.send_header("Content-Type", "application/json; charset=utf-8")
239
+ self.send_header("Content-Length", str(len(body)))
240
+ self.send_header("Access-Control-Allow-Origin", "*")
241
+ self.end_headers()
242
+ self.wfile.write(body)
243
+
244
+ def _send_html(self, path: str):
245
+ try:
246
+ with open(path, "rb") as f:
247
+ body = f.read()
248
+ self.send_response(200)
249
+ self.send_header("Content-Type", "text/html; charset=utf-8")
250
+ self.send_header("Content-Length", str(len(body)))
251
+ self.end_headers()
252
+ self.wfile.write(body)
253
+ except FileNotFoundError:
254
+ self.send_error(404)
255
+
256
+ def _read_body(self) -> bytes:
257
+ length = int(self.headers.get("Content-Length", 0))
258
+ return self.rfile.read(length) if length > 0 else b""
259
+
260
+ # ── GET routes ──
261
+
262
+ def do_GET(self):
263
+ parsed = urlparse(self.path)
264
+
265
+ # Dashboard
266
+ if parsed.path in ("/", "/index.html"):
267
+ return self._send_html(DASHBOARD_HTML)
268
+
269
+ # SSE log stream
270
+ if parsed.path == "/api/logs/stream":
271
+ return self._handle_sse()
272
+
273
+ # Status
274
+ if parsed.path == "/api/status":
275
+ return self._send_json(self._get_status())
276
+
277
+ # Sessions
278
+ if parsed.path == "/api/sessions":
279
+ return self._send_json(_get_session_list())
280
+
281
+ # Log history (REST, not SSE)
282
+ if parsed.path == "/api/logs":
283
+ return self._send_json(self._get_log_history(parsed.query))
284
+
285
+ self.send_error(404)
286
+
287
+ # ── POST routes ──
288
+
289
+ def do_POST(self):
290
+ parsed = urlparse(self.path)
291
+
292
+ if parsed.path == "/api/restart":
293
+ return self._handle_restart()
294
+
295
+ if parsed.path == "/api/model":
296
+ return self._handle_change_model()
297
+
298
+ if parsed.path == "/api/reset":
299
+ return self._send_json({"ok": False, "error": "Not implemented in Space mode"})
300
+
301
+ self.send_error(404)
302
+
303
+ # ── Handlers ──
304
+
305
+ def _get_status(self) -> dict:
306
+ env = _load_env()
307
+ cfg = _load_config()
308
+ ram = psutil.virtual_memory()
309
+ is_running = False
310
+ pid = "N/A"
311
+
312
+ # Check gateway process
313
+ if _gateway_process and _gateway_process.poll() is None:
314
+ is_running = True
315
+ pid = str(_gateway_process.pid)
316
  else:
317
+ # Try to find hermes gateway process
318
+ for proc in psutil.process_iter(["pid", "cmdline"]):
319
+ try:
320
+ cmdline = " ".join(proc.info.get("cmdline") or [])
321
+ if "hermes" in cmdline and "gateway" in cmdline:
322
+ is_running = True
323
+ pid = str(proc.info["pid"])
324
+ break
325
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
326
+ pass
327
 
328
+ model = cfg.get("model", env.get("LLM_MODEL", "unknown"))
329
+ provider = cfg.get("provider", "openrouter")
 
 
 
 
 
 
 
 
330
 
331
+ return {
332
+ "running": is_running,
333
+ "pid": pid,
334
+ "model": model,
335
+ "provider": provider,
336
+ "platform": "飞书 Feishu",
337
+ "platform_mode": "WebSocket",
338
+ "sessions": _get_sessions_count(),
339
+ "messages": 0,
340
+ "ram": f"{ram.percent:.0f}%",
341
+ "uptime_ms": int((time.time() - _gateway_start_time) * 1000),
342
+ "config": {
343
+ "OPENROUTER_API_KEY": _mask_key(env.get("OPENROUTER_API_KEY", "")),
344
+ "FEISHU_APP_ID": env.get("FEISHU_APP_ID", ""),
345
+ "FEISHU_APP_SECRET": _mask_key(env.get("FEISHU_APP_SECRET", "")),
346
+ "terminal": cfg.get("terminal", {}).get("backend", "local") if isinstance(cfg.get("terminal"), dict) else "local",
347
+ "timezone": cfg.get("timezone", "Asia/Shanghai"),
348
+ "max_turns": cfg.get("max_turns", "90"),
349
+ "memory": cfg.get("memory", {}).get("provider", "none") if isinstance(cfg.get("memory"), dict) else "none",
350
+ "no_mcp": cfg.get("no_mcp", False),
351
+ "compress": cfg.get("compress", {}).get("enabled", False) if isinstance(cfg.get("compress"), dict) else False,
352
+ },
353
+ }
354
+
355
+ def _handle_sse(self):
356
+ """Server-Sent Events for real-time log streaming."""
357
+ self.send_response(200)
358
+ self.send_header("Content-Type", "text/event-stream")
359
+ self.send_header("Cache-Control", "no-cache")
360
+ self.send_header("Connection", "keep-alive")
361
+ self.send_header("Access-Control-Allow-Origin", "*")
362
+ self.end_headers()
363
+
364
+ q: Queue = Queue(maxsize=100)
365
+ _log_subscribers.append(q)
366
+
367
+ try:
368
+ # First, send recent log history
369
+ history = self._get_log_history_inner(limit=100)
370
+ if history:
371
+ self.wfile.write(f"data: {json.dumps(history, ensure_ascii=False)}\n\n".encode())
372
+ self.wfile.flush()
373
+
374
+ # Then stream new logs
375
+ while True:
376
+ try:
377
+ lines = q.get(timeout=30)
378
+ payload = f"data: {json.dumps(lines, ensure_ascii=False)}\n\n"
379
+ self.wfile.write(payload.encode())
380
+ self.wfile.flush()
381
+ except Empty:
382
+ # Send heartbeat
383
+ self.wfile.write(":heartbeat\n\n".encode())
384
+ self.wfile.flush()
385
+ except (BrokenPipeError, ConnectionResetError, OSError):
386
+ pass
387
+ finally:
388
+ if q in _log_subscribers:
389
+ _log_subscribers.remove(q)
390
+
391
+ def _get_log_history(self, query: str = "") -> list:
392
+ params = parse_qs(query)
393
+ limit = int(params.get("limit", ["100"])[0])
394
+ return self._get_log_history_inner(limit=limit)
395
+
396
+ def _get_log_history_inner(self, limit: int = 100) -> list:
397
+ """Read last N lines from log file."""
398
+ if not os.path.isfile(LOG_FILE):
399
+ return []
400
+ try:
401
+ with open(LOG_FILE, "r", errors="replace") as f:
402
+ lines = f.readlines()[-limit:]
403
+ result = []
404
+ for line in lines:
405
+ p = _parse_log_line(line.strip())
406
+ if p:
407
+ result.append(p)
408
+ return result
409
+ except Exception:
410
+ return []
411
+
412
+ def _handle_restart(self):
413
+ """Restart the gateway process."""
414
+ global _gateway_process, _gateway_start_time
415
+ try:
416
+ if _gateway_process and _gateway_process.poll() is None:
417
+ _gateway_process.terminate()
418
+ _gateway_process.wait(timeout=10)
419
+
420
+ _gateway_start_time = time.time()
421
+ env = os.environ.copy()
422
+ env["HERMES_ACCEPT_HOOKS"] = "1"
423
+ env["MEMPALACE_PALACE_PATH"] = os.path.join(DATA_DIR, "palace")
424
+
425
+ _gateway_process = subprocess.Popen(
426
+ [sys.executable, "-m", "hermes_cli.main", "gateway", "run", "-v"],
427
+ stdout=open(LOG_FILE, "a"),
428
+ stderr=subprocess.STDOUT,
429
+ env=env,
430
+ cwd="/app/hermes-agent",
431
+ )
432
+ logger.info("Gateway restarted (PID: %d)", _gateway_process.pid)
433
+ self._send_json({"ok": True, "pid": _gateway_process.pid})
434
+ except Exception as e:
435
+ self._send_json({"ok": False, "error": str(e)}, 500)
436
+
437
+ def _handle_change_model(self):
438
+ """Change the LLM model in config.yaml."""
439
+ try:
440
+ body = json.loads(self._read_body())
441
+ model = body.get("model", "")
442
+ if not model:
443
+ return self._send_json({"ok": False, "error": "No model specified"})
444
+
445
+ # Update config.yaml
446
+ config_path = CONFIG_FILE
447
+ if not os.path.isfile(config_path):
448
+ return self._send_json({"ok": False, "error": "config.yaml not found"})
449
+
450
+ with open(config_path, "r") as f:
451
+ content = f.read()
452
+
453
+ # Replace model line
454
+ new_content = re.sub(
455
+ r"^model:.*$",
456
+ f"model: {model}",
457
+ content,
458
+ flags=re.MULTILINE,
459
+ )
460
+ with open(config_path, "w") as f:
461
+ f.write(new_content)
462
+
463
+ logger.info("Model changed to: %s", model)
464
+ self._send_json({"ok": True, "model": model})
465
+ except Exception as e:
466
+ self._send_json({"ok": False, "error": str(e)}, 500)
467
 
468
 
469
  # ---------------------------------------------------------------------------
 
473
  def main():
474
  logger.info("=== Hermes Agent — HuggingFace Space Entry ===")
475
 
476
+ # Setup persistent storage
477
+ _ensure_persistent_storage()
478
+ logger.info("Persistent storage ready at %s", DATA_DIR)
479
 
480
+ # Start log tailer thread
481
+ tailer = threading.Thread(target=_log_tailer, daemon=True)
482
+ tailer.start()
483
+ logger.info("Log tailer started")
484
 
485
+ # Start Hermes Gateway in subprocess (not thread, for isolation)
486
+ global _gateway_process, _gateway_start_time
487
+ _gateway_start_time = time.time()
488
+ env = os.environ.copy()
489
+ env["HERMES_ACCEPT_HOOKS"] = "1"
490
+ env["MEMPALACE_PALACE_PATH"] = os.path.join(DATA_DIR, "palace")
491
 
492
+ os.makedirs(LOG_DIR, exist_ok=True)
493
+ log_fh = open(LOG_FILE, "a")
494
+
495
+ _gateway_process = subprocess.Popen(
496
+ [sys.executable, "-m", "hermes_cli.main", "gateway", "run", "-v"],
497
+ stdout=log_fh,
498
+ stderr=subprocess.STDOUT,
499
+ env=env,
500
+ cwd="/app/hermes-agent",
501
+ )
502
+ logger.info("Gateway started (PID: %d)", _gateway_process.pid)
503
+
504
+ # Start dashboard HTTP server
505
+ server = HTTPServer(("0.0.0.0", 7860), DashboardHandler)
506
+ logger.info("Dashboard listening on :7860")
507
+ server.serve_forever()
508
 
509
 
510
  if __name__ == "__main__":