Russ534

wb2api-hermes-setup

WorkBuddy2API 飞牛反代接 hermes 的配置/排查完整流程.

Russ534 0 Updated 4d ago

Resources

2
GitHub

Install

npx skillscat add russ534/myopenclawbot/wb2api-hermes-setup

Install via the SkillsCat registry.

SKILL.md

WorkBuddy2API 接 hermes 完整运维

生产状态(2026-10-19 切换):飞牛 192.168.31.201 上只有 wb-hub(8788)一个 WB 容器。原 sliverkiss-go 三件套(workbuddy2api:7863 / workbuddy2api-gui:8787 / workbuddy-manager:7864)已 stop+rm,hermes config.yaml、mdc/config.json、mp2 app_settings.dynamic_app_config 全部切到 hub,hermes /model picker 里指向老 10082 反代的 buddy2api 自定义 plugin 已删。SKILL.md 里 7863 / 8787 / buddy2api 引用是历史 demo,看参考 references/workbuddy2api-hub-deploy.md 看真实配置。

Hub 默认 dashboard 密码 = admin——任何新装 hub 首次起来必须立刻改(除非 bind 到 127.0.0.1 且反向代理一定要 auth 套外面)。本机 (192.168.31.201:8788) 当前 panel 密码 = work1234(沿用 wb2api-sliverkiss-go 旧约定,下次部署新 hub 也用 work1234 保持一致)。改法 + 改完会 invalidate 所有 dashboard session 但不动 launcher_key 见 references/workbuddy2api-hub-deploy.md "Hub scheduler / 自动签到" 节。

容器数 = 5 cn 账号在 hub accounts/:50b78b75 (166****9445)、22211169 (米粒儿)、82f85471 (晴天)、cb5bd756 (🌕星期日不上发条)、d64722ef (於菟)。第 5 个(手机号)漏在 /vol1/1000/Docker/workbuddy2api/auths/(宿主层),不是在 workbuddy2api-source/auths/(容器内置)。每次从老 wb2api 迁移账号都查这两个目录。

0. 两条轨道:sliverkiss-go (7863) vs arde hub (8788)

两种 wb2api 同时存在过,已全部切到 hub(合 3 容器 + 自动签到 + cn/intl 双域):

  • hub = ghcr.io/ardeyouxipianyi/workbuddy2api-hub (Python),8788,单容器全包(反代+看板+多账号+自动任务)
  • sliverkiss-go = 历史版(7863 main + 8787 gui + 7864 manager),Go 单反代。已 2026-10-19 删 3 旧 wb 容器,全面切 hub

切换决策依据:hub 同时给 cn + intl 模型、自动切号、自动签到/Cookie 续期,sliverkiss-go 只反代。唯一会留 sliverkiss-go 的场景:hub 不可用期间当 fallback。

部署细节(compose、账号迁移、realm 坑、CLI 选项)见 references/workbuddy2api-hub-deploy.md。

1. 架构拓扑(飞牛 192.168.31.201)

Client (hermes TG/微信)
  ↓ HTTP POST /v1/chat/completions, Bearer <api_key>
[workbuddy2api :7863]  ← Go 网关核心
  ├─ 鉴权 (config.api_key)
  ├─ 选号 (pool, weighted random) → 读 /vol1/.../auths/*.json
  ├─ payload.go 改写: 强制 stream:true, 思维链注入, tool_choice 归一
  ├─ HTTP → CodeBuddy 国内 cn: (copilot.tencent.com) / 国际 global: (www.workbuddy.ai)
  │   ├─ /v2/chat/completions (SSE)
  │   ├─ /billing/...
  │   └─ /auth/...
  └─ scheduler: 签到09/21, 旅行09/21, 活跃10, 保活22, 开学12, 夜猫01

[workbuddy2api-gui :8787]  ← Node 可视化面板 (read-only /v1/status)
[workbuddy-manager :7864] ← 容器管理 (挂 docker.sock)

源码: /vol1/1000/Docker/workbuddy2api/workbuddy2api-source/ (Docker 大写)
挂载: ./auths:/app/auths, ./data:/app/data, ./config.json:/app/config.json:ro

2. hermes 配置(已验证生效)

/opt/data/config.yaml:

model:
  provider: minimax-cn
  default: minimax-m3

providers:
  buddy2api:
    api: http://192.168.31.201:7863/v1
    api_key: <from fnos config.json api_key 字段,带日期戳会变>
    api_mode: openai
    model: cn:deepseek-v4.1-flash
    models:
      - cn:deepseek-v4.1-flash   # credit=0
      - cn:glm-5.3-flash          # credit=0
      - cn:kimi-k3-1              # credit≈0.07
      - cn:minimax-m3             # credit=0
      - cn:glm-5.3                # credit≈0.01

fallback_providers:
  - provider: buddy2api
    model: cn:deepseek-v4.1-flash

model_catalog:
  excluded_providers:
    - nous          # 排除 catalog 自动加载
    - buddy2api     # ⚠️ 这只挡 catalog 静态入口,挡不住用户配的 providers.buddy2api

关键陷阱:/model 面板 = catalog 缓存 + 用户配 providers.*。excluded_providers 只对 catalog 生效。
→ 面板里 "buddy2api (5)" 是用户配的 5 个模型(providers.buddy2api.models),无法被 excluded 隐藏。
→ 如果想"面板完全干净只剩 minimax + deepseek",要删 providers.buddy2api 整段(fallback 也没了)。

2b. 接入新反代 / 改 providers.*.models 之前的 standing rule 🔴

任何人(你 / me)说"配 hermes 调某个 hub / OpenAI-compatible 服务"——第一步必先 curl /v1/models 拿模型真实 id 列表,绝不可以凭 README / 记忆 / 工具截图配 models: 字段。会出 4 种典型错:

  1. 错加 cn: 前缀——README 提过但 hub /v1/models 实际不返回带前缀的 id。前缀写到 hermes models 里 + 转发给 hub 时 hub detect_model_realm 会兜底(按 token iss),请求能过;但配置漂移、模型名找不到、analytics 全乱。
  2. 大小写搞错——minimax-m3 vs MiniMax-M3 vs MiniMax-m3 在 hub /v1/models 实际全大写或全小写,写错就直接 404。
  3. 配错模型数——README 列 5 个但 hub 实际 18 个(或反过来),用户面板 picker 只显示配置的 5 个,剩下调不到。
  4. providers.X.model 与 fallback_providers[X].model 不一致——fallback 失败找不到这个 model。

正确工作流(强制 5 步):

# 1. 拿到真实模型列表
ssh fnos-root "curl -sS http://192.168.31.201:8788/v1/models \
  -H 'Authorization: Bearer $(jq -r .launcher_key \
    /vol1/1000/Docker/workbuddy2api-hub/accounts/settings.json)' \
  | jq -r '.data[].id'" > /tmp/real-models.txt
wc -l /tmp/real-models.txt  # 数真实数量

# 2. 直接 model 字段 + models 列表都用这个列表原样配进 hermes
#    不要做任何"觉得应该这样但其实不一定"的 normalize

配置完跑一发真 probe 确认通:见 §11 末尾的"验证"。

2c. 配 contextWindow / maxTokens / 思考档位 —— 只信网关 /v1/models 自报的元数据 🔴

hub 的 /v1/models 每个模型除了 id 还带整块能力元数据(实测 deepseek-v4.1-flash):

context_length: 1000000      max_output_tokens: 128000
context_windows: [300000, 600000, 1000000]
reasoning_efforts: ["low","high","max"]   reasoning_default_effort: "high"   reasoning_can_disable: true
capabilities: {vision: true, tool_calls: true, reasoning: true}

任何客户端(hermes / mdc / dsh / opencode / 自写脚本)配 context、输出上限、思考档位前,先把这块抓下来,不要用 README、模型名直觉或"1M 应该够"来填数字。

KEY=$(grep -A3 '^  workbuddy-hub:' /opt/data/config.yaml | grep api_key | awk '{print $2}')
curl -s --noproxy '*' -H "Authorization: Bearer $KEY" http://192.168.31.201:8788/v1/models \
 | python3 -c "import sys,json;[print(json.dumps(m,ensure_ascii=False,indent=1)) for m in json.load(sys.stdin)['data'] if m['id']=='deepseek-v4.1-flash']"

档位写完必须实测每一档——reasoning_effort 传了但上游静默忽略是常规失败模式,只有响应的 usage.completion_tokens_details.reasoning_tokens 能证伪(reasoning_content 字符数只是旁证)。一次跑完三档用 scripts/probe-model-caps.py:

python3 <skill_dir>/scripts/probe-model-caps.py deepseek-v4.1-flash
# 每档输出: effort | HTTP | reasoning_chars | reasoning_tokens | 首句回答

客户端配置格式不是 hermes 的:出现 api: openai-completions + compat: {thinkingFormat, supportsReasoningEffort} + models[].contextWindow/maxTokens/reasoningEfforts 这种块,是 DeepSeek Harness(dsh)的 models.yml,不是 hermes schema。对译关系:

dsh hermes
api: openai-completions api_mode: openai
baseURL providers.<x>.api
models[].id providers.<x>.models: [ids]
contextWindow / maxTokens model_overrides.<provider>.<model>.context_window(hermes 没有 maxTokens 字段,输出上限由请求带)
reasoningEfforts 无对应配置;hermes 走 agent.reasoning_effort 单值,档位集合由 /v1/models 的 reasoning_efforts 提供

baseURL: http://127.0.0.1:8788 说明客户端跑在飞牛宿主上——hermes 容器内 8788 不监听,容器里必须换 192.168.31.201:8788。看到 127.0.0.1 别直接抄进 /opt/data/config.yaml。

thinkingFormat: openai 有坑:hub 把思考内容放在 reasoning_content 里(DeepSeek 方言)。客户端声明 openai 却读不到思考过程时,改 thinkingFormat: deepseek,请求形状不用动。

3. hermes config 命令工具坑

  • patch/write_file 工具会拦 Hermes config("use 'hermes config' instead")

  • 单值: hermes config set providers.buddy2api.api http://...

  • 列表必须 JSON 格式: hermes config set providers.buddy2api.models '["a","b"]'

  • 逗号分隔字符串会被当 1 个元素(这个会让人以为只配了 1 个模型)

  • 删除: hermes config unset providers.buddy2api.models

  • fallback_providers 是 list of dicts: hermes config set fallback_providers '[{"provider":"buddy2api","model":"cn:..."}]'
    🔴 hermes config set providers.X.model <name> 会顺手把 model.default = <name>, model.provider = X——即你的 primary 静默被改。联动规则不只 model 字段:实测触发联动的字段是 model(设值即同步 model.default = 设的 model, model.provider = X);不触发联动的字段:api / api_key / api_mode / models / base_url 这五个。正确做法:编辑 providers.* 字段全程用这五个之一,model 字段单独用 hermes config set model '{"provider":"...","default":"..."}' 显式指定,或走 hermes model 交互 picker 一次到位。任何时候设完 providers.X.model 都立刻 hermes config get model 确认 primary 没被改。

  • -p <provider> 是显式路由的强制写法(切到自定义 provider 调 chat 必须带)。hermes chat -m deepseek-v4.1-flash -q "ping" 不带 -p 会卡 30+ 秒超时(hermes 不知道走哪个 provider,扫 fallback_chain 兜底)——正确写法:hermes chat --provider workbuddy-hub -m deepseek-v4.1-flash -q "ping 答 1 字 pong"。带 -p 后 5-15s 内出 200。判断"切了后不能聊"的根因:99% 是 -p 没带,不是配置错。也用 -p 验证新建 provider 是否真通——配置对 + key 对 + 上游活的"全链真测"必须用 -p,不靠 /model picker。

4. 探针负缓存 (重要陷阱)

hermes_cli/models.py probe_api_models 有 _probe_neg_cache 内存缓存失败结果 5min TTL。

  • 触发: 任何一次 /v1/models 失败(网络抖动/timeout)→ 缓存该 base_url 5min
  • 表现: 后续 5min 内 /model 切到该 provider 都会弹 "could not reach" 警告
  • 实际 7863 是通的(实测 1ms 200, 23KB),但 hermes 不重试
  • 修法: 重启 hermes gateway 清内存(代价:会断当前 session);或等 5min;或 hermes model --refresh(需 TTY)

5. /model 切换模式

  • session-only (默认): 只改 session 内存,sessions 表 model_config 字段不变,session 结束失效
  • --global: 持久化到 sessions 表 model_config.gateway_runtime
  • 切回命令: /model minimax-cn:MiniMax-M3 --global

6. session 配置继承坑

/opt/data/state.db sessions 表 model_config.gateway_runtime.{provider,base_url,api_mode} 决定实际请求发哪。

  • _reset_from 字段: 新 session reset 时继承旧 session 的 gateway_runtime
  • 历史上 10082 端口(已死的旧反代)曾通过 _reset_from 污染 9 月的 session,又通过 reset 污染 10 月的 session
  • 表现: 配置正确 (providers.buddy2api = 7863) 但 session 用 10082 → 502
  • 修法: 直接 SQL 改:
    UPDATE sessions SET model_config=json_set(model_config, '$.gateway_runtime', 
      json('{"provider":"minimax-cn","base_url":"https://api.minimaxi.com/anthropic","api_mode":"anthropic_messages"}'))
    WHERE id='<session_id>';
  • 预防: 不定期 SELECT id, model_config FROM sessions WHERE model_config LIKE '%10082%' AND archived=0 清查

7. 排查 502 / 401 / 警告的标准流程

  1. 直接探 7863 (从 hermes 容器内):
    import urllib.request, json
    req = urllib.request.Request("http://192.168.31.201:7863/v1/models",
        headers={"Authorization": "Bearer <key>"})
    with urllib.request.urlopen(req, timeout=5) as r:
        print(r.status, len(r.read().decode()))
  2. 看 /opt/data/sessions/request_dump_*.json(失败请求会自动 dump):
    • request.url: 实际发的 URL(看是不是 10082 旧死链)
    • request.body.model: 实际 model
    • error.status_code: 502/401/etc
  3. 看当前 session routing:
    SELECT id, model, model_config FROM sessions WHERE id='<current_session_id>';
  4. 看 hermes auth:
    • hermes auth status minimax-cn → logged in / logged out
    • hermes auth status buddy2api → 同
  5. 看容器:
    • ssh fnos sudo docker ps --format '{{.Names}}\t{{.Status}}' | grep workbuddy
    • 看 healthy / unhealthy

8. 模型白嫖验证 (2026-10-18 实测)

模型 credit 状态
cn:deepseek-v4.1-flash 0 ✓ 稳
cn:glm-5.3-flash 0 ✓ 稳
cn:kimi-k3-1 0.07 ✓ 几乎白嫖
cn:minimax-m3 0 ✓ 稳(同主用)
cn:glm-5.3 0.01 ✓ 几乎白嫖

不能用的 (no_healthy_account): cn:kimi-k2-thinking, cn:glm-4.6, cn:glm-5.0-turbo
选模型时先实际 probe 一次再配进 hermes。

9. minimax-cn (主用) 协议细节

  • base_url: https://api.minimaxi.com/anthropic (不是 /v1)
  • api_mode: anthropic_messages (不是 chat_completions)
  • 5 个模型: MiniMax-M3, M2.7, M2.5, M2.1, M2
  • 当前主用: MiniMax-M3

10. 飞牛凭据

  • SSH: Russ534@192.168.31.201 (大写 R),密码 ${FNOS_PASSWORD},Russ534 不在 docker 组 → 要 sudo
  • 别名 ssh fnos 在 hermes 容器有效 (有 ~/.ssh/fnos_ed25519 + config)
  • sudo 密码 = SSH 密码
  • key 在飞牛 config.json: /vol1/1000/Docker/workbuddy2api/workbuddy2api-source/config.json 的 api_key 字段

11. 让其他容器消费 workbuddy2api (7863) —— OpenAI 兼容配置通用模式

通用前置

  1. ssh fnos 'curl -sS http://192.168.31.201:7863/v1/models -H "Authorization: Bearer <key>" | jq .data[].id' 拿到模型 id 列表(别瞎猜 model 名,要用上游实际暴露的)
  2. base_url 用 容器 IP(如 http://172.18.0.x:7863/v1)或 LAN IP http://192.168.31.201:7863/v1 —— 取决于容器是否能解析 LAN IP
  3. api_key 必填,不能省;省了服务返回 401

容器配置字段对照表(2026-10-19 实测)

容器 配置文件 / 接口 字段名 备注
moviepilot-v2 (4.x+) PG app_settings.dynamic_app_config jsonb ai_api_key / ai_base_url / ai_model_name / ai_provider='openai' 改完不需要重启容器,下次 LLM 调用即生效。plugin.ChatGPT.openai_url 是遗留字段,改了不生效
mdc (mdcng/mdc) /config/config.json metadata 块 openai_base / openai_key / openai_model 字段名不是 openai_base_url!前端 JS bundle 才认这三个名字(看 /opt/frontend/.next/ 找字段名)

mdc 配置实例(用 workbuddy2api 7863)

import json
p = '/vol1/1000/Docker/mdc/config/config.json'
d = json.load(open(p))
m = d.setdefault('metadata', {})
m['translate_engine'] = 'OpenAI'   # 必须设为 OpenAI 才会读 openai_* 字段
m['openai_base']   = 'http://192.168.31.201:7863/v1'
m['openai_key']    = '<workbuddy2api api_key>'
m['openai_model']  = 'cn:deepseek-v4.1-flash'  # 从 /v1/models 拿
json.dump(d, open(p, 'w'), indent=2, ensure_ascii=False)
# 改完重启容器让 Node.js 重新读 JSON
docker restart mdc

mp2 配置实例(同样思路)

UPDATE app_settings
SET value_json = value_json || jsonb_build_object(
    'ai_api_key',   '<key>',
    'ai_base_url',  'http://192.168.31.201:7863/v1',
    'ai_model_name','cn:deepseek-v4.1-flash',
    'ai_provider',  'openai'
)
WHERE setting_key='dynamic_app_config';
-- 不需要 restart mp2

陷阱

  • mdc 配置目录是 /vol1/1000/Docker/mdc/config/config.json 不是 /app/... 或 ~/.config/...——容器内 /config bind mount 到宿主这个路径
  • 前端 SPA 没有 /api/config 之类的 REST 端点暴露字段名——必须 grep -rE "openai_(base|key|model)" /opt/frontend/.next/ 在容器内搜 JS bundle 找真实字段名(容器内 grep 慢,限路径)
  • 修改完 mdc config.json 必须 docker restart mdc——Node.js 启动时一次读完整个 JSON,运行期不监听文件变化
  • mp2 不需要 restart——pg 改完下次 LLM 调用即生效
  • mihomo 代理拦不拦 7863?——容器用 LAN IP 192.168.31.201:7863 直连 mihomo(mihomo 在 host);workbuddy2api 自己也跑在 host 上(host network 模式),不需绕代理。LAN → LAN 不走 mihomo

12. 用户偏好(操作 wb2api / mp2 / mdc 等容器时的硬规则)

从多次纠正里抽出来的硬规则,不要再犯:

  • "自己检查" / "你去瞅一眼" / "你检查一下" = 当场实测回数据,不要凭记忆或推理给结论。被纠正"你又瞎猜啊"就是因为 agent 给"应该"/"肯定"答复。规则:每次涉及"X 现在能不能用"都跑真 probe,curl /v1/... / ssh fnos 'docker ...' / cat ...,再回报数字。
  • "用现有的 X" = grep across containers/envs/DBs first, then ask. 爸爸说"就用现有的 mp 的"指的就是当前正在跑的 mp2 容器里的 TG token。先查 docker exec / cat config.json / 翻 PG dynamic_app_config,别上来就问。完整 discovery ladder 见 fnnas-admin skill "Use the existing one" 段。
  • 不要在确认之前反复追问"你确认一下 X 吗"——给出我看到的事实 + 我打算做的,让爸爸一条话修正,别连续问 3-4 个 yes/no。一次列完。
  • "你看着来" / "你自己判断" / "干" = execute, don't enumerate. 说完就执行,不要再列 3 个选项问爸爸选。
  • 同一段连续发了几条消息("A ①" / "A B" / "B ②")= 上一条方案不对,按这条执行,不要回头再确认上一条。
  • Shell heredoc 中文括号炸 bash. echo === foo(bar)=== 在 bash <<'EOF' 里报 unexpected token。用 ASCII () / 跳过括号。每次手输代码时注意。
  • "你快闭嘴吧" / "别说话直接干" = 收到后不要回完整 plan,只回一句确认 + 直接开始执行(不要反问 "你想我 X 还是 Y")。例:收到"你快闭嘴吧"——回 "好" 或直接进入执行态,不要解释为什么这么做、不要列举下一步、不要请求更多细节。
  • "我通过 X 修好了" = 你/外部脚本改过了,立刻 重读 state,不要凭"上一轮 config 是 Y"继续——已经 drift 了。例:用户说"通过 hermes 终端修复好了",agent 不能继续推断"上次改的是 NO_PROXY",必须 ssh 重读实际 env。重读优先于凭记忆。
  • agent 不要重述自己"我要做 A B C","你拍板" 等发问式段落——已经在 §16 / §18 / §19 等多处写过,但每次踩坑。当下行动是什么 + 下一步第一个动作直接说,不问"行不行 / 怎么做 / 拍板"。

13. 操作容器配置的标准 3 步 (避免"只改了不验证")

任何"改完就走"的操作(PG 改 config / compose 改 env / config.json 改字段),按顺序:

  1. 改之前先记录当前值(grep / SELECT / cat 落盘到 /tmp/before.json)
  2. 改 + 验证 YAML / JSON 语法(docker compose config -q / python3 -c 'json.load(open(p))')
  3. 改完重启 / 重读 + 验证生效(docker ps 看 restart ok / 端点 curl 200 / 服务日志里看到新值被加载)

反例:只改 config.json 不 docker restart mdc → Node.js 跑的还是旧 JSON → 翻译引擎继续打 "无 key" 错。

14. API token 在 hermes stdout 跨域输出截断

飞牛上某些服务返回的敏感字符串(TG bot token、API key),在 Hermes Python 端 json.loads() 拿到的实际是完整字符串(165 字符),但 print(json.dumps(...)) 在 stdout 会被 mask 成 eyJhbG...W3gE(中间 ***),并且 len(token) 也被错报成 13(mask 后的字符数)。症状:用 Python 端拿到的 token 调 API 返回 401。

修法:把 token 写到 fnos 文件,再从 fnos 那边直接读:

# fnos 端
ssh fnos-root 'python3 -c "import json;print(json.load(open(\"/path/to/login_resp.json\"))[\"data\"][\"token\"])" > /tmp/tok.txt'
wc -c /tmp/tok.txt   # ← 文件大小是真实长度(165 字节,不是 13)
# 直接 cat 文件给后续 curl 用,不再走 Hermes 输出

wc -c 在 fnos 文件系统上是真实字节数,永远准确——跟 Hermes Python print 的 mask 无关。

15. "判断云盘挂载是 clouddrive2 起的 vs rclone 起的" — 用 mount 输出

ssh fnos 'mount | grep fuse'
# fusectl on /sys/fs/fuse/connections type fusectl (rw,...)
# 1000-1-7b565032:dav on /vol02/1000-1-7b565032 type fuse.rclone (...)
  • type=fusectl 是内核暴露的元数据文件系统,无关
  • type=fuse.rclone 的 mount 是 rclone 进程直接挂在宿主——跟 clouddrive2 容器无关
  • clouddrive2 容器的 fuse mount 是它自己进程内的,不通过宿主 mount 命令查

Clouddrive2 容器升级时,不影响 rclone 挂载——rclone 是宿主独立进程。升级只让 cd2 WebUI (:19798) 重启,业务挂载 /vol02/1000-1-7b565032/... 一直在。

实际:docker pull cloudnas/clouddrive2:latest && docker compose up -d → 容器名会变成 adoring_lewin(recreate 旧容器名被 docker 随机名占),需要 docker rm -f adoring_lewin 清僵尸。完整流程:

ssh fnos-root "cd /vol1/1000/Docker/clouddrive2 && docker compose pull && docker compose up -d"
sleep 5
ssh fnos-root 'docker ps -a --filter ancestor=cloudnas/clouddrive2 --format "{{.Names}} {{.Status}}"'
# 如果出现 random-named 旧容器:docker rm -f <random-name>
curl -sS -m 5 -o /dev/null -w "cd2 web=%{http_code}\n" http://127.0.0.1:19798

业务挂载验证:

ssh fnos-root 'mount | grep fuse.rclone'   # rclone 挂在的宿主路径,clouddrive2 升级不影响
ssh fnos-root 'docker exec moviepilot-v2 ls /CD2 | head'  # mp2 读得到 115open 内容就 OK

16. 区分消息来自 Telegram 还是微信 (rule)

每个 user 消息 gateway 都会带 Gateway message origin (JSON data, ...) 块(含 platform / chat_id),不要从消息内容、用户语气、机器人用户名瞎猜——同一个用户同一个人,TG 和微信都连着同一台 hermes,路由/routing/state 不一样。判定三秒完成就按脚本完,不要来回跟爸爸确认:

# 解析 origin JSON 即可
import json
origin = json.loads(origin_block[origin_block.find('{'):origin_block.rfind('}')+1])
platform = origin['platform']   # 'telegram' | 'weixin'
chat_id  = origin['chat_id']

反例:看到"Open"/MyGPT /MyOpenclaw 这种机器人名别立刻当微信——它也可能是 TG 的某个 bot 用户名;看到 chat_id 7271375745(TG Russ534)/ o9cq...@im.wechat(微信)才能定。

判错后果:把 TG 的故障排查跑去改微信 state.db → 修错地方,浪费时间还被纠正。

17. /model picker 里的脏 provider 清理 (3 个地方必须同时清)

/model picker 列什么由三处共同决定:

  1. 磁盘 catalog 缓存:/opt/data/provider_models_cache.json(key 是 provider slug,value 是 models 列表)
  2. 磁盘模型元数据缓存:/opt/data/models_dev_cache.json + /opt/data/models_dev_cache.json.etag
  3. 用户配 providers.* 段:/opt/data/config.yaml 里 providers.X

加 excluded_providers: [X, Y] 只挡 #1(catalog 静态入口),挡不住 #3(用户配的)——skill §2 已写。但:删完 providers.X 之后 picker 里还能看到 X 一段时间,因为 #1 #2 还在缓存里没刷。要 picker 立刻干净必须三处一起清:

cp /opt/data/provider_models_cache.json /opt/data/provider_models_cache.json.bak.$(date +%Y%m%d-%H%M)
rm /opt/data/provider_models_cache.json /opt/data/models_dev_cache.json /opt/data/models_dev_cache.etag
# config.yaml 改 excluded_providers / unset providers.X
hermes config set model_catalog.excluded_providers '["nous", "buddy2api", "workbuddy-hub"]'
hermes config unset providers.workbuddy-hub   # 如果想彻底干掉(fallback 也丢了)

不删 .etag 文件的后果:下次 gateway 启动会带着 If-None-Match 头去拉远端 etag,远端 etag 不变就拿 304 + 老 body——清缓存等于没清。

18. hermes 外部 config drift 防护 (rule)

/opt/data/config.yaml 不只你会改——其他 session / kanban / 别的 agent / 自动同步脚本都可能在两次对话之间把它改了。绝不可凭"上一轮是 X"或"我配的是 X"做推断,每一轮独立重新 hermes config get:

hermes config get model
hermes config get providers
hermes config get fallback_providers
hermes config get model_catalog.excluded_providers

断言"我配过 buddy2api"前,先 cat /opt/data/config.yaml 看一眼当前实际是不是 buddy2api。session 间被改成 workbuddy-hub(同字段不同名)+ base_url 换 8788 + api_key 换 wb-37S9... 都发生过。

配合 §2b:上游 base_url 变了,原来的 /v1/models 探针结果(model id 列表、是否带 cn: 前缀、大小写)全部作废——必须对新 base_url 重新跑 §2b 的真实 probe。反向特别坑:旧 gateway(7863 Go 版)model id 是 cn:deepseek-v4.1-flash(带前缀),新 hub(8788 Python)model id 是 deepseek-v4.1-flash(裸名小写)——同一套"看起来对"的配置在新老 gateway 间迁移一字不变就直接 404。

19. hermes auth status <custom_provider> 永远 logged out (rule, 别误诊)

hermes auth status minimax-cn 这种内置 provider 返回 logged in / logged out 是真实鉴权状态。但 hermes auth status workbuddy-hub(任何 providers.* 里自定义的)一律返回 logged out——因为 hermes 的 auth 池只对内置 provider 维护凭据,custom provider 走 providers_dict_to_custom_providers 逻辑不接入 auth 池。用 logged out 判断 custom provider "不能用"是误诊。

真实可用性判断(按这条顺序):

  1. 实际探一发 /v1/chat/completions(最权威)—— 配置正确 + key 对 + 上游在 = 200
  2. hermes config get providers.X 看 api / api_key / api_mode / models 字段实际有
  3. hermes config check 看 Config version: 50 ✓(语法合法)

不要用 auth status 单独判 custom provider 通不通——会把自己骗到以为配错了然后反复改 yaml。

20. /v1/models cache 命中背后藏的型号漂移(重要防呆)

/v1/models 探针走的代码路径是「先读 disk cache + 再去上游验证」——大部分时候用户切 /model 看到的列表都是缓存里的旧数据,不是当下上游的。上游换了、新模型上线了、模型重命名了,picker 不会立刻反映,必须 rm 缓存文件后下次 gateway tick 才会拉新(或者 hermes model --refresh 需 TTY)。

反向案例:上一轮删了 buddy2api 段 + 加了 workbuddy-hub 段,以为 /model 面板应该立刻只剩 hub 的 18 个——结果面板还显示 Buddy 2API 旧 5 个,因为 disk cache 还在。等清完缓存再测,hub 的 18 个就在了。

配合 §17 的三处一起清——catalog cache 不清,picker 永远是过时的视图。

21. JSON 序列化的 hermes 端大小写失真(rule, 接 hub 后多发)

hub(Python 实现的)/v1/models 返回 minimax-m3 / glm-5.3 / deepseek-v4.1-flash(全小写)。老 sliverkiss-go gateway(7863)/v1/models 返回 cn:deepseek-v4.1-flash(带前缀)。接入 hub 不要照搬老 gateway 的 model id 列表——必须 curl -sS /v1/models | jq -r '.data[].id' 拿当下的列表原样配。

hub 自动重定向 alias:glm-5.2 实测返回 glm-5.2-x,但请求发 glm-5.2 也 200(hub 内部 token 表别名映射)。但 glm-5.1 发过去 400 "reasoning effort value is not supported by the current model"——上游拒了。所以实测 17/18 ≠ 100% 可用,要每个真 probe 一发,配错的删掉。

22. hermes 容器被 mihomo proxy 截胡 LAN IP(502 Bad Gateway)

Hermes 容器 shell 默认 HTTP_PROXY=http://192.168.31.201:7890 + HTTPS_PROXY=...(mihomo 出公网代理)。Python urllib 看到这俩 env var 自动让所有 HTTP 流量——包括 LAN 内网 http://192.168.31.201:8788/v1/models——也走 mihomo。mihomo 看 LAN target 不认识 → 返回 502 Bad Gateway(不是 timeout、不是 DNS 错)。

症状(最难辨):

  • 从容器外(fnOS host / 你 ssh 进飞牛)curl http://192.168.31.201:8788/v1/models → 200 OK(走 host network 绕过 mihomo)
  • 从 hermes 容器内 python3 urllib.urlopen(...) → 502 Bad Gateway
  • hermes /model 切到 workbuddy-hub 报 "could not reach this custom endpoint's model listing"(不是 "auth failed"、不是 "model not found"——是 reachability)
  • hermes_cli/models.py probe_api_models 有 5min _probe_neg_cache,一次失败后续 5min 全 cached 失败

修法(按顺序):

# 1. .env 必须含 NO_PROXY(Python urllib 读这个;curl 读 no_proxy 也读)
#    写到 /opt/data/.env 里:
echo 'NO_PROXY="*"' >> /opt/data/.env
echo 'no_proxy="*"'   >> /opt/data/.env

# 2. 验证(hermes 容器内):
NO_PROXY='*' no_proxy='*' python3 -c "
import urllib.request
req = urllib.request.Request('http://192.168.31.201:8788/v1/models',
    headers={'Authorization':'Bearer <hub launcher_key>'})
print(urllib.request.urlopen(req, timeout=5).status)
"
# 期望 200

# 3. hermes .env 是 hermes CLI 启动时 load(`load_env()` memoized,subprocess 重新读)。
#    **不需要重启 hermes gateway 进程**——下次 `hermes chat`/`hermes model` 自然 reload

绝对不要 unset HTTP_PROXY——hermes 还要靠它走公网(OpenRouter / Anthropic 真实 API)。

🔴 但也不能写 NO_PROXY=*(2026-10-19 真实事故:TG 掉线 10 分钟):* 让所有出口直连,包括 api.telegram.org。容器直连 Telegram 必死(DNS 被污染成 199.96.59.61 之类 / 149.154.x 被墙),日志表现为 ConnectTimeout 反复重试 8 次后 telegram connect timed out after 180s。正确值是定点 LAN 列表:

NO_PROXY="192.168.31.201,127.0.0.1,localhost,::1"
no_proxy="192.168.31.201,127.0.0.1,localhost,::1"

验证一对(改完 .env 前后各跑一遍):

python3 -c "from hermes_cli.env_loader import load_hermes_dotenv; load_hermes_dotenv()
from gateway.platforms.base import resolve_proxy_url, should_bypass_proxy
print('tg bypass', should_bypass_proxy(['api.telegram.org']))          # 必须 False
print('tg proxy', resolve_proxy_url('TELEGRAM_PROXY', target_hosts=['api.telegram.org']))  # 必须给出 mihomo URL
print('hub bypass', should_bypass_proxy(['192.168.31.201']))           # 必须 True"

日志侧铁证:连接成功时会出现 [Telegram] Proxy detected; passing explicitly to HTTPXRequest: http://…:7890 —— 没有这一行 = 走直连 = 必掉。

.env 改动对已在跑的 gateway 不生效(load_env 在进程启动时读),改完必须 hermes gateway restart;重启后看 ✓ telegram connected。

诊断步骤(用于"我之前是好的现在不行了")**:

# A. 容器内 HTTP_PROXY 是否还在
env | grep -E '^HTTP_PROXY=|^HTTPS_PROXY='

# B. 同一个 URL 直接 curl vs urllib,对比:
curl -sS -m 5 -o /dev/null -w 'curl=%{http_code}\n' http://<LAN>:<port>/v1/models -H 'Authorization: Bearer <key>'
python3 -c "import urllib.request; print('urllib=', urllib.request.urlopen(urllib.request.Request('http://<LAN>:<port>/v1/models', headers={'Authorization':'Bearer <key>'}), timeout=5).status)"
#   curl=200 + urllib=502  → mihomo 截胡
#   curl=502 + urllib=502  → 端口根本不通,先看 hub 容器 alive

# C. NO_PROXY 临时测:
NO_PROXY='*' python3 -c "import urllib.request; print('NO_PROXY=*:', urllib.request.urlopen(urllib.request.Request('http://<LAN>:<port>/v1/models', headers={'Authorization':'Bearer <key>'}), timeout=5).status)"

常见踩坑:

  • NO_PROXY=192.168.31.0/24 不生效——Python urllib no_proxy 不识别 CIDR,只认 * / 单 IP / hostname
  • 只设大写 NO_PROXY 不设小写 no_proxy——历史代码两条都读
  • 修了 .env 但当前 hermes chat 还报"could not reach"——_probe_neg_cache 5min TTL,跑 hermes model --refresh(需 TTY)强制清缓存

CLI vs gateway 的 NO_PROXY 修复(分两套)

.env 改 → 只对 hermes chat / hermes model CLI 子进程生效(CLI 启动时 load .env,subprocess 重新读)。gateway 长进程(s6 起的)是 /run/s6/container_environment/* 文件——容器启动时 bake 进去,运行时改 .env 它看不到。

关键判断:

  • 用户在 WeChat/TG 跟 hermes 聊,用 gateway 进程——需要 NO_PROXY 在 /run/s6/container_environment/ + 容器重启
  • 用户在主机跑 hermes chat -q "..." 测试 / agent 自己跑 CLI 验证——.env 加 NO_PROXY 立刻生效,不需要 gateway 重启

用户从 WeChat/TG 抱怨"对话不能聊" = gateway 路径——必须改 /run/s6/container_environment/ + docker restart hermes。用户跑 hermes chat 测试 = CLI 路径——.env 加 NO_PROXY 就行。

🔴 s6 gateway 进程的 NO_PROXY 修复(.env 改不够)

坑:上一节说"修 /opt/data/.env 就行"只对 hermes chat CLI 子进程生效。gateway 长进程是 s6-overlay 启动的,env 来源不是 .env,是 /run/s6/container_environment/* 文件——这些文件是容器启动时 bake 进去的,gateway 已经跑起来之后改 .env 它看不到。

症状:CLI Python urllib 设了 NO_PROXY=* 能通 200,但 gateway 进程对外发的 chat 请求依然 502——因为 gateway 还是 HTTP_PROXY=..., NO_PROXY=。

验证 gateway 是否也走 mihomo:

# 看 gateway 进程的实际 env(hermes 容器里)
for pid in $(pgrep -f 'hermes.*gateway'); do
    grep -aE '^(HTTP_PROXY|HTTPS_PROXY|NO_PROXY|no_proxy)=' /proc/$pid/environ 2>/dev/null | tr '\0' '\n'
done
# 期望:HTTP_PROXY=...; HTTPS_PROXY=...; 没 NO_PROXY → 它走代理

两种 502 表现(实测命中):

  • urllib.error.URLError: Connection refused —— mihomo 拒 LAN IP(最常见,§22 配 NO_PROXY=* 就解决)
  • urllib.error.HTTPError: 502 Bad Gateway —— urllib 成功连到 mihomo 但 mihomo 自己回 502(也配 NO_PROXY=*)

修复 3 种(按优先级):

  1. 整容器重启(持久化推荐):改 docker-compose 加 NO_PROXY: "*" + docker compose up -d hermes,s6 重新 bake env 到 /run/s6/container_environment/。代价:断当前所有 session。
  2. 只重启 gateway service(无容器重启):在飞牛 SSH 用 root 写 /run/s6/container_environment/NO_PROXY + s6-svc -h /run/s6/services/main-hermes。不断其它不依赖 gateway 的会话,但 session 引用 gateway 进程会被踢——用户感觉跟整容器重启一样。
  3. s6-rc change(hermes 用户可跑):/command/s6-rc -h list、/command/s6-rc change main-hermes —— hermes 用户有 s6-rc 调用权,但 /run/s6/container_environment/ hermes 用户写不了,所以光 change 不写文件等于白跑。

完整 root 修法(在飞牛 SSH 跑,最稳):

ssh fnos-root 'echo "NO_PROXY=*" > /run/s6/container_environment/NO_PROXY
               echo "no_proxy=*" > /run/s6/container_environment/no_proxy
               docker restart hermes'  # 整容器重启,s6 重新 bake env
sleep 5
ssh fnos-root 'docker exec hermes grep -aE "NO_PROXY" /proc/$(pgrep -f "hermes.*gateway" | head -1)/environ | tr "\0" "\n"'
# 期望看到 NO_PROXY=* / no_proxy=*

完整 compose 修法(持久化,避免每次重启容器都要再设):

ssh fnos-root 'cat /vol1/1000/Docker/hermes/docker-compose.yml'   # 先看现有 env
# 在 environment 段加:
#   - NO_PROPY=*
#   - no_proxy=*
ssh fnos-root 'cd /vol1/1000/Docker/hermes && docker compose up -d hermes'

验证 gateway 修好了:hero 用 /model <provider>:<model> 切到 hub 模型,发消息能秒回;wb-hub docker logs --since 2m | grep chat: 能看到对应 chat 请求日志。

常见踩坑:

  • NO_PROXY=192.168.31.0/24 不生效 ——Python urllib no_proxy 不识别 CIDR,只认 * / 单 IP / hostname
  • 只设大写 NO_PROXY 不设小写 no_proxy ——历史代码两条都读
  • 修了 /run/s6/container_environment/ 但没重启 gateway service ——s6-init 启动时读 env 文件,运行期不监听 env 文件变化(这是 s6-overlay 设计,不要去 grep 文档争论)
  • 修了 .env 重启了 hermes 但 NO_PROXY 还是空 ——说明 docker-compose / docker run 命令本身没传 NO_PROXY,光改 .env 不够(.env 影响 hermes CLI,不影响 s6-supervised 长进程)
  • 飞牛 hermes 容器是 docker run 直接启的,没有 docker-compose 文件 ——要写一个 compose 或者改宿主 /etc/systemd/system/hermes.service 之类。短期最快的办法:在 /run/s6/container_environment/ 写文件 + docker restart hermes,长期再去 compose 化。

🔴 Agent 不能从 hermes 容器内核重启自己(kernel 死了 = session 死了)

踩坑(2026-10-18 实测):agent 在 hermes kernel 里跑 docker stop hermes 想重启加载新 NO_PROXY env——docker stop 杀掉了自己运行的容器,session 立刻中断,kernel 死,user 看到 Operation interrupted,agent 后续所有 execute_code / terminal 都 Session kernel died while running the cell。即使 s6 auto-restart 把 hermes 容器重新拉起来(59 秒后 Up),那个 kernel session 已经永久死掉,新的对话会起新 session。

规则:永远不要在 hermes kernel 内执行以下任一:

  • docker stop hermes / docker kill hermes / docker restart hermes / docker rm hermes
  • pkill -f 'hermes.*gateway' / kill -9 <gateway_pid> (gateway 是 s6-supervised 但其他 hermes 进程会被波及)
  • 任何会杀 PID 1 (s6-init) 的操作 —— kill 1、reboot、shutdown

改 hermes gateway env 的正确流程(从飞牛 SSH 端走,不在 hermes kernel 里):

# Step 1: 备份当前 hermes 容器配置(防止 docker run 命令拼不回去)
ssh -tt fnos 'echo ${FNOS_PASSWORD} | sudo -S docker inspect hermes > /tmp/hermes-inspect.json'
# Step 2: 备份完整 env(含敏感 key)
ssh -tt fnos 'echo ${FNOS_PASSWORD} | sudo -S docker inspect hermes --format "{{range .Config.Env}}{{println .}}{{end}}" > /tmp/hermes-env.txt'
# Step 3: stop + rm(host 端执行,hermes kernel 不参与)
ssh -tt fnos 'echo ${FNOS_PASSWORD} | sudo -S docker stop hermes && echo ${FNOS_PASSWORD} | sudo -S docker rm hermes'
# Step 4: 重建(保留 bind/network/image,加新 env)
ssh -tt fnos "echo ${FNOS_PASSWORD} | sudo -S docker run -d --name hermes \\
  --network hermes_default \\
  -v /vol1/1000/Docker/hermes:/opt/data:rw \\
  --restart always \\
  -e HTTP_PROXY=http://192.168.31.201:7890 \\
  -e HTTPS_PROXY=http://192.168.31.201:7890 \\
  -e NO_PROXY='*' -e no_proxy='*' \\
  -e LLM_MODEL=deepseek/deepseek-v4-flash \\
  -e DEEPSEEK_API_KEY=<真 key> \\
  -e TELEGRAM_BOT_TOKEN=<真 token> \\
  -e GATEWAY_ALLOW_ALL_USERS=true \\
  -e PYTHONUNBUFFERED=1 \\
  -e PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright \\
  -e HERMES_HOME=/opt/data \\
  -e HERMES_RUNTIME_DIR=/opt/hermes/tools \\
  -e HERMES_PYTHON=/opt/hermes/.venv/bin/python \\
  -e HERMES_TUI_DIR=/opt/hermes/ui-tui \\
  -e HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist \\
  -e HERMES_WRITE_SAFE_ROOT=/opt/data \\
  -e HERMES_DISABLE_LAZY_INSTALLS=1 \\
  -e HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages \\
  -e PYTHONDONTWRITEBYTECODE=1 \\
  -e XDG_RUNTIME_DIR=/tmp/hermes-runtime \\
  -e CC=gcc -e CXX=g++ \\
  nousresearch/hermes-agent:latest gateway run"
# Step 5: 验证
sleep 5
ssh -tt fnos 'echo ${FNOS_PASSWORD} | sudo -S docker ps --filter name=hermes --format "{{.Names}}\t{{.Status}}"'
ssh -tt fnos 'echo ${FNOS_PASSWORD} | sudo -S docker exec hermes pgrep -af gateway | head -3'

改 env 而不是重建:如果只是想改 env,先备份 env 全量再用相同参数重建(docker run 不支持改 env),没有捷径。docker container update --env-add 不支持,只能 stop+rm+run。

核验 env 真的进了容器(不是 .env 或 set -f):

ssh -tt fnos 'echo ${FNOS_PASSWORD} | sudo -S docker exec hermes sh -c "for pid in \$(pgrep -f gateway); do grep -aE \"^(HTTP_PROXY|HTTPS_PROXY|NO_PROXY|no_proxy)=\" /proc/\$pid/environ 2>/dev/null | tr \"\\0\" \"\\n\"; done"'
# 期望: 看到 HTTP_PROXY=..., HTTPS_PROXY=..., NO_PROXY=*, no_proxy=*
# 期望中**不**看到的: 任何来自 .env 但 s6-supervised 进程没读到的变量

关于 s6 service restart(不重启整个容器)的限制:理论上 s6-svc -h /run/s6/services/main-hermes 只重启 gateway service 不重启容器,理论上不杀 agent session——但 agent 跑在 main-hermes 启动的 python 里(/proc/self/exe 是 hermes 内核),service 重启 = main-hermes 全组进程重启 = agent 也被杀。实测无差异,还不如整容器重启来得稳。

为什么 .env 的 NO_PROXY 不够:s6-supervised 长进程启动时完全从 /run/s6/container_environment/* 文件读 env,不读 /opt/data/.env。.env 是 hermes config / hermes chat CLI 启动时 load_env_file() 才读,对用户从 WeChat/TG 跟 hermes 聊天的路径(gateway 长进程)完全无效。两次踩坑写下来:改 .env → 跑 hermes chat -p hub -m X -q "ping" → CLI 自己通 → 但用户说"还是聊不了" → 因为 gateway 没读 .env → 还是走 mihomo 502。只有重启容器并把 NO_PROXY 写进 /run/s6/container_environment/(或 docker run -e)才能修。

23. "mp2 智能助手能用" ≠ "hermes 调这个反代能用" (rule, 别外推)

用户说"mp2 的 LLM 助手调这个反代好好的" 不能 当 hermes 调同反代也通的证据。两个消费者走的是不同路径:

消费者 配置位置 base_url 通常是 鉴权 key 容器网络栈
mp2 (jxxghp/moviepilot-v2) PG app_settings.dynamic_app_config jsonb http://192.168.31.201:7863/v1(老 sliverkiss-go gateway,跟 hub 8788 是不同容器) Wb2Api-Russ534-20261018-... mp2 容器自己的网络,不走 hermes gateway
hermes /opt/data/config.yaml providers.workbuddy-hub http://192.168.31.201:8788/v1(hub) wb-37S9Wcidx6Wcjo4rLNQ-iOoV2yKc0HQL hermes 容器,走 s6-overlay 起的 gateway 进程 + mihomo proxy

踩坑链路(实测多次踩):

  1. mp2 用 7863 端口通 → 用户推断 hermes 也通 → 但 hermes 配的是 8788 → 8788 跑不了或 hermes 调它有别的问题
  2. mp2 容器直连 LAN 没代理 → hermes 容器走 mihomo 代理 → mihomo 拒 LAN → 502(§22 链)
  3. mp2 用的 key 跟 hermes 用的 key 不是同一个 → 一边通 ≠ 另一边通
  4. mp2 直连 7863 不走 hermes gateway ——mp2 容器不是 hermes 的下游,是独立消费者(PG app_settings.dynamic_app_config 配的 ai_base_url=http://192.168.31.201:7863/v1,直接出 container)。改 sliverkiss-go → hub 时,mp2 的 PG 字段也要同步改——mp2 LLM_BASE_URL=http://192.168.31.201:7863/v1 不跟着切 hub,mp2 同样 502。
  5. mp2 直连走的是容器内直连(不绕 mihomo),所以 mp2 调 7863 OK 不等于 hermes 调 8788 也走容器直连 ——两个容器的网络栈独立,mihomo 注入也独立

反推不可用的两种(实测命中):

  • 用户说"mp2 OK" → agent 推"hermes 调 hub 也 OK" → 错(不同容器不同配置不同 base_url)
  • 用户说"hub 5/5 模型跑通" → agent 推"hermes 调 hub 也 OK" → 错(hermes kernel urllib 通但 s6 gateway 走 mihomo 502)

"X 容器用着好,所以新容器也会通" 的逻辑完全错——每个容器有独立 env / 网络栈 / 配置位置。

正确判断 hermes 是否真通(不靠外推):

  1. hermes config get providers.workbuddy-hub 看实际配的 api / api_key
  2. hermes 当前 TG session routing 里 model_config.gateway_runtime.base_url 是啥
  3. request_dump 看真实 gateway 实际发的 URL + 响应
  4. docker logs wb-hub --since 2m 看 hub 端有没有收到那个请求 + chat 状态
  5. 不用 "mp2 通" / "wb-hub /v1/models 通" 当 hermes 通的证据

23a. mp2 v2 Web 智能助手流式对话 endpoint (实测 2026-10-19 真通)

mp2 v2 (jxxghp/moviepilot-v2) 的"智能助手" web UI 是反代过来的,endpoint 在 /api/v1/message/agent/stream(不是 /agent/stream,前缀 /message/agent 在 /app/app/api/apiv1.py:14 include_router(agent.router, prefix="/message/agent"))。完整流程:

# 1. 登录拿 token(oauth2 form)
TOK=$(curl -sS -m 8 -X POST http://192.168.31.201:3000/api/v1/login/access-token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "username=admin&password=${FNOS_PASSWORD}" | jq -r .access_token)
#    路径错位: /api/v1/user/access-token → 405, /api/v1/login/access-token → 200

# 2. POST /api/v1/message/agent/stream,body 字段是 text (不是 message)
curl -sS -m 120 -X POST http://192.168.31.201:3000/api/v1/message/agent/stream \
  -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
  -d '{"text":"ping 答 1 字 pong","session_id":"e2e-test"}'
# 期望 SSE 响应:
#   data: {"type": "start", "session_id": "web-agent:..."}
#   data: {"type": "delta", "content": "pong"}
#   data: {"type": "done"}

endpoint 发现路径(任何 mp2 / 类似项目的 web API 都这套思路):

# 找 router 注册位置
ssh fnos-root 'docker exec moviepilot-v2 cat /app/app/api/apiv1.py | head -20'
# 找目标文件里 @router.post 端点
ssh fnos-root 'docker exec moviepilot-v2 grep -nE "@router.post.*chat|@router.post.*stream" /app/app/api/endpoints/<file>.py'
# 看 input schema
ssh fnos-root 'docker exec moviepilot-v2 grep -nE "class AgentWebChatRequest" /app/app/schemas/*.py'

关键陷阱:

  • 字段 text(不是 message / content / prompt)—— 看 schema 文件不是瞎猜
  • session_id 复用历史对话;测试用一次性 UUID-style ID 避免污染 ~/.hermes_sequence / state.db 会话历史
  • 超时设 ≥ 60s(agent 调用 hub 模型 + 反思可能要 20-40s)
  • 改了 PG dynamic_app_config 的 ai_* 字段确实不需要重启 mp2 容器(容器启动后 settings loaded 一次,但每个 LLMHelper 调用都从 settings.get_* 重新读)—— 但 改了 .env 的 LLM_* 后必须 restart(env 是 process 启动时读)

为什么必须测这个 endpoint:mp2 的 LLM_* env + PG dynamic_app_config.ai_* 经常不同步(mp2 v2 4.x 双层配置),改 PG 但 mp2 实际走 env 是常见坑。只看 .env LLM_BASE_URL 是 hub 不是 mp2 真在用——唯一权威测试是发一发 /api/v1/message/agent/stream,响应内容是不是 hub 模型答的(不是 siliconflow 旧答)+ docker logs wb-hub --since 30s | grep "POST /v1/chat" 有对应请求。

24. 验证 hermes 调某个 provider 真通,必须三处都看 (rule, 反"自测 OK"陷阱)

踩坑:agent 说"我自己 curl /v1/chat 通 200"就拍胸脯"已生效"——这条证据只覆盖第 1 个环节,hermes gateway 实际有没有走对不走,不看下面几处就是漏:

检查点 命令 期望
1) 上游 API 真活 curl http://<LAN>:<port>/v1/models -H 'Authorization: Bearer <key>' 200, N 模型
2) 容器内直连也活 hermes kernel 内 urllib.request.urlopen(...) 200(验证 NO_PROXY 不被截胡)
3) gateway 进程 env 对 cat /proc/$(pgrep -f 'hermes.*gateway')/environ 看 HTTP_PROXY / NO_PROXY NO_PROXY=* 必须有
4) gateway 配置对 hermes config get providers.X api / api_key / models 字段全有
5) session routing 对 SELECT id, model_config FROM state.db WHERE id='<session>' gateway_runtime.base_url 指向 X
6) hub 端实际收到 docker logs wb-hub --since 2m | grep "chat:" 看到对应 chat 请求 200

只要 1+2 通就报"已生效" → 至少缺 3+5——gateway 用的 env 跟 kernel 内 Python 不同,配置 vs session 持久化又是两层。用户抱怨"切了不灵"通常栽在 3 或 5。

报告顺序:每条独立一行,先数字(pid / port / status code / 行数)后结论。不要 只给"通了"两字。

端到端 chat 真测命令(任何 provider 切换后必跑,必须带 --provider/-p,否则卡 30+ 秒超时,详见 §3 末尾):

hermes chat --provider <provider_slug> -m <model_id> -q "ping 答 1 字 pong"
# 例: hermes chat --provider workbuddy-hub -m deepseek-v4.1-flash -q "ping"

25. "加 1 个新模型" ≠ "替换现有 5 个的某一个" (rule)

用户说"加 1 个新模型 / 你看着加 1 个 / 还有 1 个没加"——默认解读:在已有列表基础上追加新模型(不替换、不去重)。agent 经常错误地把 "加 1 个 cn:deepseek-v4.1-flash" 当成"换成这个",然后抱怨"已经在了"——实际原列表里那个可能是别的/旧的:

  • "再加 1 个 cn:deepseek-v4.1-flash":如果列表里已有这个,用户说"加"的可能是想加 deepseek-v4-pro 或别的同名变体;必须问而不是猜
  • 真想确认要不要换 → 直接问"列表里已有 X,替换成哪个新模型?"
  • 不想问 → 试探性加一个明确不重复的候选(从 /v1/models 取个用户当前 5 个列表里没有的),报给用户看

反例:本次对话用户问"再加 1 个 cn:deepseek-v4.1-flash",agent 答"已经在了不用动"——用户实际是想要更多可用模型,agent 误解为确认意图。正解:答"这个已经在列表里了,你是不是想加 cn:deepseek-v4-pro 或别的?我列 5 个候选你选"。

26. 配完配置必做的 5 步验证(防"配置看着对,实际没生效")

任何改完 config.yaml / .env / docker-compose / state.db 后的强制 checklist,用户问"你确认了没"就对照这个:

  1. 重新读配置(防 session 间漂移):hermes config get <key>、cat /opt/data/config.yaml、cat /opt/data/.env——自己读而不是引用上一轮的记忆
  2. 重探上游:curl http://<LAN>:<port>/v1/models -H 'Authorization: Bearer <key>'——这次的,不是上轮的
  3. 核对模型命名:/v1/models 的 id 列表 vs providers.X.models 列表——精确匹配(大小写、前缀、引号都看)
  4. 端到端 chat 真过:hermes chat --provider <provider_slug> -m <model_id> -q "<一句短提示>" — 必须显式带 --provider(详见 §30)

完成 ≠ "set 命令退出 0"。完成 = 5 步全过 + 实际一次端到端 chat 通了(hero 手动发或 hermes chat --provider X -m Y -q "ping" 模拟)。

27. 容器 hostname = 当前 kernel 所在容器(hermes 自身的别名)

Hermes 的 host: hermes runtime env,hostname 命令输出 hermes——这是当前 kernel 所在容器的 hostname,不是飞牛宿主。任何"在 hermes 里 ssh 到 hermes 容器"或"docker exec hermes 在 hermes 容器内"的表述都是错的——docker exec hermes 是从别的环境(飞牛 SSH)发起的,从 hermes 容器内核视角,自身 hostname 就是 hermes。

实际含义:

  • hermes kernel 内 ssh 到飞牛(ssh fnos)→ 是从 hermes 容器往外连飞牛
  • 飞牛 SSH 内 docker exec hermes <cmd> → 是从飞牛进 hermes 容器跑命令
  • hermes kernel 内 docker ps → hermes 容器内没 docker CLI(sandbox 没装),会 command not found
  • cat /proc/1/cgroup 显示 0::/ → 在容器内(root cgroup namespace);在宿主会显示完整 cgroup path

别再说 "我先在 hermes 里 ssh 到飞牛,然后在飞牛上 docker exec 到 hermes 容器里跑命令"——这种绕法不存在。要么 hermes kernel 内直接执行(self ssh not needed),要么从飞牛宿主端直接 docker exec hermes。

28. hermes 实测网络 / 探针 跟"上游是否真的收到请求" 必须分别确认

跑下面这条常被当成"hermes 通" 的检查只证明第 1 个环节:

# hermes kernel 内:
import urllib.request, json
r = urllib.request.urlopen("http://192.168.31.201:8788/v1/chat/completions", ...)
print(r.status)  # 200

实际 hermes gateway 发请求时,可能:

  • 走不同的 proxy 配置(§22 讲过)
  • 用不同的 model id(用户在 /model 选了别的)
  • session routing 不一样(state.db 里 base_url 是旧值)

完整链路确认:

  1. hermes kernel 内 urllib 直连 8788 → 200(只证明 8788 容器自己 + hermes kernel 网络 + 没代理拦)
  2. hermes /v1/models 探针 success(只证明 hermes catalog 拿到了列表)
  3. docker logs wb-hub --since 5m | grep POST /v1/chat 看到 hermes 的请求 → 这是真凭实据

只做 1+2 就报"通了"→ 100% 缺第 3 步——而第 3 步恰恰是用户最关心的"切了不灵"那种隐性失败。

29. tg 微信 / v 微信 / v 微信端" 误判 5 次 = 不强提示(rule)

本对话中 agent 多次把 TG(chat_id=7271375745)的消息说成"微信"或"微信端",反复被纠正。已记在 §16 + memory,但对话中复现率高。

强提醒:每次回复前 1 秒检查 origin JSON 的 platform 字段——platform == "telegram" 就说 TG,platform == "weixin" 就说微信。不要凭记忆、不要凭上次的消息、不要凭机器人用户名。

最常踩的两个误判:

  • 截图里有"Openclaw"/"MyGPT"/"Open"机器人名 → 当成微信(实是 TG 的 bot 截图——微信 i-bot 是不同 UI 元素)
  • 截图里有"公众号"提示 → 当成微信(实是 TG 转发卡片)

判别关键:chat_id 的格式 + platform 字段。TG chat_id 是纯数字(7271375745),微信 chat_id 是 o9cq...@im.wechat 形式。只看 chat_id 前缀即可一秒定:数字 → TG,o9cq... → 微信。

session 层另一根线:每次回复前查 origin JSON 同时确认 chat_id 跟 system prompt 里的 Home Channels(TG=7271375745 / 微信=o9cq806xgTLbQAbl_N01pCJLw9Bg@im.wechat)对应关系——发现不一致(如 origin 说 TG 但 chat_id 是 o9cq...)立刻报错而不是凭记忆解释。

30. "改了 = 真生效" vs "改了 = 配置看起来对" (rule, 加 wb2api-hub 后多发)

部署完 OpenAI-compatible 反代 (wb2api-hub) → 改 4 个下游配置文件 (hermes / mp2 PG / mdc JSON / .env) → 报告"切换完成"是常见流。这个报告等于 0 实际保证——只代表你改了文件, 不代表下游服务真用上了新配置 / 新 key / 新 base_url。

踩坑实例 (2026-10-19 实测, 爸爸原话: "告诉我你改了个啥? mdc 容器我还没检查, hermes 这边一直连通不了我怀疑也是这个原因吧"):

  1. 我改了 mp2 PG dynamic_app_config.ai_api_key + .env LLM_API_KEY (用的 launcher_key 塞给所有下游)
  2. 我改了 mdc metadata.openai_key (用的同一个 launcher_key)
  3. 我改了 hermes providers.workbuddy-hub.api_key (用的同一个 launcher_key)
  4. 报告"切换完成"
  5. 实际 mdc 容器没检查是不是还在用 wb2api 老地址
  6. 实际 hermes gateway 长进程 env 里 NO_PROXY 没改 (配置改了, s6-supervised 进程不监听 .env)
  7. 实际 mp2 智能助手 endpoint 没真发过 一发测试 chat, 不知道响应是不是 hub 模型答的

修法 - 改完必做 5 件真测 (按这个顺序跑, 任何一项 fail 都得继续 fix, 不能报"切换完成"):

# 测试 命令 / 步骤 期望
1 mp2 智能助手真发 进 mp2 WebUI :3000 智能助手 tab, 发 "ping" 1 字 收到 hub 模型答的 (不是 siliconflow 旧答) + docker logs wb-hub --since 30s | grep chat 有请求
2 mdc 翻译真发 触发一个 mdc 转种任务, 或手动 docker exec mdc python3 -c "..." 调翻译 不再 "无 key" 报错, mdc 日志里没 TranslationError
3 hermes 链路 §24 三处 + hermes chat -p workbuddy-hub -m deepseek-v4.1-flash -q "ping" gateway 进程 env 有 NO_PROXY=*, chat 命令实际答 1 字, 不 timeout
4 key 类型对 看所有下游配置的 key 是不是 97ff4fe0... 格式 hex (dashboard key), 不是 wb-... 格式 (launcher_key) launcher_key 只 在 hub 运维端点 (改 settings.json / 看 /health), 不 下游 .env / config.yaml / mdc JSON
5 完整报告 上面的 1-4 全过 + 列所有改了哪几处 + 给爸爸一份"切换清单" "切完" = 5 件全过 + 报告逐条数字

"改了 = 生效" 是错误假设, 任何报告 "切换完成" 必须前面有真实发过请求的响应作为证据。

"改了 = 通过默认 verify" — PG 改字段会立即生效 (无 restart), config.json 改字段需要 restart (Node.js 启动时一次读完), .env 改 env var 改不影响 s6-supervised 长进程 (只影响 hermes CLI 子进程)。

31. "我以为...结果..." (rule, 加 launcher_key / dashboard key 误区后多发)

部署 hub 时我把 accounts/settings.json.launcher_key (自动生成的 wb-37S9Wcidx6Wcjo4rLNQ-iOoV2yKc0HQL) 当成 "hub 的 API key" 直接塞给所有下游——错了。launcher_key 是 hub 第一次启动自动写的 系统级 admin key, 跟 dashboard / panel 里 手动新建的 业务 API key (97ff4fe052d0fd446028e4cd4da05abfda64 这种 hex) 是两套独立系统:

  • launcher_key: 全权限 (等同管理 dashboard), 改 settings.json 重启就失效所有调用方 (重启所有下游跟着死)
  • dashboard API key: 业务调用方专用, dashboard 可单独 revoke 不影响其他调用方, label 区分 (eg hermes-prod, mp2-assistant, mdc-translate)

为什么我之前会用错:

  • 看到 wb- 前缀 → 以为这是 hub 的"主 key", 拿来用
  • 看到能调 /v1/models → 以为有权限
  • 没去 dashboard 找 "新建 API key" tab, 没意识到还有另一套系统
  • README 提 "Dashboard 默认密码 admin" 但没说 launcher_key ≠ dashboard 业务 key

怎么避免 (下次加新反代 / OpenAI-compatible 中间件):

  1. 装完不要立刻用 launcher_key 配下游
  2. 先改 dashboard 默认密码
  3. dashboard 里逐一为每个下游新建 key + 给清晰 label
  4. 再用 dashboard key 配下游配置 (hermes / mp2 PG / mdc JSON / 各 .env)
  5. launcher_key 留在 hub 服务器本地用, 不下发任何下游
  6. 最后跑 §30 的 5 件真测

详细 key 类型对比 + 下游分配清单 + 抓 key CLI 见 references/workbuddy2api-hub-deploy.md "launcher_key ≠ dashboard 发的 API key" 节。

32. 国际版 (intl) 免费模型 + 用 X-Realm 强制出口(实测 2026-10-20)

hub 有两套账单:同一个 model id 在两个 realm 价格不同。/v1/models?realm=intl 里 credits: x0.00 就是免费。

实测(2026-10-20):

model intl cn
deepseek-v4.1-flash x0.00 免费 x0.11
hy3 x0.00 x0.00
step-5-preview x0.00 x0.00
glm-5.3-flash x0.08 x0.08

源码口径:wb_proxy.free_models_by_realm() 只认 credits in ("x0.00","x0","0","0.00"),按 realm 分别算;wb_accounts.is_free_cap_model(realm, model) 是限额联动范围。查当下集合:

ssh fnos-root 'docker exec -i wb-hub python3 -' <<'PY'
import sys; sys.path.insert(0,"/app")
import wb_proxy
fm = wb_proxy.free_models_by_realm()
for r in ("intl","cn"): print(r, sorted(fm.get(r) or ()))
PY

首选:把该 Key 的 realm 绑成 intl(面板 → 设置 → API Key → 编辑 → 出口),服务端强制,客户端漏带头也不会掉回 cn。次选:X-Realm: intl 请求头(优先级 wb_proxy._request_realm():?realm= 显式 > key 绑定的 realm > X-Realm 头 > ?realm= query > 全局 active_realm——key 一旦绑了 realm,头就失效)。所以先看 key 有没有绑 realm(accounts/settings.json 的 api_keys[].realm 为空才轮到头生效)。

hermes 侧接法:

hermes config set providers.workbuddy-hub.extra_headers '{"X-Realm":"intl"}'

验证不能只看 200,要看哪个账号接的单:

ssh fnos-root 'docker logs wb-hub --since 2m 2>&1 | grep "chat done" | tail -5'
# acct=05e0ef5f / 6554baa5 = intl 账号;d64722ef 等 = cn

三个必踩的坑

  1. extra_headers 按 base_url 匹配(config_providers._entries_for_route 只比 route base_url,不看 provider 名)。同一个 base_url 不能挂两个 header 不同的 provider(想"一个走 cn 一个走 intl"这样配 = 两个都被打上 intl 头)。要么单 provider 全 intl,要么用 key 绑 realm 分两把 key(同一 base_url 无需 header,就不会互相污染)。
  2. cn 独占模型在 intl 出口下 400:exclusive_realm() 判定 CN_EXCLUSIVE = {deepseek-v4-pro, glm-5.1, glm-5v-turbo, kimi-k3-1, kimi-k2.7, minimax-m3, ...} + 前缀 minimax- / deepseek-v4-pro。实测 minimax-m3.1-flash、deepseek-v4-pro 走 intl 直接报「只在国内版提供」。intl 链里不能放这些,哪怕 /v1/models?realm=intl 里列了 minimax-m3.1-flash(列表有 ≠ 不跨域)。intl 可用:deepseek-v4.1-flash / hy3 / hy4-preview / glm-5.3(-flash) / glm-5.2 / kimi-k3 / kimi-k2.6 / kimi-k2.8-preview / step-5-preview / gpt-5.x / gpt-6.x / grok-4.7 / gemini-3.5-3.8-flash。
  3. 改完对在跑的 session 不生效:provider/header 是 agent init 时定死的,当前会话仍走老出口,新会话才走 intl。判断时别把"当前对话还在 cn 账号"当成配置没生效——用 hermes chat --provider ... -q 另起一发验。

附带:custom provider 的 fallback 被"空凭据池"挡死(2026-10-19 真实事故)

症状:429 后日志出现 Fallback skip: <provider>/<model> credential pool is exhausted (every entry in cooldown),但其实没有任何 cooldown。

根因:auth.json 的 credential_pool.<provider> 有一行 source: env:XXX_API_KEY,env 源条目不落盘 secret,每次 load 重新水合;水合走 _seed_from_env() → PROVIDER_REGISTRY.get(provider),自定义 provider 不在注册表里 → 直接 return,密钥永远补不上 → _available_entries 因 runtime_api_key 为空丢弃该条目 → has_available()=False 且 next_available_at()=None → _candidate_pool_exhausted() 判定"池耗尽"跳过整条 fallback。

修法:删掉这个不可水合的池行(密钥在 providers.X.api_key 里本就有):

hermes auth remove <provider> <id>     # 会顺带清 .env 里的变量并抑制重新播种

验证:_candidate_pool_exhausted(agent_stub, provider, model) 应变 False。别用 hermes auth status <custom_provider> 判断——它对自定义 provider 恒 logged out(§19)。

33. 看板设置巡检(登录 → 抓全量设置 → 按实际用量给优化)

用户问"哪些设置需要优化 / 你看看看板"时的完整流程、每个开关的判断依据、以及"面板积分列跨 realm 混算"这类读数据的坑,见 references/hub-dashboard-review.md。

三条最常被忽略的结论先记在这里:

  • Key 绑出口 > 客户端带头:5 个 Key 全"跟随面板切换"时,任何客户端漏发 X-Realm 就静默回到付费出口;面板上按 Key 绑定出口是服务端强制的。
  • 国际版"每日活跃"不要用主力那个免费模型:活跃打卡也吃"账号 × 模型"的 24h 免费窗口,和真实流量抢同一个窗口,撞 6004 后主力直接 429;把活跃模型换成另一个免费模型(hy3 / step-5-preview)。
  • Anthropic Messages 默认关:开了等于给 Claude Code/SDK 开一条丢 cache_control 的路(长会话积分飙升)。确认 logs.sqlite3 里 /v1/messages 命中为 0 再关掉。

34. hub 看板运维 API + 真浏览器登录路径(2026-10-20 实测)

34.1 不用点 UI,直接打面板 API(改设置/绑出口/开限额)

# 1. 登录拿 token(面板密码在 settings.json 里是 PBKDF2 摘要,明文要问爸爸/看密码库)
TOK=$(curl -s --noproxy '*' -X POST http://192.168.31.201:8788/panel/login \
  -H 'Content-Type: application/json' -d '{"password":"<panel_pwd>"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')

# 2. 写设置(合并语义:只传要改的字段,不传的保持原值)
curl -s --noproxy '*' -X POST http://192.168.31.201:8788/settings/save \
  -H "Authorization: Bearer $KEY" -H "X-Panel-Token: $TOK" -H 'Content-Type: application/json' -d '{...}'

/settings/save 常用字段(都实测可用):

字段 形状
api_keys 全量 list,每行 {id,name,realm,models,concurrency_limit,enabled,key:""}(key 空=保留原密钥;必须带 deleted_api_key_ids:[] 才是 upsert,否则是 replace-by-omission)
limits {reserve_credits:{global:200}, daily_credit_limit:{global:300}, daily_token_limit:{...}, model_daily_token_limit:{...}, expiring_window_days:{global:7}},子作用域 intl/cn 传 null=继承全局
daily_chat_model 国际版每日活跃用的模型 id
egress_backoff_enabled 出口级瞬时失败退避
enable_anthropic_messages 面板开关
pricing_enabled / pricing_refresh_minutes / quota_scan_models / update_check_enabled / local_web_tools / window_probe / remaining_priority_enabled 同名布尔/数组

34.2 Key 绑出口 > 客户端发 X-Realm 头

api_keys[].realm(intl/cn/空=跟随面板全局)是服务端强制的,_request_realm() 里 key 绑定的优先级高于 X-Realm 头。能绑就绑:客户端(hermes / mp2 / mdc)升级、换库、漏带头都不会偷偷回到付费出口。实测绑完 key 后,连已经在跑的 session(客户端还带着老 header)也立刻改走 intl,不用重启客户端。

注意:绑了 intl 的 key 调 cn 独占模型(deepseek-v4-pro、minimax-*、glm-5.1)会直接 400,所以按 key 实际调的模型绑:只跑 deepseek-v4.1-flash 的客户端(mp2 助手、mdc 翻译)完全可以绑 intl 白嫖。

34.3 WB_ENABLE_ANTHROPIC_MESSAGES 是 OR,不是 AND

_anthropic_messages_enabled() = 面板 enable_anthropic_messages 或 环境变量。只关面板不够,还要从 compose 删掉环境变量并 recreate 容器。验证:POST /v1/messages 应返回 400 Anthropic Messages is disabled by default。

34.4 重启容器会“变出”重复账号

accounts/ 里若残留旧格式 workbuddy-<uid>.json(5 个 3KB 小文件),启动时会被当成独立账号加载 → /health 的 accounts 从 7 变 12,等于同一账号在池里两份(重复计额度/调度)。

ssh fnos-root 'cd /vol1/1000/Docker/workbuddy2api-hub && mkdir -p legacy-accounts-archive && mv accounts/workbuddy-*.json legacy-accounts-archive/ && docker compose restart wb-hub'
curl -s --noproxy '*' http://192.168.31.201:8788/health   # accounts 应回到真实数量

每次 recreate/restart 后都要核对 /health 的 accounts 数。

34.5 飞牛上的真浏览器打开看板(LAN 被 mihomo 502)

fygo-browser 的 Chrome 启动带 --proxy-server=127.0.0.1:7890,打开 http://192.168.31.201:8788 会 502;而 hub 端口只绑在 LAN IP 上,127.0.0.1:8788 是 refused。但 Chrome 默认绕过 localhost——所以在 fnOS 上起一个 loopback 转发即可:

ssh fnos-root 'python3 /tmp/wbfwd.py'   # 127.0.0.1:8799 -> 192.168.31.201:8788
# 然后 Chrome 开 http://127.0.0.1:8799/?pwd=<面板密码>

CDP 侧(hermes 容器内):ssh -L 127.0.0.1:16025:127.0.0.1:16005 -N fnos-root 建隧道,用 websockets 连 ws://127.0.0.1:16025/devtools/page/<id>;/json/new 必须用 PUT,且 urllib 要 ProxyHandler({}) 关掉代理(否则 16025 被代理截胡超时)。

35. 用 platform_hints 让 hermes 自己识别"我在哪个端口"

加 config.yaml.platform_hints.<platform>.append 即可让 hermes 在 system prompt 追加"我在哪个端"的提示——新 session 启动时自动加载。用途:爸爸在 WeChat/TG/容器终端跟同一台 hermes 对话,agent 默认不知道自己跑在哪个端,配上 platform_hints 就知道了。

最小完整配置(用 hermes config set):

hermes config set platform_hints.telegram.append '

【当前会话平台:Telegram(TG)端】
你正在通过 Telegram 与爸爸对话。每条回复开头应隐含或显式确认这是 TG 端消息(如用 TG 友好的 markdown 格式、避免微信专有语法)。
其他端(微信/容器终端)的最近对话已通过 AGENTS.md 注入块同步给你,可参考但 TG 端回复不依赖对方。'

hermes config set platform_hints.weixin.append '

【当前会话平台:微信(WeChat)端】
你正在通过微信 iLink bot 与爸爸对话。回复请用微信友好的格式(无 <br> 等 HTML,用换行;markdown 表格会被截断,可用 bullet list 替代)。
其他端(TG/容器终端)的最近对话已通过 AGENTS.md 注入块同步给你,可参考但微信端回复不依赖对方。'

hermes config set platform_hints.cli.append '

【当前会话平台:飞牛 NAS hermes 容器终端(CLI)】
你正在 hermes 容器的命令行会话中与爸爸对话。回复可使用完整 markdown(含表格、代码块),无平台渲染限制;输出会显示在 docker exec / ssh 终端里。
其他端(TG/微信)的最近对话已通过 AGENTS.md 注入块同步给你,可参考但 CLI 端回复不依赖对方。'

三个关键事实:

  1. platform key 是 source 名(telegram/weixin/cli/cron/tui),不是显示名(WeChat/Telegram)——agent.platform 直接拿来匹配这个 key。判断方法:grep -rn 'platform = .*lower()' /opt/hermes/agent/ 看 _resolve_platform_hint 的来源(agent.platform 在 gateway 注入 session context 时设值,来源是 MessageEvent.platform)。
  2. appended 文本在 agent init 时读,已经在跑的 session 不会重新读——改了 .append 后,已经在跑的 agent 看不到,新 session 才会带上。判断"配了生效"必须看新发消息触发的新 session 的 system prompt,不是当前对话。
  3. append vs replace:默认(不传 replace)只 append;想完全替换默认 hint 用 replace: "..."。反例:用 replace 替代默认 hint → 默认 hint 里通常有平台相关的 render 提示(如 TG rich messages 提示),丢了之后客户端拿到 markdown 可能炸。

配合 cross-platform sync:platform_hints 是 system prompt 内的"我在哪"标识,AGENTS.md 注入块(hermes-cross-platform-sync skill + sync-digest.py cron)是"其他端聊什么"上下文。两个机制叠加才完整:agent 既知道自己当前端(platform_hints),又知道其他端最近说了啥(AGENTS.md 注入)。

判断当前端用什么 key:跑 hermes chat --provider <any> 触发一发,agent.log 看 conversation turn: ... platform=<key>,key 就是 platform_hints 应该用的名字。反例:想给"微信平台"配 hint 但 key 写成 wechat——_resolve_platform_hint 找不到 wechat,落回默认 hint(看不出来生效了)。

验证生效:

hermes config get platform_hints   # 看配置对
# 然后从对应端发一条消息触发**新 session**,agent 第一句回复里应该带"当前会话平台:X"

踩坑:.append 改完当前对话还是看不到——预期行为(不是 bug),新 session 才会注入。