Files
AskaEth 8babd1d2bb W76:把「分享批量投递零声明」提成可单测的纯函数 + 假后端 http.jsonl 记请求体摘要
两件事都是为了让「分享 3 张图 = 3 条消息、零 interrupt」这条结论有据可查——
一条是代码侧的构造性保证,一条是设备侧的证据工具。

1) ShareRules.declaresSendPreference(fromShare, index)(纯函数)
   「这一次投递要不要按『她忙时发消息』偏好声明 interrupt / parallel」原先分在两处手写:
   ChatViewModel.sendShared 传 false、emitPlannedMessages 里 && index == 0。两处各是半个判据、
   一处都单测不到,而这条边界正是 W25 修掉的「分享多图自相打断」(第 2 张把第 1 张刚起的回合
   打掉,见 02 的 F4 与 05 那一节)。提成一处判据后语义不变,穷举整批(0..MAX_IMAGES-1 及越界)
   由 ShareSendDeclarationTest 钉住:分享恒 false、手打只在第 0 条 true。
   ChatViewModel 不在本次 scope,接线那一行没动(已作为 finding 上报,纯函数暂为唯一出处)。

2) 假后端 out/http.jsonl 记请求体摘要(不只是 headers)
   分享多图走 REST 不走 WS(ChatSendRouting.channelFor:hasImage ⇒ Rest),所以「发了几条、
   每条带的是正文还是图、有没有 parallel」只能从 HTTP 流水里读。此前它只记 headers,
   现在加 body 摘要:结构照留(键 / 布尔 / 数组长度),超过 200 字符的字符串换成 <N chars>,
   非 JSON 记 {bytes, note}——图片的 base64 只留长度,不会把一行撑到几 MB。
   _read_body 顺带加了缓存:body 只能从 rfile 读一次,先记录再让 handler 读会得到空串。

assembleDebug + testDebugUnitTest 全绿:1674 测试 / 0 失败 / 0 错误 / 0 跳过(134 个测试类)。
2026-10-26 22:17:44 +08:00

546 lines
37 KiB
Markdown
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.
# 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 字符的字符串换成
`"<N chars>"`,非 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=<jwt>` | `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":{...}}` 里的键)**是自造的**,不要照它写客户端代码。