From 4c938c73306ca263b0ac2b62f4638ecdca4c4958 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 07:44:31 +0800 Subject: [PATCH] =?UTF-8?q?docs(devtools):=20=E5=81=87=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E8=A1=A5=E3=80=8C=E4=B8=BB=E5=8A=A8=E6=B6=88=E6=81=AF=E3=80=8D?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E9=85=8D=E6=96=B9=EF=BC=88proactive=5Finject?= =?UTF-8?q?.py=20+=20README=20=E5=9C=BA=E6=99=AF=E4=BA=94=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改了什么: - 新增 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(客户端必须自己钉主会话) —— 形状抄错或漏掉落库那条腿,验出来的结论就是假的。落成脚本 + 文档,下次不必重推。 --- devtools/fake-backend/README.md | 44 ++++++++++- devtools/fake-backend/proactive_inject.py | 90 +++++++++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 devtools/fake-backend/proactive_inject.py diff --git a/devtools/fake-backend/README.md b/devtools/fake-backend/README.md index 85d35b5..7509a7d 100644 --- a/devtools/fake-backend/README.md +++ b/devtools/fake-backend/README.md @@ -20,7 +20,7 @@ ## 1. 起服务(Windows + Git Bash) ```bash -cd /d/Project/Code/Uni/Cyrene-Android-FB/devtools/fake-backend +cd /d/Project/Code/Uni/Cyrene-For-Android/devtools/fake-backend python fake_backend.py --port 18080 ``` @@ -271,6 +271,39 @@ curl -s "$B/__control/requests?limit=20" > Git Bash 的坑:命令行里的中文会被转成本地代码页(GBK)再交给子进程,服务端按 UTF-8 解 ⇒ 400 `invalid json body`。 > 所以**带中文的请求体写进文件再用 `--data-binary @file`**,控制端点的参数用 URL 编码(`%E4%BD%A0%E5%A5%BD`)。 +### 场景五:主动消息(`proactive_message`)实时到达 + +「她主动说的话」在线上有两条路:有在线客户端时走 `proactive_message` 帧,走 IM 桥时走 `message_persisted{proactive:true}`。 +**帧壳不带 `sessionId`**(真后端 `gateway` 的 `wsEvent{Type, Data}` 逐字如此),所以客户端必须自己钉死主会话。 + +真后端**没有任何 HTTP 入口**能按需触发一次主动推送(`backthink` 只由心跳/事件决定,路由里只有一个 +`GET /api/v1/admin/backthink`),所以本机验收只能自己造帧 —— `proactive_inject.py` 按真后端的顺序 +(**先落库、再发帧**)把那一对动作做出来: + +```bash +python fake_backend.py --port 18090 & + +# 探针**必须带 `--send` 或 `--ping`** —— 不带时 `ws_probe.py:201` 只监听 `min(listen, 1.0)` 秒就收工, +# 连接随即关闭 ⇒ 之后广播的帧 `delivered=0`(本工具没有补发,白忙一场) +python ws_probe.py --port 18090 --send '{"type":"noop","data":{}}' --listen 8 & + +# 落一行 assistant 历史行 + 广播带**同一 id** 的 proactive_message(两条腿都在这一个脚本里) +python proactive_inject.py --port 18090 --text "验收:她主动说了一句" +``` + +探针那边应当原样收到(形状出处:`Cyrene_v2/internal/services/gateway/proactive.go:23-41`): + +```json +{"type":"proactive_message","data":{"content":"验收:她主动说了一句","platform":"web", + "messageId":"im-msg-000004","sender":"昔涟","id":"msg-000004","createdAt":1791243748593}} +``` + +`--without-id` 是**另一档**:帧上不带 `id`/`createdAt`,而且**连历史行都不落**(落了行就验不出这一档)—— +它等价于真后端「落库失败」。此时客户端只该有个**本地气泡**、**刷新后它就不该在了**, +但它**绝不能被静默丢掉**(「落库失败也不能让消息消失」正是这条的验收点)。 + +App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡;② 切到任一次级会话 —— **不**出现;③ 重进聊天页后**还在,且只有一条**。 + ## 8. 端点速查 `/api/v1/**`(一律包信封:成功 `{data}` / 失败 `{error:{code,message}}`,`ApiEnvelope.kt:8`): @@ -278,7 +311,7 @@ curl -s "$B/__control/requests?limit=20" | 端点 | 备注 | |---|---| | `POST /api/v1/auth/login` `/refresh` `/logout` | 回假 token(`role:"owner"`,`expiresAt` 是 **Unix 秒**) | -| `GET /api/v1/health` | 存活探针:回 `{"status":"ok"}`。**不鉴权、不做任何重活**(对齐真后端 `registerPublicRoutes`)。客户端 W41 起在「WS 未就绪」时先探它一次,通就回落 REST 发那条消息;缺了它,那条阶梯在本机就退化成「直接排队」 | +| `GET /api/v1/health` | 存活探针:回 `{"status":"ok"}`。**不鉴权、不做任何重活**(对齐真后端 `registerPublicRoutes`)。⚠️ 客户端**已经不消费它**(W45 删掉了「先探 `/health` 再回落 REST」那一整套,见 `SendFallback.kt:120`):现在 WS 未就绪就**直接**试 REST,只有网络类失败才进队列 —— 所以这个端点留着只是别的消费者在用,**缺了不影响本机任何验证** | | `GET /api/v1/sessions` | 排序照真契约:`pinned DESC, type='main' DESC, created_at ASC`;`unreadCount` 按已读游标算 | | `POST /api/v1/sessions` | 可绑 `personaId`(未知角色 → 400);支持**完全不带 body** | | `PATCH /api/v1/sessions/{id}` | 局部响应;`{}` → 400;`personaId` → 400;title trim 后空 → 400;>80 字 → 400 | @@ -339,6 +372,7 @@ curl -s "$B/__control/requests?limit=20" | `chat_duplicate` / `pending_full` 的可选键整键缺席 | `WsEvent.kt:291-336, 798-809` | | `interrupt{scope}` 取值与「只取消在跑回合」 | `WsOutbound.kt:59-78` + `InterruptScope.kt:27` | | `stream_chunk` + `stream_end` 回退形状 | `WsEvent.kt:205-228` + `ChatViewModel.kt:1451-1472` | +| `proactive_message` 载荷(`id`/`createdAt` 与落库行**同源**、帧壳**不带** `sessionId`) | **后端源码**:`Cyrene_v2/internal/services/gateway/proactive.go:23-41` + `proactive_persist.go:36`。这是本工具唯一一处**以真后端为准**而不是以客户端为准的形状 —— 因为它正是「后端加了新键、客户端旧版静默丢弃」的那条路 | ## 11. 已知不确定 / 没做 @@ -351,6 +385,12 @@ curl -s "$B/__control/requests?limit=20" - **不实现**:admin 面全部端点、`documents` / `upload`、`voice_input` 的落库、`mode_switch` 的语义、 `observability_*` 推送、`subagent_ask` / `task_output_query`。上行收到这些帧**只记录、不处置**。因此 App 里 管理员页面、附件上传这类路径在这个假后端上跑不通。 +- **主动消息只能靠 `proactive_message` 帧造**:本工具不模拟 `backthink`(真后端也没有 HTTP 入口), + 所以「她自己决定什么时候开口」这件**服务端**行为在这里验不到 —— 能验的是「帧到了客户端之后」那一段 + (见第 7 节场景五)。同理,本工具**不会**自动补发 `message_persisted`:真后端走 IM 桥的主动消息会额外 + 广播一条 `message_persisted{proactive:true}`,那条路在本机需要自己造帧。 +- **`ws_probe.py --listen N` 单独用只有 1 秒**(`ws_probe.py:201` 是 `min(listen, 1.0)`):要让它守着连接 + 等别人广播,必须带 `--send` 或 `--ping`,否则连接会先关掉、之后所有广播都是 `delivered=0`。 - **`message_persisted` 与流帧的先后顺序**是我按「正文流完再落库」安排的,没有对着真后端核过。 - **`--hold-replies` 的三个代价**(都刻意保留,因为改掉就不再是「只关那一件事」了): 1. `POST /api/v1/chat` 的回执形状**逐字不变**(仍 `{stream:true,queued:false,queueDepth:0,messageId,sessionId}`), diff --git a/devtools/fake-backend/proactive_inject.py b/devtools/fake-backend/proactive_inject.py new file mode 100644 index 0000000..be59f47 --- /dev/null +++ b/devtools/fake-backend/proactive_inject.py @@ -0,0 +1,90 @@ +#!/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())