Files
AskaEth 4c938c7330 docs(devtools): 假后端补「主动消息」验收配方(proactive_inject.py + README 场景五)
改了什么:
- 新增 devtools/fake-backend/proactive_inject.py:按真后端 SendProactive 的顺序
  (先落库、再发帧)造一条主动消息 —— 落一行 assistant 历史行,再广播带同一 id/createdAt 的
  proactive_message 帧。--without-id 是另一档(落库失败):不落行、帧上不带 id。
- README 新增「场景五」:完整命令序列 + 帧形状 + App 侧三条判据;端点速查/形状出处/已知不确定
  各补一条。
- 更正两处过时内容:§1 的工作目录还是旧 worktree 名 Cyrene-Android-FB;§8 说「客户端 W41 起先探
  /health」——W45 已把那整套删掉(SendFallback.kt:120),这个端点客户端不再消费。
- 记录踩到的坑:ws_probe.py --listen N 单独用只有 1 秒(ws_probe.py:201 是 min(listen, 1.0)),
  不带 --send/--ping 时连接会先关掉,之后所有广播都是 delivered=0。

为什么:
- 真后端没有任何 HTTP 入口能按需触发一次主动推送(backthink 只由心跳/事件决定,路由里只有一个
  GET /admin/backthink),所以「她主动说的话」在客户端这一段的验收只能自己造帧。真后端只把
  proactive_message 广播给在线连接、没有离线补发,帧壳又不带 sessionId(客户端必须自己钉主会话)
  —— 形状抄错或漏掉落库那条腿,验出来的结论就是假的。落成脚本 + 文档,下次不必重推。
2026-10-06 07:44:31 +08:00

91 lines
4.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""注入一条「主动消息」:先落一行 assistant 历史行,再广播带**同一 id** 的 `proactive_message` 帧。
为什么要成对做:真后端 `SendProactive`(`Cyrene_v2/internal/services/gateway/proactive.go:59`)的顺序是
**先落库、再发帧** —— 帧载荷里的 `id`/`createdAt` 就是那条落库行的(`proactive_persist.go:36`)。
客户端据此把实时气泡对齐到历史行:**实时可见 + 刷新后仍在 + 按 id 不会重复**。
只发帧不落行,就相当于真后端「落库失败」那一档(那时帧上**没有** `id`),验的是退化路径,不是正常路径 ——
`--without-id` 走的就是这一档,且它**连历史行都不造**(落了行就验不出「刷新后它不在了」)。
帧壳**不带 `sessionId`**(真后端 `wsEvent{Type, Data}` 逐字如此)—— 客户端必须自己钉死主会话,
否则会在每个次级会话里都冒一条。
python proactive_inject.py --port 18080 --text "验收:她主动说了一句"
python proactive_inject.py --port 18080 --without-id # 退化档:落库失败
判据(App 侧):
1. 主会话(`web`)里**实时**多一条助手气泡,正文就是 `--text`;
2. 切到任一次级会话 —— **不**出现这条;
3. 下拉/重进聊天页(触发 `GET .../messages`)—— 它**还在**,且**只有一个**(没有被合并成两条)。
"""
import argparse
import json
import sys
import urllib.parse
import urllib.request
def control(port, action, params=None, body=None):
url = "http://127.0.0.1:{}/__control/{}".format(port, action)
if params:
url += "?" + urllib.parse.urlencode(params)
data = json.dumps(body).encode("utf-8") if body is not None else None
request = urllib.request.Request(url, data=data, method="POST")
request.add_header("Content-Type", "application/json; charset=utf-8")
with urllib.request.urlopen(request, timeout=5) as response:
return json.loads(response.read().decode("utf-8"))
def main(argv=None):
parser = argparse.ArgumentParser(description="注入一条带落库行的主动消息")
parser.add_argument("--port", type=int, default=18080)
parser.add_argument("--session", default="web", help="落库行进哪个会话(真后端恒为主会话)")
parser.add_argument("--text", default="验收:她主动说了一句")
parser.add_argument("--sender", default="昔涟")
parser.add_argument("--without-id", action="store_true",
help="故意不带 id/createdAt —— 等价于真后端落库失败那一档")
args = parser.parse_args(argv)
if args.without_id:
data = {
"content": args.text,
"platform": "web",
"sender": args.sender,
"messageId": "im-orphan-1",
}
print("(落库失败档)**不**造历史行 —— 落了行就验不出「刷新后它不在了」")
else:
seeded = control(args.port, "messages", {
"session": args.session, "role": "assistant", "text": args.text,
})["data"]
data = {
"content": args.text,
"platform": "web",
"sender": args.sender,
"messageId": "im-{}".format(seeded["lastId"]),
"id": seeded["lastId"],
"createdAt": seeded["newestCreatedAt"],
}
print("落库行 id={} createdAt={}({})".format(
seeded["lastId"], seeded["newestCreatedAt"], args.session))
frame = {"type": "proactive_message", "data": data}
delivered = control(args.port, "frame", body=frame)["data"]["delivered"]
print("广播 proactive_message delivered={} —— {}".format(
delivered, json.dumps(frame, ensure_ascii=False)))
if not delivered:
print("⚠ delivered=0:这一刻没有客户端连着 WS(看 GET /__control/state 的 wsClients)。"
"广播没有补发,重跑前先确认连接在。")
return 0
if __name__ == "__main__":
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
sys.exit(main())