# Cyrene 假后端(`devtools/fake-backend/`) 一个**可编程的假后端**:用来把「需要特定服务端行为才验得到」的客户端行为变成可复现的测试。 纯 Python 3 标准库,零第三方依赖,不需要 Gradle、不需要模拟器里的真后端。 它**不是**产品的一部分,也**不是**后端行为的权威来源 —— 所有响应形状都是从**客户端代码**里读出来的 (出处逐条列在下面「形状出处」一节)。它照我们读出来的形状回话,不代表真后端真的这么回。 **它不做什么**(这条比它能做什么更重要): - **不校验任何鉴权**。`Authorization` / `X-API-Version` 照收不误、一律放行;收收到什么头都会记进 `out/http.jsonl`。 - **不落盘**成任何持久数据(只有 `out/*.jsonl` 的流水日志)。进程一停,消息全没了。 - **不调模型**。回复正文是你在控制端点里给的字符串按字符切片切出来的,没有任何生成。 - **不实现契约的全集**。只实现「启动到渲染」要用到的那一段(见端点表),其余一律 404。 - **不要拿它当后端行为的事实来源**。要判断真后端怎么回,去看 `Cyrene_v2/Docs/api-client.md` 与真后端本身; 这个工具的价值是「造场景 + 记录客户端发了什么」,不是「回答后端是什么」。 --- ## 1. 起服务(Windows + Git Bash) ```bash cd /d/Project/Code/Uni/Cyrene-For-Android/devtools/fake-backend python fake_backend.py --port 18080 ``` 启动后会打印监听地址、回放条数、回复形状、记录目录,然后原地等连接(Ctrl-C 退出)。 > 端口随便挑,**别用 10942** —— 那是真后端的端口,占用或干扰它会打断另一个会话的联调。 常用开关: | 开关 | 默认 | 作用 | |---|---|---| | `--port N` | `18080` | 监听端口 | | `--host H` | `127.0.0.1` | 监听地址 | | `--history-replay N` | `50` | WS 握手 `history` 帧**只**回放最近 N 条(比库里的条数小就是那个洞) | | `--stream-chunk` | 关 | 回复走旧形状 `stream_chunk` + `stream_end`(验客户端的回退分支) | | `--auto-reply` | 关 | 收到上行 `chat` 帧后自动回一轮(默认关,保持确定性) | | `--interrupt-ack` | 关 | 收到上行 `interrupt` 帧后回 `session_interrupted{mode:"idle",dropped:0}` | | `--hold-replies` | 关 | 上行消息**只记录、不落库**:收到 `chat` / `POST /api/v1/chat` 后不广播 `message_persisted`,由 `POST /__control/persist` 显式补落库(见第 6 节) | | `--out-dir DIR` | `<脚本目录>/out` | 记录目录 | ## 2. 客户端怎么指过来 ```bash adb reverse tcp:18080 tcp:18080 ``` 然后在 App 的登录页(「服务器地址」一栏,`LoginViewModel.kt:57`)填: ``` http://127.0.0.1:18080 ``` 登录随便填(用户名 / 密码都不校验)。`adb reverse` 把模拟器内的 `127.0.0.1:18080` 转到 PC 上的假后端, 所以 REST 与 `ws://…/ws/chat?token=…` 走的是同一个地址,App 侧不用做任何特殊配置。 想从干净状态重来:设置里退出登录 / 清 App 数据,再打一次 `adb reverse`(它只跟着设备会话活)。 ## 3. 场景一:`after` 正向补洞 **要验什么**:WS 重连只回放最近 50 条,断线期间漏掉的那一段由 `GET /messages?after=` 补回来 (客户端 `ChatViewModel.catchUp` → `MessageRepository.loadAfter`,契约 §1 行 6)。 用真后端要灌 51 条消息、每条跑一次模型回合;这里灌 120 条是**免费且瞬时**的。 ```bash # 1) 库里灌 120 条(不调模型,直接进内存) curl -s -X POST 'http://127.0.0.1:18080/__control/messages?session=web&count=120' # 2) 看握手到底回放了什么:只剩最近 50 条(msg-000071..msg-000120),前 70 条是洞 python ws_probe.py --port 18080 --listen 1 # 3) 断线前「本端最后看到的那条」——它才是客户端 gapCursor 的来源 curl -s 'http://127.0.0.1:18080/__control/message?session=web&index=20' # 4) 用它的 createdAt 作 after 游标补洞:第 1 页给回来的正好是那段洞 curl -s 'http://127.0.0.1:18080/api/v1/sessions/web/messages?limit=50&after=<上一步的 createdAt>' # 5) 用「本页最后一条的 createdAt」继续推进,直到返回空数组(客户端就是这么推进的) ``` **在 App 上跑同一条路**(真正的端到端): ```bash # App 已经连着假后端时,把它踢下线;客户端会重连,重连触发 catchUp curl -s -X POST 'http://127.0.0.1:18080/__control/ws-close?code=1001' # 客户端到底发了哪些 after 请求?看请求流水: grep -o '"after=[0-9]*"' devtools/fake-backend/out/http.jsonl ``` 注意客户端只在「曾经 Connected 过、然后断开、再连上」时才做这次正向补拉 (`ChatViewModel.kt:634-646`),冷启动走的是首屏 REST,不动 `after`。 ## 4. 场景二:打断帧(`interrupt`) **要验什么**:设置档位 = 「打断」且会话**正忙**时,客户端在发消息之前会先发 `interrupt{scope:"current"}`(`InterruptScope.kt:27`、`ChatViewModel.kt:1058-1063`)。 真后端不给你看上行帧,这里**看得到**,而且这是唯一的第一现场。 ```bash # 1) 起服务(回执可选:开了之后打断会立刻回到 idle,方便看界面状态) python fake_backend.py --port 18080 --interrupt-ack # 2) 让主会话忙起来 —— 客户端收到 session_busy 才会认为「正忙」 curl -s -X POST 'http://127.0.0.1:18080/__control/busy?session=web&mode=thinking' # 3) App 里:设置 → 打断档;然后在聊天页再发一条消息 # 4) 证据(每条上行帧一行,含时间戳) tail -n 2 out/upstream.jsonl # 也可以用一个不依赖 App 的等价动作,证明记录链路本身是通的: python ws_probe.py --port 18080 --listen 2 \ --send '{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}' ``` ## 5. 场景三:`stream_chunk` 回退路径 **要验什么**:契约 §3.1 的「非流式回退路径」——整段正文只在 `stream_chunk` + `stream_end` 里, 没有 `message.start/delta/end`。真后端可能永远不走那条路,这里可以**指定**走。 ```bash # 1) 起服务,指定回退形状 python fake_backend.py --port 18080 --stream-chunk # 2) 触发一轮回复(不需要在 App 里打字;text 用 URL 编码传中文) curl -s -X POST 'http://127.0.0.1:18080/__control/reply?session=web&text=hello&chunks=3' # 3) 想在连接上原样看到这几帧: python ws_probe.py --port 18080 --listen 4 ``` 想临时切回结构化形状,不改启动参数也行:`…/__control/reply?session=web&text=hi&frame=structured`。 ## 6. 场景四:忙时被拒 —— 让「待发气泡还挂着」可复现(`--hold-replies`) **要验什么**:客户端手里**还挂着**一条没发出去的气泡时,收到「队列满 / 忙时被拒」会怎样。 判据是客户端的纯函数 `queueFullOutcome`(`ChatViewModel.kt:240`),三档: - 本会话待发 **0** 条 ⇒ 刻意**什么都不说**(没有气泡会挂住,不能凭空弹提示); - 恰好 **1** 条 ⇒ **回滚**:气泡摘掉、正文 / 图片 / 附件退回输入栏,另加一条横幅; - **≥2** 条 ⇒ **不猜**:一个气泡都不动,横幅只说「可能没发出去」(`QUEUE_FULL_UNKNOWN_MESSAGE`)。 **为什么需要这个开关**:默认行为下,客户端一发上行 `chat`(或 `POST /api/v1/chat`),假后端**立刻** 把那条用户消息落库并广播 `message_persisted`;客户端拿 `clientMsgId` 一比就把乐观气泡收走 (`consumePendingFor`,`ChatViewModel.kt:116`)。**一个往返之后待发列表就空了** —— 于是「服务端把这条 拒掉」这类场景永远凑不出来:手里根本没有气泡可回滚,判定只会落到「0 条 ⇒ 静默」那一档。 真后端忙时不会立刻落库(先排队或先拒绝),所以这是假后端的**建模缺陷**,这个开关就是修它。 **它做什么**:`--hold-replies` 打开后,收到上行消息**只记录、不落库、不广播** `message_persisted`: - 上行帧照常进 `out/upstream.jsonl`,`/__control/state` 的 `wsUpstreamByType` 照常计数(判据来源不变); - **回复路径不受影响**:`--stream-chunk` / `--auto-reply` / `/__control/reply` 自己的 `persist=` 该怎么走还怎么走 —— 那是另一个开关,关的是「上行那条的落库」这一件事; - 那条消息挂进 `state.heldMessages`(带 `holdId` / 先占的 `messageId` / `clientMsgId` / `heldAt`), 由 `POST /__control/persist` **显式**补上落库那一步。 于是场景可以分两段做:「先把气泡挂住」→「再落库,或者干脆发一帧拒绝」。运行期也能开关: `POST /__control/hold-replies?on=1|0`(等价于启动参数,改完立刻生效)。 ```bash B=http://127.0.0.1:18099 # 1) 起服务(换个端口,方便和默认那一档并排) python fake_backend.py --port 18099 --hold-replies # 2) 发一条 chat:它会被挂住 —— 「挂住」的判据是 2 秒里一条回帧都没有 python ws_probe.py --port 18099 --listen 2 \ --send '{"type":"chat","data":{"sessionId":"web","clientMsgId":"local-1","text":"这条先挂着"}}' curl -s "$B/__control/state" # heldMessages 里有它;wsUpstreamByType.chat=1 grep -c message_persisted out/downstream.jsonl # 0:一条都没广播 # 3) 显式补落库 —— 这时才广播 message_persisted # 先在一个终端起探针(它监听 5 秒): python ws_probe.py --port 18099 --ping --listen 5 # 再在另一个终端补落库(回执里的 delivered 应当 >= 1): curl -s -X POST "$B/__control/persist?id=local-1" # 4) 忙时被拒:再发一条(local-2)挂着,然后注入 pending_full —— 这是「1 条待发」那一档 # 还是分两个终端:探针先发 chat 再监听 6 秒,把那段时间留给另一个终端注入 python ws_probe.py --port 18099 --listen 6 \ --send '{"type":"chat","data":{"sessionId":"web","clientMsgId":"local-2","text":"这条会被拒"}}' # 另一个终端注入(探针还在监听时执行,回执里的 delivered 应当是 1): curl -s -X POST "$B/__control/pending-full?session=web&mode=thinking&max=5&clientMsgId=local-2" # 探针那边就能看到那帧原样到达: # <<< frame 1 [after-send] {"type":"pending_full", … # 客户端期望:气泡回滚、正文回到输入栏、横幅是 QUEUE_FULL_MESSAGE # 「她正忙且队列已满,这条没发出去,请稍后手动重试」(ChatViewModel.kt:138) ``` **在 App 上跑同一条路**:带 `--hold-replies` 起服务、客户端指过来,在聊天页发一条,然后 `curl -s -X POST "$B/__control/pending-full?session=web&mode=thinking&max=5"`(**不带** `clientMsgId`, 走的就是「1 条待发」那一档:本会话只有这一条在飞,判定无歧义)。想让帧**精确**指向那条气泡,就把键 填成客户端铸的 `localId` —— 它就在 `out/upstream.jsonl` 那条 chat 帧上(`data.clientMsgId`)。 帧上带不带那两个可选键,客户端的处置完全不同(与 `queueFullOutcome` 的判定顺序一一对应): | 帧上的键 | 客户端看到的效果 | |---|---| | `&clientMsgId=<那条 localId>`,**不带** `messageId` | 命中那条气泡 ⇒ **回滚 + 横幅**(本场景要验的就是它) | | `&clientMsgId=<那条 localId>&messageId=m-1` | **收编**:只摘掉气泡,正文**不**退回输入栏(「其实早就进去了」) | | 不带 `clientMsgId`,本会话 1 条待发 | 就是它 ⇒ 回滚 + 横幅 | | 不带 `clientMsgId`,本会话 ≥2 条待发 | **不猜**:一个气泡都不动,横幅只说「可能没发出去」 | | 不带 `clientMsgId`,本会话 0 条待发 | 什么都不说(刻意静默) | 定向那一帧同理,与广播帧是**同一个 code、同一句文案**: ```bash curl -s -X POST "$B/__control/error?session=web&code=chat_busy&clientMsgId=local-2" ``` 同一件事被两条帧各报一次(广播 `pending_full` + 定向 `error{chat_busy}`)时,第二次到达气泡早已摘走, 客户端不会重复归还(`QueueFullOutcomeTest` 钉住了这条)。 ## 7. 其它可编排场景(都试过) ```bash B=http://127.0.0.1:18080 # 会话在服务端没了 → 404 session_not_found(客户端据此置 sessionGone,ChatViewModel.kt:1808) curl -s -X POST "$B/__control/forget?session=web:5eed0000000000000000000000000001" curl -s "$B/api/v1/sessions/web:5eed0000000000000000000000000001/messages?limit=1" # 队列满:广播 pending_full(可选带 clientMsgId / messageId,两个键都能缺席) # 忙时被拒的完整打法(先让气泡挂着再拒)见第 6 节 curl -s -X POST "$B/__control/pending-full?session=web&mode=thinking&max=5&clientMsgId=local-zz" # 定向错误帧(与 REST 的 409 chat_busy 同码) curl -s -X POST "$B/__control/error?session=web&code=chat_busy&clientMsgId=local-zz" # 上行落库挂起 / 恢复,以及显式补落库(第 6 节那两个端点) curl -s -X POST "$B/__control/hold-replies?on=1" curl -s -X POST "$B/__control/hold-replies?on=0" curl -s -X POST "$B/__control/persist?session=web&id=local-zz" # 忙碌 / 空闲 / 已打断 curl -s -X POST "$B/__control/busy?session=web&mode=executing" curl -s -X POST "$B/__control/idle?session=web&mode=idle" curl -s -X POST "$B/__control/interrupted?session=web&mode=idle&dropped=0" # 任意下行帧(整帧原样广播,形状不对时客户端会安全忽略 —— 正好用来验「坏帧不崩」) curl -s -X POST "$B/__control/frame?json=%7B%22type%22%3A%22session_idle%22%2C%22sessionId%22%3A%22web%22%2C%22data%22%3A%7B%22mode%22%3A%22idle%22%7D%7D" # 编辑 / 删除单条消息(REST 回执 + 广播帧都会来) curl -s -X PATCH "$B/api/v1/messages/msg-000001" -H 'Content-Type: application/json' -d '{"content":"edited"}' curl -s -X DELETE "$B/api/v1/messages/msg-000002" # 已读游标(推进后 unreadCount 归零,并广播 session_read) curl -s -X POST "$B/api/v1/sessions/web/read" -H 'Content-Type: application/json' -d '{}' # 会话列表 / 改名 / 置顶 / 新建 / 清空 / 复位 curl -s "$B/api/v1/sessions" curl -s -X PATCH "$B/api/v1/sessions/web" -H 'Content-Type: application/json' --data-binary @body.json curl -s -X POST "$B/__control/create?session=web:mine&type=sub&pinned=1" curl -s -X POST "$B/__control/clear?session=web" curl -s -X POST "$B/__control/reset" # 会话/消息/上行帧记录回到种子状态(out/*.jsonl 不动) # 看状态 / 看上行帧 / 看 HTTP 请求流水 curl -s "$B/__control/state" curl -s "$B/__control/upstream?limit=20" curl -s "$B/__control/requests?limit=20" ``` > 控制端点响应里的 `delivered` = **这一刻**连着 WS 的客户端数。它是**广播**(没有离线队列、没有补发), > 所以「App 还没连上 / 已经断开」时是 `0` 而帧不会留到下次。想让 App 收到,先确认 `GET /__control/state` > 里的 `wsClients` 不为 0。 > 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. 端点速查 ### 8.1 语音夹具(接收侧:`voice` 键 + `GET /api/v1/media/{name}`) **它解决什么问题**:生产库里**一条带 `voice` 的真消息都没有**(拉 200 条 0 命中),而语音的 `voice` 键**只在四个 REST 读取面**填充(契约 v0.79 §4.4 覆盖率)—— ⇒ 想验「语音气泡 + 播放」,必须由服务端 造一条**带 `voice` 键的历史消息**。 `POST /__control/messages` 多两个参数(其余不变): | 参数 | 取值 | 说明 | |---|---|---| | `voice` | `transcribed` / `empty` / `failed` / `nocopy` / `none`(默认) | 见下表 | | `duration` | 毫秒(默认 `2000`);`0` | `0` ⇒ **不打 `durationMs` 键**(验「缺席就不显示时长」那一档) | | `voice=` | `source` / `status` | 音频副本 | 设备上该看到什么 | |---|---|---|---| | `transcribed` | `asr` / `transcribed` | 有 | 语音气泡 + 播放键 + 时长;正文 = 你给的 `text` | | `empty` | `asr` / `empty` | 有 | 播放键照在;正文为空 ⇒ 多一句「没有识别出文字」 | | `failed` | `asr` / `failed` | 有 | 播放键照在;正文由你用 `text=` 给(假后端**不**替你编服务端那句话) | | `nocopy` | `platform` / `transcribed` | **无** | 只有「语音 · 时长」,**没有播放控件**(微信 `weixin-ilink` 那条路) | 服务端那一条:`GET /api/v1/media/{name}` 只认夹具名(`fakevoice0000000000000000000000.wav`), 回的是一段**真的 2 秒 440 Hz WAV**(启动时用 `wave` 现生成、16-bit PCM 单声道 8 kHz,不落盘), 其余名字一律 `404 media_not_found`。 ```bash B=http://127.0.0.1:18080 # 1) 灌一条有副本的语音(正文是转写文本) curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&text=%E6%98%8E%E5%A4%A9%E5%87%A0%E7%82%B9%E5%87%BA%E5%8F%91" # 2) 确认库里那条真的带 voice 键(形状 = 契约 §4.4) curl -s "$B/api/v1/sessions/web/messages?limit=1" # 3) 副本字节本身(浏览器/播放器都能验:2 秒 440 Hz) curl -s -o voice.wav "$B/api/v1/media/fakevoice0000000000000000000000.wav" # 4) 另外三档(各灌一条,屏幕上应看得出四档的差别) curl -s -X POST "$B/__control/messages?session=web&count=1&voice=empty" curl -s -X POST "$B/__control/messages?session=web&count=1&voice=failed&text=%E8%BF%99%E6%AE%B5%E8%AF%AD%E9%9F%B3%E6%B2%A1%E8%83%BD%E8%BD%AC%E6%88%90%E6%96%87%E5%AD%97" curl -s -X POST "$B/__control/messages?session=web&count=1&voice=nocopy" curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&duration=0" # 不显示时长 ``` **在 App 上验**(App 已按第 2 节指向本工具):灌完之后**重进聊天页**(或用 `POST /__control/ws-close` 踢一次重连触发补拉),那几条语音就会以语音气泡出现。 > ⚠️ 本工具的 `history` 握手回放**会把 `voice` 一起回放**(它直接回内存里那份消息对象), > 而**真后端不会**(合同 §4.4:`history` 回放不带 `voice`)。所以在这里「重进页面就看到语音气泡」 > 比真后端**更容易**;真后端上「实时到达的语音先按普通文本显示、等下一次 REST 拉取才升级」那一段, > 本工具验不到(它也不会复现「回放把已升级的气泡打回文本」那个反向效果)。 ### 8.2 图片夹具(灌一条带图的历史消息:`image_file`) **它解决什么问题**:要验「点开一张图 → 全屏预览」,手里就得有一条**带 `image` 键**的历史消息; 而预览里那些从**字节**现读的东西(分辨率、EXIF 拍摄日期、格式)必须来自**原样的字节**。 相册选择器(`PickVisualMedia`)会把选中项**转码**一遍,转码后的副本**没有 EXIF**(实测:一张带 `DateTimeOriginal` 的 41 KB JPEG,经它拿到手变成 23 KB 且不含 APP1)——用它验不了拍摄日期这一项。 本工具不代取外链、也不起静态文件服务,所以给的是 `data:` URI:客户端 (`ImageSource.Inline` → `ImageDataUri.bytes`)解出来的就是文件**逐字节相同**的那份内容。 `POST /__control/messages` 多一个参数(其余不变): | 参数 | 取值 | 说明 | |---|---|---| | `image_file` | 本地图片路径,扩展名限 `.jpg` / `.jpeg` / `.png` / `.gif` / `.webp` / `.bmp` | 读成 base64 `data:` URI 打进这条消息的 `image` 键。扩展名不在白名单 ⇒ `400`;文件为空 ⇒ `400`(**不**退回 `application/octet-stream`:错的类型会让场景看起来和实际不是一回事) | ```bash B=http://127.0.0.1:18080 # 灌一条带图的消息(role=user 才长得像「自己发的一张图」) curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=image%20probe&image_file=D:/tmp/w70/with_exif.jpg" ``` 灌完之后**重进聊天页**,那条消息就是一个图片气泡;点它即进 `ImagePreviewDialog`。 > ⚠️ `data:` 只适合**小图**:整张图的 base64 会随这条消息进每一次历史响应(本工具没有缩略图链路)。 > 40 KB 的图没问题,别拿 20 MB 的来灌。 > > ⚠️ 若 App 本机已经有一份**本地历史**(此前连过真后端),新灌的消息可能与它并存 / 不被立刻看到: > 要让「服务端历史」成为唯一真相,得清 App 数据重登(`adb shell pm clear top.yeij.cyrene`)—— > 这一步会连登录态一起清掉,动手前先想清楚。 ### 8.3 纯附件 / 无正文消息(`documents=` + `text=` 留空) **它解决什么问题**:**纯附件消息**(`documents` 非空、`content` 为空)这条形状以前**灌不出来** —— 本工具不实现 upload / 摄取链路(§11),`PATCH /api/v1/messages/{id}` 又要求 `content` 非空。 于是「没有正文的消息」这一类(纯附件 / 纯图片 / 没转出字的语音)在本机一条都造不出, W72 修的那条「气泡外侧的时间」也就无从验收。 两个参数一起用就能造出来: | 参数 | 取值 | 说明 | |---|---|---| | `text` | **空串**(`text=`) | 与「不带 `text`」是**两件事**:不带 ⇒ 沿用默认正文 `seed #N`;`text=` ⇒ 这条 `content` 就是 `""`。带非空值时照旧(支持 `{n}` 占位) | | `documents` | `extracted` / `listed` / `unreadable` / `none`(默认) | 往这条消息的 `documents` 键打**一枚**芯片。三档见下(`none` = 不打这个键,后端本来就是空即省略) | | `documents=` | `status` | 设备上那枚芯片(文案出处 `DocumentChips.statusLabel`) | |---|---|---| | `extracted` | `extracted` | 常规色 · `TXT · 12 B · 10 字 · 已读入` | | `listed` | `listed` | 常规色 · `… · 只看得到清单`(正文没进上下文,**不是**读不到) | | `unreadable` | `unreadable` | 灰态 · `… · 读不到`;`reason` 那句原话留给附件预览弹层 | ```bash B=http://127.0.0.1:18080 # 1) 纯附件消息:content 为空 + 一枚附件芯片 curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&documents=extracted" # 2) 纯图片消息:同一个招牌的另一半(content 为空,只有 image 键) curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&image_file=D:/tmp/w70/with_exif.jpg" # 3) 没转出字的语音(正文为空、只有语音形态) curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&voice=empty" # 4) 确认那条真的没有正文、也没被塞一个默认正文 curl -s "$B/api/v1/sessions/web/messages?limit=1" # 5) 对照:真的空气泡(三样都没有)——它走的是 ContentlessNote 那条路,不是本夹具的重点 curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&documents=none" ``` 灌完之后**重进聊天页**(或 `POST /__control/ws-close` 踢一次重连触发补拉)就能看到。 `documents` 与 `voice` 不同:真后端**也会**在握手 `history` 回放与 `message_persisted` 里带上它 (同一个 DTO,`MessageDto.kt:13` 那一段),所以这里回放出来没有「比真后端更容易」的问题。 > ⚠️ 这两个参数**只是往消息上打那两个键**,不是摄取链路:文件没有真的被读、正文没有真的进上下文, > 所以附件预览弹层里「服务端读到的那段正文」在本机永远是空的。 `/api/v1/**`(一律包信封:成功 `{data}` / 失败 `{error:{code,message}}`,`ApiEnvelope.kt:8`): | 端点 | 备注 | |---|---| | `POST /api/v1/auth/login` `/refresh` `/logout` | 回假 token(`role:"owner"`,`expiresAt` 是 **Unix 秒**) | | `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 | | `DELETE /api/v1/sessions/{id}` | `web` → 403 | | `GET /api/v1/sessions/{id}/messages` | 支持 `limit`(默认 50 / 上限 200)/ `before`(倒序)/ `after`(**升序**)/ `q` / `platform`;`after×before`、`after×q` → 400 | | `POST /api/v1/sessions/{id}/read` | 游标只前进;成功后广播 `session_read` | | `GET /api/v1/meta/enums` | 载荷逐字取自客户端仓库的线上夹具 | | `GET /api/v1/personas` | 用户侧角色目录(不含 admin 专属 `thinking`) | | `PATCH` / `DELETE /api/v1/messages/{id}` | 编辑 / 删除单条,并广播 `message_edited` / `message_deleted` | | `POST /api/v1/chat` | **额外**实现的 REST 写路径(长文 / 图片 / 附件走这条),回 202 + `ChatAckDto` 形状 | | `GET /api/v1/media/{name}` | 语音副本播放端点(契约 §1.2 **U19**):只认夹具名,回 2 秒 WAV;其余 `404 media_not_found`。⚠️ 真后端要 Bearer `owner\|admin`,本工具**不校验鉴权** ⇒ 这里验不到鉴权那一半(见 8.1) | | `GET /ws/chat?token=…` | 手写 WebSocket;握手后立刻发 `history`(回放条数可配) | `/__control/**`(同样包信封,但载荷是本工具自己的状态,不是产品契约): | 端点 | 作用 | |---|---| | `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=&voice=&duration=&image_file=&documents=` | 免费灌 N 条(`text` 支持 `{n}` 占位;**`text=` 空串 = 这条没有正文**);`voice=` 灌一条**语音消息**(见 8.1);`image_file=` 灌一条**带图的消息**(见 8.2);`documents=` 灌一条**带附件的消息**(见 8.3) | | `GET /__control/message?session=&index=` | 取第 n 条(1 基),脚本里用来**精确**拿游标 | | `POST /__control/busy` / `idle` / `interrupted` | 下发 `session_busy` / `session_idle` / `session_interrupted` | | `POST /__control/reply?session=&text=&chunks=&frame=&persist=` | 下发一轮助手回复(`frame=default\|chunk\|structured`) | | `POST /__control/pending-full` / `error` / `frame` | 队列满、定向错误帧、任意帧 | | `POST /__control/hold-replies?on=` | 运行期开关:上行消息要不要挂起(等价于 `--hold-replies`) | | `POST /__control/persist?session=&id=&index=` | 把挂起的上行消息**显式**落库并广播 `message_persisted`;不给选择器就按 FIFO 落最旧那条 | | `POST /__control/ws-close?code=&reason=` | 踢掉所有客户端 WS(用来触发客户端的重连 / 补洞路径) | | `POST /__control/forget` / `create` / `clear` / `reset` | 会话级造场景 | | `GET /__control/state` / `upstream` / `requests` | 当前状态、上行帧、HTTP 请求流水 | ## 9. 记录文件(`out/`,每次启动追加,删掉即清空) - `out/upstream.jsonl` —— **每条上行帧一行**:`{ts, epochMs, client, type, sessionId, raw, frame}`。 `raw` 是客户端发来的**原样文本**。「客户端到底发没发那一帧」以这里为准。 - `out/downstream.jsonl` —— 每条下发帧:`{ts, to, origin, raw}`。`origin` 会写清是 `history`、`control:reply` 还是某次广播。 - `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers, body}`。 `Authorization` 只留 sha256 指纹、WS 的 `?token=` 同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。 `body` 是**请求体的摘要**,不是原文:结构照留(键、布尔、数字、数组长度),超过 200 字符的字符串换成 `""`,非 JSON 的体记成 `{bytes, note}`。这样「发了几条、每条带不带图、有没有 `parallel`」 读得出来,而图片的 base64 不会把一行日志撑到几 MB。`body` 只在有请求体时出现。 挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的 `heldMessages`。`out/*.jsonl` 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。 ## 10. 形状出处(它照谁回话) | 实现 | 依据 | |---|---| | 信封 `{data}` / `{error:{code,message}}` | `app/.../data/api/ApiEnvelope.kt:8,18` | | 鉴权头 `X-API-Version: 1` + `Authorization: Bearer …` | `app/.../data/api/ApiClientFactory.kt:115-118`(**只记录,不校验**) | | WS 地址 `GET /ws/chat?token=` | `app/.../data/ws/ChatSocket.kt:287-295` | | `LoginResponse`(`expiresAt` 是 Unix **秒**、`refreshToken` 可缺) | `app/.../data/api/dto/LoginDto.kt:12-19` | | `SessionSummaryDto`(`title` / `lastMessage` / `personaId` 可整键缺席) | `app/.../data/api/dto/SessionDto.kt:16-50` | | 会话排序 `pinned DESC, main DESC, created_at ASC` | `SessionDto.kt:8-9` + 线上夹具 `SessionDtoRealPayloadTest.kt:36-38` | | `HistoryMessageDto` | `app/.../data/api/dto/MessageDto.kt:13-46` | | `voice` 键(7 个键名 / 三态 / 四者同生共死)与 `GET /api/v1/media/{name}` | **契约**:`Cyrene_v2/Docs/api-client.md` §4.4「`voice` 语义」+ §1.2 **U19**(v0.79 已实现)。本工具按它造夹具,**不代表**真后端的逐个字节 | | `after` 升序 / `before` 倒序 / `q` 与 `after` 互斥 | `app/.../data/api/SessionsApi.kt:71-101` + `MessageRepository.kt:79-101` | | limit 默认 50 / 上限 200 | `app/.../data/repository/MessageRepository.kt:149-150` | | `EnumTableDto` 的 6/5/9 张表与 `unknown:"display-raw"` | 线上夹具 `MetaDtoRealPayloadTest.kt:24`(逐字) | | `UserPersonaDto`(用户侧不含 `thinking`) | `PersonaDto.kt:16-26` + `PersonaDtoDecodingTest.kt:32` | | `404 {code:"session_not_found"}` | `ChatViewModel.kt:1802-1808` | | `session_read` 载荷只有 `readAt` | `WsEvent.kt:338-356` | | `message_edited` 的 `data` **就是**消息本体(没有 `message` 包一层) | `WsEvent.kt:676-680` | | `message_persisted` / `channel_persisted` 的 `data:{message}` | `WsEvent.kt:671-674` | | `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. 已知不确定 / 没做 - **`history` 帧壳**:我发了 `{type, sessionId:"web", seq, data:{messages}}`。契约说 v0.36 起每个结构化流帧的帧壳 都带 `sessionId`(`WsEvent.kt:21-27`),但客户端**对 `history` 不读帧壳**(`ChatViewModel.kt:1410-1412` 明确绕过 `isForSession`,改用每条消息自己的 `sessionId`)。所以这一处就算和真后端不一致,也不影响本工具能验的东西。 - **`platform` 过滤**是「相等即收」的近似,不是契约里那套 LIKE 转义语义。客户端当前所有调用点都传 `null` (`MessageRepository.kt:42,99`),所以这条路径没被真正走过。 - **`q` 搜索**是 ASCII 大小写不敏感的子串匹配;`%` / `_` / `\` 不做通配(我根本不解析通配符)。 - **不实现**:admin 面全部端点、`documents` / `upload` 的**摄取链路**(`POST /upload` 与「文件真的被读、 正文真的进上下文」那一段;`/__control/messages` 的 `documents=` **只是往消息上打那个键**,见 8.3)、 `voice_input` 的落库、 `mode_switch` 的语义、`observability_*` 推送、`subagent_ask` / `task_output_query`。上行收到这些帧 **只记录、不处置**。因此 App 里管理员页面、附件上传这类路径在这个假后端上跑不通。 ⚠️ `GET /api/v1/media/{name}` **实现了**(语音播放那一半,见 8.1),但**不校验 Bearer** —— 真后端那条是 `owner|admin` 端点(契约 U19)。 - **主动消息只能靠 `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}`), 而那条消息此刻**并没有进库** —— 这个回执是「假后端在说已受理」的近似;想如实区分,看 `/__control/state` 的 `heldMessages`。 2. 挂起期间**不认领**幂等键(与「队列满的判定发生在认领之前」一致)⇒ 同一 `clientMsgId` 重发会得到 **第二条**挂起记录,而不是 `chat_duplicate`。 3. `createdAt` 在**补落库那一刻**才盖 ⇒ 若期间库里已经有更晚的消息,这条会以「晚到」的方式插进去, 而 WS `history` 用的是**列表序**(不做排序)⇒ 这时的 `history` 顺序会与 REST 按 `createdAt` 的 排序不同(默认模式下两者本来就一致,所以只有挂起场景看得到这个差)。 - 挂起的消息**只活在内存里**(进程一停就没了);`/__control/reset` 会清空挂起列表,但**不动**模式开关 (`holdReplies` 是模式,不是库里的状态)。 - **不做 TLS**(只有 `http://`),也不支持 WebSocket 扩展 / permessage-deflate。 - 控制端点的响应形状(`{"data":{...}}` 里的键)**是自造的**,不要照它写客户端代码。