主题
Handoff — Hermes WeChat 绑定 + LLM 直连 (pick up here)
2026-05-18 22:30 GMT+8. /clear 之后从这里继续。
当前生产状态(hanliang730 实例)
| 项 | 值 |
|---|---|
| Container | hermes-cmp6vr0zq000bp601beipc3nr (running, 1 CPU / 1g RAM, dashboard 127.0.0.1:32803) |
| Profile dir (host) | /home/twilight/hermes-profiles/cmp6vr0zq000bp601beipc3nr/ |
| Gateway state | running |
| WeChat 绑定 | connected, account 68f8f99a630a@im.bot |
| Home channel | o9cq8060luFbQ74LnqJZOuUmwUKs@im.wechat |
| LLM 路由 | OpenRouter direct (key sk-or-v1-a8dc...3d81), bypasses NewAPI + CF |
| Last verified | 04:25:29 收到"hello now" → 9.6s 后回复"你好!有什么我可以帮你的吗?" |
| DB instance row | status=READY,weixinChannel=NULL(没回写,因为 backend code 还不知道 bridge 路径) |
关键改动 / 当前手工 hack 列表(生产 ECS 上)
| 位置 | 改动 | 是否回写到 repo |
|---|---|---|
hermes-cmp... 容器 | --cpus 1.0 --memory 1g(不是 spawn-profile 默认 0.25/256m) | ❌ 临时 |
| backend container | apk add openssl python3 手动跑过 | ❌ 临时 |
| Hermes image | docker tag nousresearch/hermes-agent:latest :0.13.0 | ❌ 临时 |
profile .env | 加 OPENAI_API_KEY/OPENAI_BASE_URL 指 OpenRouter | ❌ 临时 |
profile config.yaml | model.base_url 改 https://openrouter.ai/api/v1 | ❌ 临时 |
| profile dir | weixin-qr-bridge.py 拷到 /home/twilight/hermes-profiles/<id>/weixin-qr-bridge.py | ✅ source 在 scripts/admin/weixin-qr-bridge.py |
profile weixin/accounts/68f8f99a630a@im.bot.json | qr_login confirmed 自动写的 | ✅ 持久化 |
| HermesInstance DB row | status=PENDING_BIND(早晨手工 hot-fix)→ 后续 spawn 后变 PROVISIONING / READY | ❌ DB 状态 |
| HermesInstance.llmApiKey | 写了 NewAPI key (sk-J6It...IhMM),但当前 OpenRouter 直连不读这个字段 | ❌ |
已落档文档
| 文档 | 位置 | 内容 |
|---|---|---|
| 全流程架构 | docs/architecture/payment-flow.md | 目标 flow + 状态机 + Gap A/B/C + Phase 1-4 进度(Phase 1+2 ✅,Phase 3-4 待做) |
| 故障 root cause | docs/err/2026-05-18-hermes-wechat-binding-and-llm-routing.md | 11 个坑详解 + cross-cutting themes + 全局 fix list |
| 联调参数审计(早期) | docs/superpowers/plans/2026-05-17-integration-params-audit.md | NewAPI / 3-tier plan / 模型选择决策 — 部分作废(NewAPI 路径暂停) |
| Hermes activation 原 plan | docs/superpowers/plans/2026-05-17-hermes-activation-complete.md | 早期 plan,部分作废(iLink-first 设计被推翻) |
进行中 / 打开的 PR
- PR #97
feat/v0.4.3-hermes-activation— Phase B/C/E(NewAPI resilience + 3-tier plan + status machine)— 跟新流程冲突,多半放弃 status-machine 改动,保留 schema additions - PR #98
feat/local-dev-stack— 本地 compose + CI smoke + dry-run-migration — backend-smoke CI 已通过,等 merge
/clear 之后第一件事
读这两份文档(按顺序):
docs/architecture/payment-flow.md— 整体上下文 + 目标 flow + 进度docs/err/2026-05-18-hermes-wechat-binding-and-llm-routing.md— 今天踩的坑 + 全局 fix list
然后按下面优先级走 Phase 3。
Phase 3 待做(按依赖顺序)
A. 必须做(codify 当前手工 hack)
A1-A8 全部列在 err doc 的「Global fix list / A. 必须做」。优先级最高 是:
- [ ] A1 spawn-profile.sh path 修复(host vs container path)
- [ ] A3 backend Dockerfile 加
openssl python3 - [ ] A4 profile
.envtemplate 加OPENAI_API_KEY/OPENAI_BASE_URL - [ ] A5 provision-worker
TIER_PROFILES默认提到 1cpu/1g - [ ] A7 backend service-runtime 接 bridge(替换现有 iLink 直连)
B. backend Code 改动概要
ts
// backend/src/modules/service-runtime/service-runtime.service.ts
async getWeixinQr(subscriptionId: string) {
const instance = await this.getInstanceBySubId(subscriptionId);
const { stdout } = await execFileAsync('docker', [
'exec', `hermes-${instance.id}`,
'/opt/hermes/.venv/bin/python3',
'/opt/data/weixin-qr-bridge.py', 'start',
]);
return JSON.parse(stdout); // { status: 'wait', qrcode, scan_url }
}
async pollWeixinStatus(subscriptionId: string) {
const instance = await this.getInstanceBySubId(subscriptionId);
const { stdout } = await execFileAsync('docker', [
'exec', `hermes-${instance.id}`,
'/opt/hermes/.venv/bin/python3',
'/opt/data/weixin-qr-bridge.py', 'poll',
]);
const state = JSON.parse(stdout);
if (state.status === 'confirmed') {
// bridge 已经写 WEIXIN_TOKEN/ACCOUNT_ID 到 .env
// backend 这里需要 recreate container 让新 env 生效
await this.recreateContainer(instance.id);
await this.prisma.hermesInstance.update({
where: { id: instance.id },
data: { weixinChannel: state.account_id, status: 'READY' },
});
}
return state;
}C. spawn-profile.sh 改动概要
bash
# 添加 bridge 拷贝
cp "$REPO_ROOT/scripts/admin/weixin-qr-bridge.py" "$PROFILE_DIR/weixin-qr-bridge.py"
chmod +x "$PROFILE_DIR/weixin-qr-bridge.py"
chown 10000:10000 "$PROFILE_DIR/weixin-qr-bridge.py"
# 删除 WEIXIN_HOME_CHANNEL / WEIXIN_TOKEN 入参(bridge 自己写)
# .env 模板加(短期 OpenRouter direct):
# OPENAI_API_KEY=... # 从 provisioning 注入
# OPENAI_BASE_URL=https://openrouter.ai/api/v1
# docker run volume:传 HOST path,不是 backend-container path
# 当前 backend 容器内 PROFILE_BASE=/twilight/hermes-profiles
# 需要解析为 host /home/twilight/hermes-profiles
# 简单做法:spawn-profile.sh 接受 HOST_PROFILE_BASE 入参,docker -v 用它别忘了
- 当 NewAPI 路径恢复时(关 CF Bot Fight Mode 或 NewAPI 加 UA 白名单),回退
config.yaml+.env的base_url/ OPENAI_BASE_URL 回llm.fsagent.cc/v1,恢复 per-user quota 跟踪。 - err doc Cross-cutting Theme #2:把 spawn-profile.sh 当 backend image 的一部分对待,依赖在 Dockerfile 里装齐。
- PR #97 reconcile 之前,不要 push 任何 schema migration 上 ECS(migration 0002+0003 还没 apply)。
验证命令(拿来就用)
bash
# SSH ECS
ssh -o ProxyCommand="cloudflared access ssh --hostname ssh-ecs.fsagent.cc" root@ssh-ecs.fsagent.cc
# 看 hermes 容器状态
docker ps --filter name=hermes-cmp6 --format "{{.Names}}\t{{.Status}}"
# 看 gateway 实时状态
cat /home/twilight/hermes-profiles/cmp6vr0zq000bp601beipc3nr/gateway_state.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['gateway_state'], d['platforms']['weixin']['state'])"
# 看 agent activity
tail -20 /home/twilight/hermes-profiles/cmp6vr0zq000bp601beipc3nr/logs/agent.log
# 跑一次 bridge start(生成新 QR,仅当 force re-bind)
docker exec hermes-cmp6vr0zq000bp601beipc3nr /opt/hermes/.venv/bin/python3 /opt/data/weixin-qr-bridge.py start
# direct iLink getupdates(看 pending msg)
docker exec hermes-cmp6vr0zq000bp601beipc3nr /opt/hermes/.venv/bin/python3 -c "
import json, asyncio, sys
sys.path.insert(0, '/opt/hermes')
from gateway.platforms.weixin import _get_updates, _make_ssl_connector, _load_sync_buf
import aiohttp
async def main():
creds = json.load(open('/opt/data/weixin/accounts/68f8f99a630a@im.bot.json'))
sync_buf = _load_sync_buf('/opt/data', '68f8f99a630a@im.bot')
async with aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector()) as s:
r = await _get_updates(s, base_url=creds['base_url'], token=creds['token'], sync_buf=sync_buf, timeout_ms=5000)
print('msgs:', len(r.get('msgs',[])))
asyncio.run(main())
"