改了什么: - 新增 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(客户端必须自己钉主会话) —— 形状抄错或漏掉落库那条腿,验出来的结论就是假的。落成脚本 + 文档,下次不必重推。
91 lines
4.2 KiB
Python
91 lines
4.2 KiB
Python
#!/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())
|