Administrator
发布于 2026-09-07 / 2 阅读
0
0

Hermes 远程客户端搭建:从外网直连自己的 AI 助手

远程搭建客户端直连 Hermes(API Server + Caddy + 证书固定)

场景:云端服务器上跑着 Hermes AI 助理,微信通道 token 每天过期、Web 登录会话易失效。本文记录一条持久直连通道的完整搭建方案:本地桌面/Web 客户端通过 HTTPS 直连服务器,聊天(SSE 流式)+ 文档下载,端到端 10 项测试全通过。

一、总体架构

本地电脑                                      云服务器
┌──────────────────────────┐               ┌──────────────────────────────────┐
│ 客户端(Tkinter桌面版 /    │  HTTPS+证书固定│ Caddy :443(TLS 终结)           │
│  Web浏览器版)            │ ────────────▶ │  ├─ /api/* /v1/* → 127.0.0.1:8642│
│ ├─ 登录(地址+密钥+指纹)  │               │  │   = Hermes API Server 平台    │
│ ├─ 聊天(SSE 流式)       │               │  └─ /files/*  → 127.0.0.1:8643   │
│ └─ 文件(浏览/下载)      │               │      = files_server.py(文档服务)│
└──────────────────────────┘               └──────────────────────────────────┘

关键设计决策:

  • 服务端零胶水代码:直接用 Hermes 内置的 API Server 平台(OpenAI 兼容接口 + SSE 流式),不自研 agent 接入层。
  • 客户端零依赖:纯 Python 标准库(tkinter/ssl/urllib),本地机器不用装任何 pip 包。

二、服务端配置

1. 启用 API Server 平台

它在 gateway 启动时读 .env,改完必须重启 gateway:

# ~/.hermes/.env
API_SERVER_ENABLED=true
API_SERVER_KEY=<openssl rand -hex 24>   # 生成随机密钥
API_SERVER_HOST=127.0.0.1               # 只监听回环,公网入口交给 Caddy
API_SERVER_PORT=8642

可用端点(全部 Bearer 认证):
- GET /v1/models
- POST /v1/chat/completions(OpenAI 兼容,stream:true 即 SSE)
- POST /v1/runs + GET /v1/runs/{id}/events(结构化事件流,见下文)
- 会话 API /api/sessions/...、定时任务 API /api/jobs/...

2. 文件服务(files_server.py)

~200 行标准库 http.server 实现:列目录 + 流式下载,Bearer 认证 + Range 断点续传 + 路径穿越防护,systemd 托管(restart=on-failure)。

中文文件名三个必踩坑(都实测踩过):
1. BaseHTTPRequestHandler.self.path 是 latin-1 解码的,要用 unquote_to_bytes(path).decode("utf-8") 还原,否则中文名乱码;
2. Content-Disposition 含非 latin1 字符时必须用 RFC 5987 格式 filename*=UTF-8''<percent-encoded>,否则响应中途抛 UnicodeEncodeError(症状:HTTP 200 但 0 字节);
3. Content-Length + Accept-Ranges + Content-Range 三件套支撑 206 断点续传。

路由要同时接受 /files/files/(Caddy 原样透传客户端的斜杠)。

3. Caddy 路由(复用现有证书,不影响博客)

:443 {
  tls /etc/caddy/certs/blog.crt /etc/caddy/certs/blog.key
  handle /api/* { reverse_proxy 127.0.0.1:8642 }
  handle /v1/*  { reverse_proxy 127.0.0.1:8642 }
  handle /files/* { reverse_proxy 127.0.0.1:8643 }
  handle { reverse_proxy 127.0.0.1:8090 }   # 博客等其它流量
}

4. 重启 gateway 的旁路技巧

gateway 是 user 级 systemd 单元(系统级 systemctl status 会显示假 inactive/not-found)。在 gateway 会话内直接重启会被拦截,用 systemd-run 绕出进程树:

# 重启脚本内写:
XDG_RUNTIME_DIR=/run/user/1000 systemctl --user restart hermes-gateway.service
# 执行:
sudo systemd-run --on-active=2 --uid=1000 /path/to/restart.sh

三、客户端安全设计(自签证书环境)

  1. 证书固定(cert pinning):自签证书没有 CA 链,客户端首次连接时取服务器证书的 SHA-256 指纹(ssl socket 的 getpeercert(binary_form) → base64),用户在界面确认后存入配置;之后每次请求都校验指纹,指纹不符 → 抛 SecurityError 拒绝连接。这是无 CA 环境下的 MITM 防护正解。
  2. Bearer 令牌:所有请求带 API_SERVER_KEY,错 key 一律 401 并在登录界面给出指引。
  3. 本地配置~/.hermes_client/config.jsonos.chmod(0o600),先写临时文件再 rename 防半写。
  4. SSL 上下文(客户端):ssl.create_default_context()check_hostname=False; verify_mode=CERT_NONE; set_ciphers("DEFAULT:@SECLEVEL=1")——用 pin 代替 CA 校验。

四、SSE 结构化流式(显示思考/工具/token 用量)

只用 /v1/chat/completions 只能拿到最终答案,思考过程和工具执行全被丢弃。正确姿势是走 /v1/runs 事件流:

POST /v1/runs  {model, input, conversation_history, session_id}
 202 {run_id, status}
GET  /v1/runs/{run_id}/events  (SSE)
 reasoning.available {text}     思考过程
 tool.started {tool, preview} / tool.completed {tool, duration}
 message.delta {delta}         正文增量
 run.completed {output, usage:{input_tokens, output_tokens}}

注意点:
- 事件 JSON 是 ensure_ascii 转义的(\ud83d\ude80 形态),客户端必须 json.loads 还原;
- tool 事件字段名是 tool_name 不是 tool
- chat/completions 路径会过滤下划线开头的工具,拿不到 thinking——必须走 runs;
- 旧服务器无 /v1/runs 时回退 chat/completions SSE + event: hermes.tool.progress 解析;
- SSE 解析按 \n\n 分割、event:/data: 分行处理,要兼容 \r\n\r\n、分块切断、注释行和 [DONE]

Web 版前端用 fetch + ReadableStream 消费,渲染为:可折叠思考块(<details>)、工具卡片(🛠 名称+preview+耗时)、正文气泡、token 用量脚注。emoji 端到端透传没问题,缺的是 CSS 字体回退——字体栈要加 "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji"

五、响应式与深色 UI 要点

  • 根字号 clamp(13px, 0.95vw + 11px, 16px) 随视口缩放;气泡 max-width: min(78%, 900px)
  • 断点:<760px 侧栏变固定抽屉(position:fixed + z-index),☰ 按钮切换,选完会话自动收起。
  • 面板拖拽改宽:两个 6px .resizer div,mousedown 记起始宽度、mousemove 绑在 document(绑在把手上一拖过边缘就丢事件)、mouseup 后宽度存 localStorage。方向易搞反:侧栏把手在右侧 w=startW+dx,文档面板把手在左侧 w=startW-dx
  • 长文件名不省略截断:word-break: break-all; overflow-wrap: anywhere + title 属性悬停看全名。
  • 大坑:.view 的居中属性(align-items:center)泄漏到主界面会让整页收缩成窄条(新会话不满页、聊几轮出现宽表格后"突然变满页")。主界面必须显式覆盖 align-items: stretch; justify-content: flex-start

六、401 排查流程(按序执行)

实践中服务器几乎总是好的——401 就是客户端发的 key ≠ API_SERVER_KEY

  1. grep "^API_SERVER_KEY=" ~/.hermes/.env 拿权威密钥;
  2. ss -tlnp | grep 8642 确认监听;
  3. 本机 curl -s -H "Authorization: Bearer $KEY" http://127.0.0.1:8642/v1/models → 应 200;
  4. 公网 curl -sk ... https://<host>/v1/models → 应 200;
  5. 负向对照:故意错 key → 应 401(确认 401 语义);
  6. 结论:服务器正常 → 客户端输入问题。常见原因:key 前后粘了空白/换行、输入法打了全角字符、用了旧轮换的 key。

七、验收清单

  • [ ] curl -sk -H "Authorization: Bearer $KEY" https://<host>/v1/models → 200 JSON
  • [ ] stream:true → SSE data: 分块流式
  • [ ] 错 key → 401;错指纹 → 客户端 SecurityError 拒连
  • [ ] 文件:认证列目录、中文名 PDF 下载(%PDF 魔数)、Range → 206 且长度精确
  • [ ] 穿越攻击:/files/..%2Fetc%2Fpasswd → 400
  • [ ] systemd 服务开机自启;gateway 用 systemctl --user 管理

八、踩坑补充

  • Tkinter 所有网络 IO 必须放 daemon 线程,UI 更新只走队列轮询(root.after(100, ...))——worker 线程直接碰控件必崩。
  • 自定义 SSL 上下文必须挂到 build_opener(HTTPSHandler(context=ctx)),裸 urllib.request.urlopen 用默认上下文会报自签证书校验失败。
  • global 声明必须在函数内任何读取之前,否则 SyntaxError "used prior to global declaration"。
  • 登录顺序坑:先构建主界面再开/开会话——会话树控件在主 UI 建立前不存在。

评论