225 Commits
Author SHA1 Message Date
AskaEth d0b8c5f2f7 docs(05): 写死纪律——不准用 WaitFor 等子代理(会把回合锁住,主人无法插话)
为什么:主人当场打断我的 WaitFor 并明确「纪律是不准用 WaitFor」。这条在后端仓库 AGENTS.md §10 里原本
就有,本项目文档此前没写、我因此重犯了一次。正确做法:派完就结束回合,等待期间只做不冲突的并行工作
或停手。
2026-10-06 15:46:40 +08:00
AskaEth 4fce12249e docs(05): S7② 与 S3 的真机验收结清(S7② 通过;S3 在「在线冷启动进有内容次级会话」场景下否证)
S7②(通过):补洞请求打在已摘除的会话上 ⇒ 404 ⇒ sessionGone;那句 8 秒的告知抓到([33,378][1047,480]);
select(主会话) 的副作用 POST /sessions/web/read 两轮都观测到。⚠️ 同时**修正我自己写的判据**:原先要求
「read 出现在 GET /sessions 之前」——实测顺序不确定(两条协程各起),该顺序判据不成立;真正该验的
「refresh 失败时也能回去」本轮没做。

S3(否证):screenrecord + ffmpeg fps=8 抽帧 + 逐帧量两块区域的深色墨迹(不改生产代码)。时间线是
启动画面(新版图标)→ 「加载中…」 → 内容,**没有出现「还没有消息」空态**。局限:8fps 采样(<125ms
的闪烁会漏)、单场景、单 build,不写成「S3 不存在」。

方法论:别用墨迹的 x 宽度猜那是哪句话——我一开始据此误判,抽帧一看是「加载中…」。
2026-10-06 14:24:45 +08:00
AskaEth 9f04fc4304 docs(05): S7②/S3 第二轮受阻记录 —— 模拟器劣化与主机被占、不依赖 dump 的验证套路、收工状态
为什么:这一轮 S7② 仍未验(进次级会话那一步没成),但花掉的时间都是环境问题而非代码问题,
必须把「先看主机在跑什么」「盲点 + 用服务端流水判结果」这两条写下来,下一轮别再重摸。
2026-10-06 14:16:55 +08:00
AskaEth 0583b1bc31 docs(05): 补记 S7② 为什么这轮没验成 + 下次的干净起手(含判据与两个坑:表单错位、dump 陈旧缓存)
为什么:S7② 需要「App 停在已删会话里」才能造场景,而登录表单被前几轮实验搞乱(地址栏残留 W48 的
坏值、字段错位),input text 落不进目标字段;uiautomator dump 还会返回陈旧缓存(同一交互前后两张
dump 的地址栏展开态互相矛盾)。把判据与干净起手写下来,下一轮别再重摸。
2026-10-06 13:57:53 +08:00
AskaEth b604fefbd4 docs(05): 锚点补记二十九 —— W51 合并、S2/S7① 设备验收结论,并更正补记十九里 S7 那句写错的前提
改了什么:
- 新增锚点补记二十九:W51 做了什么(S2 的纯判据与「首屏从没成功过」这个判据为什么这么定、S7① 的
  GoneSessions、S7② 真正的缺口是「出路不能依赖网络」);设备验收结论(S2 通过:恢复网络后客户端自己
  补拉并显示出消息;S7① 通过:删掉会话后真踢一次 WS,重连窗口里没有注定 404 的补拉,而握手证明通路
  活着);以及 S3 / S7② **未验**(如实写明,不写成「没问题」)。
- 更正补记十九里 S7 那句「`sessionGone` 位没有消费者」:消费者一直都在(ChatScreen.kt:165-166 →
  ChatHomeScreen.kt:109 的 onSessionGone),由 b5c0b13(2026-10-04、该行所依据提交 ff699f5 的祖先)
  引入,已用 git merge-base --is-ancestor 验证。这句是我写错的,W51 的任务书因此也带着错前提。
- 记下这次同类教训(第二次):真机「没生效」时**先核设备上 APK 的 lastUpdateTime 与本地 APK 的时间**
  —— 我就是拿上一版 APK 测了半天;另记「撤 adb reverse 不会掐断已建立连接,要真抖 WS 用假后端的
  /__control/ws-close」。
2026-10-06 09:20:40 +08:00
AskaEth 620a4e77fa merge: W51 —— 次级会话连接恢复自己补拉(S2);已删会话不再发注定 404 的补拉、sessionGone 的出路不再依赖网络(S7) 2026-10-06 09:04:09 +08:00
AskaEth 9980fb2b28 fix(chat): 次级会话补上「连接恢复自己补拉」(S2);已删会话不再发注定 404 的补拉(S7)
## S2 —— 次级会话没有握手 history 这第二条安全网

- 新增纯判据 `ui/chat/ReconnectPull.kt`:连接恢复那一刻该补哪一段
  (Forward / FirstPage / Nothing)。判据只看四样:会话 id、首屏状态、掉线游标、
  「本端已知它没了」。
- `ChatViewModel`:把 init 那次首屏 `loadLatest` 提成 `loadFirstPage()`,init 与补拉
  **共用一份代码**(补出来的结果必须与首屏逐字段同形);新增 `FirstPageState` 作为判据输入。

为什么这么定:
- **为什么不是照抄主会话的 history**:服务端固定回放主会话(`ws_hub_conn.go`),要它回放
  次级会话就是改契约面。次级走的是本项目已有的那条 REST 口径(拉最新一页)。
- **为什么用「首屏从没成功过」而不是「本端有没有数据」**:服务端真的没有消息时列表**也是空的**,
  拿空列表当判据会让每一个空次级会话在每次重连都被整表重拉 —— 那正是 S4/S7 这一类问题的成因。
- **为什么 Loading 不算失败**:首屏还在飞时再发一次同样的请求会与它抢跑,还会改掉
  「history 帧一到就 loading=false」那段时序(S3 本轮**一个字节都不动**)。
- **为什么主会话排除在外**:它有 history 兜底,在那里再补一条 REST 就是给同一件事造第二套口径。
  主会话的兜底行为因此**逐字不变**(有洞走 Forward,其余 Nothing)。
- 补拉那一次把 `loading` 翻回真:屏幕上原本是「还没有消息」停在原地,如实应该是「加载中…」。

## S7 ① —— 注定 404 的补拉不再发生

- 新增 `core/session/GoneSessions.kt`(进程级、**不持久化**、登出/换账号时清空):本端已知
  「服务端没有这个会话」的 id 集合。写入口是 `SessionsViewModel.deleteSession` 成功那一刻
  (本端唯一的删除入口),读入口是**每一个** `ChatViewModel`。
- 判据第 1 条:已知它没了 ⇒ 什么都不发。于是本地删过的会话从此**零次**注定失败的往返;
  已经 404 过的会话从「每次重连各撞一次」收敛成**恰好一次**(`sessionGone` 同时成为补拉的闸)。

## S7 ② —— sessionGone 的出路不再依赖网络

那个位的消费者**一直都在**(`ChatScreen` 按它触发的 `LaunchedEffect` → `ChatHomeScreen` 的
`onSessionGone`:一句如实告知 + 带回主会话),所以本轮补的是它真正的缺口 —— 原来只靠 `refresh`
**成功**之后的 `resolveActiveSession` 回落(拉失败、或列表为空时刻意不回落 ⇒ 什么都不动)。
于是「已切回主会话」在网络不稳 / 服务端抖一下时是一句**做不到的话**,用户被留在已经没了的会话上、
没有任何出路。现在先在本端 `select(主会话)`(与用户点抽屉里那一行走的是**同一条**路,不另造机制),
`refresh` 退回本职:与服务端对齐、把抽屉里那条去掉。

## 未做 / 未验

- **没有真机验收**(任务书禁碰 adb):S2 的「连接恢复后次级会话自己补上」只有纯判据单测。
- 「REST 首屏失败、但 WS 从头到尾没断(因此没有任何状态变化可挂)」这一格**不在本轮范围内**,
  仍是已知缺口。
- 全程未碰 S3、主会话兜底与游标口径、W48 的 VM key、W49 的节流判据、W50 的主动消息消费路径。
2026-10-06 09:03:22 +08:00
AskaEth a9cc3c5737 chore(gitignore): 忽略仓库根的 IMG_*.png(主人从手机丢进来的素材,只用不存)
为什么:主人明确「原图不进仓库」——换图标用的 IMG_20261006_081803.png(5.8MB)留在工作树即可,
加一条只作用于仓库根的忽略规则挡住误提交(只挡 IMG_*.png,不影响别处的图片资源)。
2026-10-06 08:57:27 +08:00
AskaEth a025624798 feat(icon): 应用图标换成主人提供的新版画作(昔涟特写)
改了什么:
- mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/ic_launcher.png:新画作按 48/72/96/144/192 缩放(lanczos)。
- 同目录 ic_launcher_round.png:同一画作按圆形 alpha 掩膜裁出(ffmpeg geq),供圆形掩膜的启动器。
- 同目录 ic_launcher_foreground.png:自适应图标前景按 108dp 画布全出血(108/162/216/324/432 像素)。
  第一版把画心缩到 76/108,实测在这台启动器上会露出背景一圈;改成铺满画布后任何掩膜下都不会露底。
- values/colors.xml:ic_launcher_background #F8F8F8 → #FFE6E0(用 ffmpeg 对源图左上角 300×300 取平均色得到,
  与画作底色同调;前景已铺满,这个底色只在极端掩膜下才可能露出)。

为什么:主人把新版图标源图放在仓库根(IMG_20261006_081803.png,5033×5033、5.8MB),要求替换应用图标。
**源图不进仓**(仍是未跟踪状态),只提交生成物;构建与安装均通过,实机底栏图标已是新版。
2026-10-06 08:30:53 +08:00
AskaEth 1d150cce85 docs(02): 客户端答复 —— 回执二十四·补:①推迟照办、②未读口径确认(维持 omitempty)、两条澄清,并反问 .end 能否带结果摘要
改了什么(只加了我们这一节,未碰他们那一节):
- ① 工具回看面推迟:照办。明确写死两条口径——不把「toolCalls[] 缺席」当「这轮没调工具」;
  不再按消息字面 join turnId(两者不是同一套标识)。也说明我们**不提前写**那个解析器。
- ② 未读面:回答他们的问题——**维持 omitempty 即可**,因为对我们权威来源是列表面(GET /sessions 恒带
  unreadCount),增量键只是省一次重拉的优化;接入口径按他们说的「键在才改、缺席什么都不做」。
  同时如实说明本端**还没消费**这个键(今天走的是 W49 的重拉,已实机验收),免得他们误以为已生效。
- 两条澄清:GET /api/v1/messages/{id} 我们从未用过(只有 PATCH/DELETE,SessionsApi.kt:130,137);
  tool_execution (a) 维持收到。
- 反问一件与回看面无关的事:能否给 tool_execution.end 加可选 resultSummary(截断、缺席语义)——
  因为 .end 只带 tool/phase/id、update 又没有生产者,实时工具卡的结果区结构性为空;这是投入最小的一条。
  明确说「不做也接受,但请明说」,我们要的是确定而不是一定要这个键。
- 告知 W50/W48/W49 已落地并实机验收(不需要他们动作)。

为什么:他们那条回执标红了「别把缺席当没调过」,并直接问我们要不要区分「确实 0 / 不知道」——
这两个都是会决定他们改不改代码的问题,必须当场答清。
2026-10-06 08:24:50 +08:00
AskaEth b23948bed0 docs(05/07): 锚点补记二十八 —— W48/W49 设备验收通过(换环境串味 / 抽屉未读角标)
改了什么:
- 05 新增锚点补记二十八:W48 的判别法(先清空服务端主会话再换账号,否则两账号同一份历史看不出串味)与结果
  (A 的本地气泡与持久行在 B 账号下都 0 次、抽屉残留 0 次);W49 的机制证据(抽屉关着时客户端自己在
  消息到达后 60ms 发出 GET /api/v1/sessions)与可见结果(次级会话行 未读 1、正在看的主会话无角标);
  并更正一处认知:红点是抽屉内会话行的未读角标,菜单图标上没有点。
- 07 的坑清单补两条:① 登录页地址栏留空时 App 会回落到上次记住的地址(我因此白追一小时「请求怎么打到
  真后端」);②「登出」是设置页最底部的图标按钮(图标是 MiuixIcons.Back、只有 contentDescription),
  截图上像「返回」很难找。

为什么:这批验收原来只有单测覆盖,结清后 05 的待办里不再挂着它;两条坑都是这次真实花掉时间的,
写下来避免下次重演。
2026-10-06 08:24:00 +08:00
AskaEth c905a47d6f docs(05/07): 锚点补记二十七 —— W50 落地并设备验收通过;细节单开 07(证据与避坑)
改了什么:
- 05 新增锚点补记二十七:结论(afee5f6 已推 / 1193 测试真跑 0 失败 / APK 尺寸)、四条硬要求逐条
  对应实现处、红线核对、设备四条判据全绿、环境坑一句话版、真后端那条主动路径是活的(副产品)、
  待办变化(W48/W49 验收环境已就绪;W50 的窄尾巴)。
- 新增 devdocs/07-W50-主动消息-验收与避坑.md:验收判据与逐条证据、完整环境坑(含绕法与命令)、
  复现整套验收的最短路径、随批次修掉的 timestamp 解码缺陷、以及「落库失败档」那条尾巴的收口建议。

为什么单开 07:05 已 1400+ 行,验收的操作细节与避坑属参考材料而非状态;05 只留结论与指针,
下一个我读 05 就能接手,需要动手复现时再翻 07。
2026-10-06 08:11:26 +08:00
AskaEth afee5f612d merge: W50 —— 她主动说的话实时进主会话(proactive_message 消费 + 帧解码修复 timestamp 类型不符) 2026-10-06 07:51:26 +08:00
AskaEth 8b5d15bdb6 fix(chat): 她主动说的话实时进主会话(W50 proactive_message 消费 + 帧解码修复)
缺口:她主动说的话既是实时帧、也是主会话历史里的一条 assistant 行,而安卓端实时看不到 ——
ChatViewModel.onEvent 里没有 ProactiveMessage 分支(落到 else -> Unit),另两个下行消费者也不认它;
而且帧里只解了 messageId(IM 侧的号),落库行的 id / createdAt 被 ignoreUnknownKeys 静默丢弃。
后端这条 Web 路由只发这一帧、不补 message_persisted,所以没有别的路能把它带进来。

一、帧解码(data/ws/WsEvent.kt)
- ProactiveData 与 ProactiveMessage 补上 id 与 createdAt(落库行的权威 id 与时间戳,同现同省)。
- 顺带修掉一个把整帧弄没的缺陷:载荷里的 timestamp 服务端是 Go time.Time,线上是 RFC3339 字符串,
  而本端按 Long? 解 ⇒ 解码抛异常、被 parse 的 runCatching 吞成 null,帧在成形之前就没了
  (所以只加一条 onEvent 分支根本碰不到它)。改成按线上的字面量收,数字形状也照样读出。

二、为什么按已落库行并入,而不是画一个只活在 UI 的气泡
真相是服务端历史里的那一行,实时帧只是它早到一步的副本:按 HistoryMessageDto.id 走既有的
mergeMessages 并入 ⇒ 实时能看到,且刷新 / 重开 / 切会话拉历史之后她的话仍在;同一条
(实时帧 + 握手回放 + 分页)无论到几次都只有一行,不需要任何额外去重。
判据抽成纯函数 proactiveIngestFor(ui/chat/ProactiveIngest.kt),可单测。

三、为什么必须自己钉死主会话
proactive_message 帧壳不带 sessionId(服务端构造这一帧时没设它,且该键是 omitempty),
而 isForSession 对空 sessionId 的口径是放行 ⇒ 这一帧会投给每一个活着的 ChatViewModel。
不钉就会在每个次级会话里都冒出一条她的话(比看不到更糟)。判据是等值比较、不是前缀:
主会话 web 与次级会话 web:xxx 是两个会话。

四、无 id 时为什么退化而不是丢弃
id 与 createdAt 一起缺席 = 服务端那一步落库失败了(它自己就说了这条没有历史行)。这时画一条
本地气泡,如实显示、不假装已落库:本地 id 用帧里已有的稳定键(IM 消息号,其次时间字面量),
同一个键第二次到达(重推 / 重放)也只是 upsert,不会成两个气泡。KDoc 写明它不在历史里、
刷新 / 重开后不会再有。
无 id 且无正文(帧里没有任何可显示的东西)才整帧跳过 —— 那一条画的是空气泡,只会让人以为出了别的事。

五、抽屉红点(复用 W49 那条路,不新写取数路径)
服务端不为这条路补 message_persisted ⇒ W49 的「非激活会话落新消息 ⇒ 节流静默重拉」不会被触发,
主会话的红点会停在她开口之前。SessionsViewModel 用同一个 persistedRoute 把这一帧接进同一条路
(复用 requestSilentRefresh 与既有的 InactiveSessionMessage 成因,不动节流判据本身):
停在别的会话 ⇒ 节流静默重拉一次(红点亮);正看着主会话 ⇒ 与 message_persisted 同一档
「看到即已读」,免得抽屉为她刚说的话亮一个红点。无 id 那一档不拉(历史里没有行,未读面上无事可做)。

测试
- 新增 WsEventProactiveFrameTest:真实帧形状(id / createdAt / RFC3339 的 timestamp)、数字形状容忍、
  落库失败那一档、以及「帧壳没有 sessionId ⇒ 会话闸对每个会话的 VM 都放行」这条事实。
- 新增 ProactiveIngestTest:归属(次级会话不并入、id 按等值比)、身份(有 id = 已落库行)、
  退化(本地气泡 / 空帧跳过)、不重复(重发与历史页都收敛成一行)。
- InactiveSessionPullTest 补一条钉子:她主动说的话该走拉列表还是「看到即已读」这条路。
2026-10-06 07:50:33 +08:00
AskaEth 5520d6015d docs(05): 更正代理旧路径引用(工具已在 devtools/ws-block-proxy/,历史条目不改写) 2026-10-06 07:45:31 +08:00
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
AskaEth d9b7c22ecb docs(05): 锚点补记二十六——续接锚点(现状/环境/设备驱动协议/W50 在飞与合并检查清单/W38~W49 索引/待办排序/与后端状态/方法论教训),供下一会话直接接手 2026-10-06 07:38:29 +08:00
AskaEth d5d2feee36 docs(05): 锚点补记二十五——W48(账号指纹进 VM key 修 S1 + 搜索 VM 同类缺陷 + 5 处错注释 + 代理挪进 devtools)与 W49(非激活会话节流静默重拉修 S6,3 秒窗口 + 结构性 trailing + 一处刻意偏差)落地;合并后 1177 测试全绿 2026-10-05 22:30:08 +08:00
AskaEth 5dc6408bb9 merge: W49 —— 非激活会话落新消息时节流静默重拉(3 秒窗口 + 结构性 trailing),让抽屉红点在不开抽屉时也能出现 2026-10-05 22:28:39 +08:00
AskaEth 93cf3e359a fix(sessions): 非激活会话落新消息时自愈式节流重拉,抽屉红点不再等用户开一次抽屉(S6)
现象:App 停在主会话时,次级会话落了新消息 ⇒ 抽屉红点不出现,必须打开一次抽屉
(那次静默 refresh)才补上。

根因:message_persisted 只在 sessionId == activeSessionId 时才推进 / 更新,非激活
会话那一帧直接 return;而会话列表更新的触发点此前只有抽屉打开 / 新建 / 删除 / init
四处,于是「别的会话攒了新消息」没有任何人会去发现。

为什么是客户端自愈而不是等后端:这一帧不带 unreadCount(契约面缺一个增量),而本端
没有逐条时间线 —— 自己累加出来的数会与编辑 / 删除 / 别端操作漂移,那是造数不是修复。
帧壳本身已经说清「某个会话有新消息落库」这件事,所以本端只是据此去问一次服务端,
走的是与抽屉打开时那次完全相同的路径(refresh(silent = true),含已读游标遮罩),
只是不需要用户先开一次抽屉。后端把增量补上之后这条路可以退成不必走,但在此之前它
是唯一能把红点点亮的路。

节流窗口 3 秒,理由:
- 够快:3 秒落在「看起来就是刚发生的」这个感知里;5 秒往上就开始像卡了一下才补上;
- 够粗:一次静默重拉是两个请求(refresh 里还跟着一次角色目录),而播出方是按每条
  消息推帧的 —— 3 秒能把人手连打 5 条(约 2~4 秒)或一轮回复的连续落库收进同一个
  窗口,收到 1 秒就会漏成两三次请求;
- 不另立尺子:3 秒正是本项目已有的「等够久才值得为一次等待开口」那个量级
  (ChatScreen 的 SHARE_WAIT_NOTICE_MILLIS)。

trailing 怎么保证:窗口内的第二条起不拉,但排一次「窗口到点补拉」(等待时长 =
窗口 - 已过时间),到点直接拉一次并把窗口重开;同一时刻最多一个补拉任务(已排上时
后续成因只更新账本、不重排),所以最后一条之后必定还会再拉一次,红点不会停在旧值。
另外「正好等于窗口」算窗口已过(>= 而不是 >),补拉到点那一刻不会被自己刚开的窗口
挡回去。

哪几种情况不拉:当前打开的那个会话落新消息(那条路本来就有「看到即已读」,多拉一次
列表就是白花两个请求);同一窗口内的后续消息;窗口内且补拉已经排上。另外切会话那条
路刻意不接进来 —— select 紧接着就发推进已读游标的请求,此时重拉会与还没落地的游标
抢跑、把刚清掉的角标闪回来(抽屉打开时那一次紧跟在游标之后,本来就会补上这一拉)。

判据抽成纯函数(新增 InactiveSessionPull.kt):persistedRoute 决定这一帧走哪条路,
silentRefreshStep 决定这一刻该不该拉、拉完账本怎么走;时钟由调用方喂,不用 sleep
也就能穷举边界。

顺带把身份变化(重登成另一个账号 / 换了服务器地址)也接进这条路径,用 ContextChanged
绕过节流窗口 —— 那份列表换了主人,等满一个为「别的会话又攒了几条」而设的窗口没有
道理;登出时把账本与补拉任务一起作废(否则补拉会带着一个已经没有令牌的身份发请求)。
抽屉打开时那次 refresh、activeSessionId 的推进、已读游标的既有行为都没动。

验证:gradlew.bat assembleDebug testDebugUnitTest —— BUILD SUCCESSFUL;
1171 条单测 failures 0 / errors 0 / skipped 0,其中新增 InactiveSessionPullTest 13 条。
2026-10-05 22:28:19 +08:00
AskaEth e589ab303d merge: W48 —— 聊天页 VM key 带上账号指纹(修换环境后仍显示上个环境的会话)+ 注释纠错 + 把 WS 拦截代理挪进 devtools 2026-10-05 22:26:44 +08:00
AskaEth 40dac87dda docs(05): 补记二十四附——本轮两个决定(P1 主动消息消费我方接、tool_execution 选 a 并要 toolCalls[] 回看面带输出)与推送暂不接的口径 2026-10-05 22:26:09 +08:00
AskaEth 766843fdbd docs(02): 客户端答复回执二十二·补的四问——主动消息「不会两次」但真缺口是「我们没消费那帧 ⇒ 实时看不到」;tool_execution 选 (a) 并附取舍;纠正「每屏一条 WS」过时前提;推送暂不接并说明「应用内提示」在客户端零实现 2026-10-05 22:25:59 +08:00
AskaEth 4f860aa499 fix(chat): W48 聊天页 VM key 带上账号指纹,修换环境后仍显示上个环境的会话
缺陷:换账号或换服务器后,聊天页仍然摆着上一个环境的消息。成因是 ChatScreen 里那个
ChatViewModel 的 owner 是 Activity 级的 ViewModelStore,而登出只是把 ChatHomeScreen
移出组合、并不清理那个 store(ChatViewModel.onCleared 是空的,全仓也没有 ViewModelStore.clear())。
key 里只有 sessionId 时,换环境再进来 viewModel() 会把上一个环境那个还活着的实例原样取回来,
它继续收着那个环境的帧,屏幕上就是别的服务器的消息。主会话的 id 恒为 web,两次登录必然撞上
同一个 key,所以这不是理论风险。

为什么 VM key 要带账号指纹:指纹回答的是「这份本地状态属于哪个环境」。把它抽成纯函数
accountFingerprint(baseUrl, username),与 SessionPreferences 判断快照作废、
SessionsViewModel 落盘快照用的是同一份口径(SessionsViewModel 原本就地拼的那串改成调用
同一个函数,免得两套判据分叉——分叉的那一侧就是漏出去的那一侧)。同一个环境里切会话,
key 未变,各会话的状态照旧各留一份(这正是「切回来状态还在」依赖的);环境一变(换了服务器
或换了账号),key 就变,取到的一定是新实例。

会话内搜索那个小 VM 是同一类残留:它持有的命中就是消息正文,换环境后在主会话上重开搜索,
面板里就是别的服务器的消息。它的 key 同样带上指纹。

为什么本轮不回收旧 VM:要真正回收掉上一个环境那个实例,得自定义 ViewModelStoreOwner 或直接
清 store,而「切回来状态还在」这条体验正绑在 store 不清这件事上,属于要主人拍板的设计取舍。
本轮只保证换环境后取到的是新 VM,回收这件事不动。

顺带的零成本纠错(只改注释的成因描述,行为一字未动):ChatHomeScreen / ChatScreen /
ChatViewModel 里几处「onSessionGone 一被调用,那个会话的 VM 连同它的状态立刻被销毁」是错的——
变的是 key,被销毁的只是承载横幅的那段组合;VM 实例仍在 Activity 级的 store 里活着并继续收帧。
「横幅必须挂在上一层」那条结论本身是对的,原样保留;改的只是它前面的理由。
同时把 devtools 里那个窄代理的路径写对(此前注释指向一个并不存在的路径)。

开发工具挪出会被 clean 扫掉的位置:app/build/e2e-shots/ws_block_proxy.py 躺在 gitignored 的
app/build/ 里,一次 gradlew clean 就没了。挪进 devtools/ws-block-proxy/(与 fake-backend
同级同风格)并配 README,写清用途、用法(python ws_block_proxy.py 18098 10942,配
adb reverse tcp:10942 tcp:18098)与它最有价值的判据:调试时 Web 前端直连后端、不经过
adb reverse,所以代理日志里的每一行都只可能来自 App。脚本行为未改,也没有硬编码任何凭据。

新增单测 ChatViewModelKeyTest 钉住拼法:同账号同地址 => 同 key;换地址 / 换账号 / 换会话
=> 不同 key;username 缺失时退化成 baseUrl 一段,换地址仍然分得开。

验证:gradlew.bat assembleDebug 与 gradlew.bat testDebugUnitTest,
BUILD SUCCESSFUL;单测 1164 条,failures 0、errors 0、skipped 0。
2026-10-05 22:25:57 +08:00
AskaEth a4ac11a1c1 docs(05): 锚点补记二十四——流程教训:动 02 之前除 git status 还要 diff 它最新几十行(我差点在没读后端回执的情况下给后端写信);并登记后端正在等我们回的四件事与「每屏一条 WS」这个过时前提 2026-10-05 22:23:44 +08:00
AskaEth 7d1c56e03b docs(02): 给后端一份「我方实际依赖的线形状」清单(clientMsgId 幂等是我们敢重发的唯一安全基础、REST stream 回执必须仍是小 JSON、鉴权失败只认 401…)+ 两条已登记缺口的提醒 + 告知客户端已不再调 /health + 提供 CyreneRoute 日志作为联调取证 2026-10-05 22:22:58 +08:00
AskaEth c09c40388b docs(05): 锚点补记二十三——登记在飞的两个 worktree(W48 修 S1+注释纠错+工具归位;W49 修 S6 客户端自愈)与合并纪律 2026-10-05 22:21:49 +08:00
AskaEth 324be57c7a docs(02): 客户端登记——「非激活会话红点不实时增长」缺一个未读增量面(message_persisted 不带 unreadCount),并说明我方先用客户端自愈顶着、它不能替代增量面 2026-10-05 22:21:48 +08:00
AskaEth df8df67742 docs(05): 锚点补记二十二——③ 结案:阶梯机制正常(两次有效观测 route=Rest 且落库),W45 那轮异常未复现也因当时无 CyreneRoute 日志而无法定论;并写死一套可靠的设备驱动协议(确认前台→确认文字落点→从 dump 取发送键坐标) 2026-10-05 22:12:16 +08:00
AskaEth f2075c1f49 docs(05): 锚点补记二十一——移除键补验有效(附我复用旧坐标点空的教训与布局回流说明);③ 的第一次尝试没跑成(CyreneRoute 为空、代理无 POST、库无该条 ⇒ 发送没触发),并写明下一轮必须三步确认的做法 2026-10-05 22:10:00 +08:00
AskaEth df20a21fb5 docs(05): 锚点补记二十——W42 图片多选设备验收通过(多选器是真多选、计数「添加(2 项)」正确、输入栏出现两枚带移除键的缩略图),并登记移除键位置这处观感待主人定 + 两个未验的邻近格 2026-10-05 22:05:21 +08:00
AskaEth 669d05b781 docs(05): 锚点补记十九——子会话流程变成清单(S1~S9,每条带文件:行号与归类),先纠正我自己两条错前提,附 12 步设备验收骨架、优先级建议与 ws_block_proxy.py 不在 git 里的提醒 2026-10-05 22:01:20 +08:00
AskaEth ff699f5079 docs(05): 锚点补记十八——W47 静默按事故复位(清零点收进一个纯判据、事故=队列空到空),含为什么不选「只在成功时清零」与三条验收体验 2026-10-05 20:25:58 +08:00
AskaEth 3a07e94180 merge: W47 —— 队列静默改为按事故复位(队列排空即清零),不再只在补发成功时清零 2026-10-05 20:25:23 +08:00
AskaEth ce50bd2c24 feat(chat): 静默告知改为按事故复位,队列排空即清零
W43 的静默规则只在「补发真的成功一次」时把 queuedResendFailures 清零。若一段坏网络的
队列以回滚或被收编收场(一次都没成功发出去),这一格就永远挂着 ⇒ 之后连接恢复、再断
一次、用户又发消息时,那句解释再也说不出口。那不是减少噪音,是把解释永久关掉(主人
2026-10-06 裁决:哪个体验好用哪个)。

改成按事故复位:一个「事故」= 队列从空到非空、再回到空,结束就复位,不管它是怎么结束
的——发出去了、回滚了(内容退回输入栏)、还是被收编了(气泡换成权威消息)。清零点因此
有两个:补发成功一次(原样保留),以及队列排空(既有钩子 forgetQueuedNoticeIfDrained,
回滚那条路也走这里,它是队列清空的唯一两个入口之一)。

这样不会在长期断网时刷屏:阈值仍是 3;「一批从空到非空只说一次」仍由 queuedNoticeTold
管着;纯网络故障下消息会堆积在队列里 ⇒ 队列不清空 ⇒ 不算新事故、计数不复位 ⇒ 不会每条
消息都弹一次横幅。而事故一旦结束,下一次断线又能重新解释。

判据提为纯函数 queuedResendFailuresAfterDelivery(VM 与测试共用,跟
queuedSendNoticeAllowed 同一个写法),单测钉住:队列排空即复位(哪怕这一批一条都没成功
发出去)、队列还有货则计数保留、补发成功照旧清零、一个事故里失败 0/1/2 次仍允许告知而
到阈值静默、下一个事故重新告知。静默不动队列:那条既有用例(判定那一刻队列仍在、下一步
仍是发这条)一字未改。

只动告知判据与它的记账。队列与出队逻辑、sendRouteFor / 三档路由 / W38 分享落地 / W45
直接试 REST / W46 的 CyreneRoute 日志全部未碰。

验证:gradlew.bat clean assembleDebug 与 gradlew.bat testDebugUnitTest,
failure / error 均为 0。
2026-10-05 20:25:00 +08:00
AskaEth 798c6ee907 docs(05): 锚点补记十七——W45 阶梯设备验证通过(route=Rest、代理见 200、落库 1 条),附我连续三次误判的共同点与三条设备验证纪律,以及工具卡缺口的根因与登记位置 2026-10-05 19:53:16 +08:00
AskaEth 16699c4958 merge: W46 —— 发送路由判据带上原因,并加一行 CyreneRoute 诊断日志(DEBUG) 2026-10-05 19:50:59 +08:00
AskaEth d546f31755 feat(W46): 发送路由带上原因,并加一行 CyreneRoute 诊断日志
为什么要把「原因」也带出来
- 结果只有 Ws / Rest / Queue 三档,而「为什么是这一档」在设备上完全看不见。W45 那轮真机回归
  就卡在这里:那条消息进了补发队列、网络侧一条请求都没有,而「零请求 + 进队列」在判据里只可能
  来自 Queue 那一档;可顶栏同时显示「重连中…」。两者都只是结论,事后无法分辨「判据读到
  Offline」与「判据读了别的值」。
- 于是把判据抽成 sendRouteDecisionFor(wsDispatched, connection) -> (route, reason):reason 就是
  原来那三条规则各自的分支标识(FrameDispatched / ConnectionOffline / ConnectionNotReady)。
  sendRouteFor 现在读它的 route,所以两处不可能分叉。判据、优先级、行为逐字未改。

那一行日志(tag = CyreneRoute,Log.d,只在 DEBUG 构建打)
- 打点:ChatViewModel.sendThroughLadder 一进入就一笔,覆盖阶梯的三个分支(帧交出去 / 试 REST /
  直接进队列),一行 key=value、空格分隔:
  wsDispatched=... socket=<ChatSocket.state.value,权威值> mirror=<顶栏读的那个镜像>
  wsObject=<present|absent> route=... reason=... outcome=... fromQueue=...
- socket 与 mirror 必须同时打出来:mirror 是 collect 过来的一格,只会滞后、不会超前,两者不一致
  就说明顶栏那一刻显示的是上一格 —— 这正是「顶栏重连中…」与「这条进了队列」能同时出现的形态。
- 这是开发期诊断仪,不是产品行为:字段增删不构成兼容问题、不为它写测试(要钉的是纯函数那张全表)。
  不打 token / 请求体 / 正文 / localId。

顺带(同一轮的可观测性)
- ChatSocket 新增只读属性 hasLiveSocket(webSocket != null),只喂日志,不是产品逻辑的输入。
- 复核 WsState.Offline 的写点:ChatSocket 里只有两处(runConnectionLoop 的 ConfigMissing,以及
  endWithAuthFailure),close() 写的是 Idle;非 401 的握手失败(代理回 503)走 Closed ->
  Reconnecting,进不了这一档。

测试
- SendFallbackTest 新增两例:五档连接状态 × wsDispatched 真假的全表逐个钉住结果 + 原因,并钉住
  sendRouteFor 与判定同值;另一个钉住三个 reason 都可达、没有第四种组合。全量 1155 例,
  0 失败 0 错误。
2026-10-05 19:50:28 +08:00
AskaEth b8399b5949 docs(02): 客户端登记——工具调用记录在回合结束后消失,根因是落库消息没有工具字段(数据面缺失),并给出要补的四点与影响面
排查证据:助手行落库时客户端会为流式思维链做「打捞 + 滞留」,而工具没有字段可打捞、连同流式路一起被丢弃;后端其实已把工具调用落进 flow_graphs,缺的是取出来给聊天端。
2026-10-05 19:44:53 +08:00
AskaEth b18d864145 docs(05): 锚点补记十六——W45 把探测换成直接试 REST(判据/两个超时/被证伪的那条担心/删除与测试承接),并把本文件里所有关于探测的描述显式标为作废 2026-10-05 18:34:56 +08:00
AskaEth 1d548ddcda merge: W45 —— 发送阶梯改成直接试 REST,删掉 W41 的连通性探测(少一个端点、一套超时预算与一个失败面) 2026-10-05 18:34:15 +08:00
AskaEth ec8d54f3a5 feat(W45): 发送阶梯改成直接试 REST,删掉 W41 的连通性探测
为什么换成直接尝试
- W41 的阶梯是「WS 未就绪 ⇒ 先探 GET /api/v1/health ⇒ 通才走 REST」。
- 真机回归(devtools/ws_block_proxy.py 只掐 /ws/chat,HTTP 全放行)发现消息进了队列而不是回落
  REST;而 ProbeClientTest 证明那条链在 JVM 上是通的,Unreachable 生在连接之前,三种成因
  (建 client 抛 / 连上但一个字没写 / ConnectException)在外同形,那一轮无法定论。
- 结论:不该有探测。探测只是「能不能连上」的替身,而真正的 REST 请求本身就是最直接的检验
  ——试成了消息还顺手发出去了;试败了进队列(内容一个字节不丢,重发仍是同一个 clientMsgId)。
  探测唯一省下的是「完全无网时少发一个注定失败的请求」,代价却是一个独立 client、一套独立超时
  预算和一整套新的失败面。那个探针 client 还不复用连接池,每次都要现做一次冷握手,预算比主链路
  紧 4 倍(2.5 秒整通 vs 10 秒连接)——今天这个 bug 的形态就长在这上面。

改了什么
- 路由:wsDispatched ⇒ Ws(成功路径逐字不变);Offline ⇒ Queue;其余三档未就绪 ⇒ 直接试 REST。
- 直接试 REST 走既有的 REST 发送路径与请求体(同一份 chatRequest / 同一个 clientMsgId):服务端
  收下(200 stream:true / 202 queued / 200 duplicate)⇒ 完成、不进队列;网络类失败与没等到裁决
  ⇒ 进队列;服务端给过裁决(409 / 500 / 503 / 400 与 401)⇒ 回滚 + 既有文案,口径不变。
- 判据纯函数化:sendRouteFor 去掉探测入参;新增 restAttemptLanding(Result<ChatAck>? ⇒ 四档落点);
  ChatAck.isAccepted 从 ChatViewModel 的私有方法搬到判据文件。
- 删掉探测那一套:ConnectivityRepository / HealthApi / HealthDto / ApiClientFactory.createProbe /
  PROBE_TIMEOUT_MILLIS / Reachability / reachabilityOf / ChatViewModel.probeReachability /
  AppContainer 的接线(删前逐个确认过没有别的调用方)。
- 超时:一个 client、两个超时。client 上的连接超时 10 秒改成 2.5 秒;回落那一次投递的整通上限仍然
  是 10 秒(REST_FALLBACK_TIMEOUT_MILLIS 一个数都没动)。
- 测试:SendFallbackTest 按新事实重写;ProbeClientTest 删掉,其中仍然成立的事实搬到新的
  RestAttemptClientTest(真 OkHttp + 真 Retrofit + JDK HttpServer)。

为什么是这两个超时值
- 连接超时 2.5 秒:正常网络下一次 TCP 握手是毫秒到几十毫秒量级,2.5 秒是「人开始觉得卡」之前的
  量级,也与 W41 那 2.5 秒的探针预算对齐 ⇒ 换成直接试 REST 之后「无网时用户最多等多久」没有变长
  (完全无网时 ≤ ~2.5 秒进队列)。绝不许回到 10 秒:那会让每一条离线消息的用户都白等十倍的时间。
- 整通上限 10 秒:它管的是另一档——「连上了但一直不回话」(半死的代理、接住连接却不回应的
  服务端),到点也按网络类失败进队列。它同时是用户干等时间的上界,不许拉长。
- 整通上限放在调用点而不是 client 的 callTimeout:这个 client 是共用的,长文本 / 内联图片那两条
  本来就该走 REST 的路用的是同一个,client 级 callTimeout 会把它们一并框住,等于给一条本轮不打算
  碰的路引入新失败面。

为什么 Offline 仍然不试(保留 W41 的决定)
- 该档的语义是「握手 401 且续期也失败」(终态、不自动重连),会话已知是死的:试 REST 只会拿到
  401 并落到「回滚 + 一句误导人的鉴权错误」,把「已排队不会丢」换成更差的结果。W41 的理由
  (不鉴权的健康端点必然回 200 ⇒ 假通)随探测一起消失了,但结论换到直接试这边一字不用改。

验证
- gradlew.bat assembleDebug:BUILD SUCCESSFUL
- gradlew.bat testDebugUnitTest:1153 个用例,failures=0,errors=0
  (SendFallbackTest 14 例、RestAttemptClientTest 6 例全绿)

未做
- 没有 push。devdocs/05 的 W41 / W44 段落仍写着探测(那份由主会话维护,本轮只在回复里点出过时
  的句子,没有改它)。
2026-10-05 18:33:32 +08:00
AskaEth 2bee0b52b0 merge: W44 —— 判定 W41 缺口:A(503 判成 Offline)已否;B 只到形态,那个 Unreachable 生在连接之前,需一次设备取证(含 ProbeClientTest 与 createProbe 的 DEBUG 日志) 2026-10-05 15:50:19 +08:00
AskaEth 06028cc352 test(probe)+docs: W44 判定 W41 缺口——A(把 503 判成 Offline)已否;B 只到形态(那个 Unreachable 生在连接之前,三种成因在外面同形,需一次设备取证)
判定 A:否。ChatSocket.isAuthFailure 只认 HTTP 401(ChatSocket.kt:320-323),非 101 的握手响应
(代理回 503)走 SessionEnd.Closed ⇒ Reconnecting 退避重连(ChatSocket.kt:301-304 / 230-235);
而顶栏「重连中…」正是 Reconnecting 的文案(ChatScreen.kt:918,Offline 那档是「离线」)。
⇒ 那一刻 socket 真值就是 Reconnecting,sendRouteFor 在这一档会看探测结论,镜像也没滞后。

判定 B:形态成立,但「这条链根本发不出请求」不成立。
- 调用点是无条件求值的实参(ChatViewModel.kt:1614)⇒ Queued 这一档必然调用探测,
  连 Offline 那档也会(sendRouteFor 只是把结论丢掉,与本轮前的 KDoc 说法相反,已改)。
- 新增 ProbeClientTest(6 例,真 OkHttp + 真 Retrofit + JDK HttpServer):这条链能在真 socket 上
  留下 GET /api/v1/health、不带 Authorization、200 ⇒ Reachable、503 ⇒ Unreachable、
  2xx 但回执解析不出来 ⇒ Unreachable、黑洞服务端在 2.5 秒整通上限处失败
  ⇒「探测压根发不出去」不是代码事实。
- 真机代理日志里一条 health 都没有 ⇒ 那个 Unreachable 生在连接之前。三种成因在外面完全同形:
  (甲) 建 client 就抛(baseUrl 空/非法,Retrofit.baseUrl 直接抛);
  (乙) 连上了但一个字都没写(callTimeout 是整通上限含连接,而探测不复用连接池 ⇒ 每次冷握手,
       预算只有 2.5 秒,比共用客户端的 10 秒连接预算紧 4 倍);
  (丙) 请求出去了、回执非 2xx(只有这一档在对端留痕)。
  ⇒「代理日志没有 health 那一行」推不出「App 没探过」;(甲)(乙) 都不留痕。

本轮改动(除一处 DEBUG 日志外全是文档与测试,无行为变化):
- 新增 app/src/test/kotlin/top/yeij/cyrene/data/api/ProbeClientTest.kt(6 例)。
- ApiClientFactory.createProbe:DEBUG 下挂 BASIC 日志(与通用 create 同一约定)。这是本轮要的
  诊断仪——--> 打在连接之前,让「到底有没有探」与「死在连接还是死在回执」当场可分;探测不带令牌、
  没有请求体,日志不涉凭据。
- KDoc 修正「连探测都不做」这处失真(SendFallback 的 sendRouteFor、ChatViewModel 的调用点与
  probeReachability),把「三种成因 + logcat 判据」写进 ConnectivityRepository 类注释,
  并给 WsState.Offline 补上「非 401 的握手失败不属于这一档」。
- sendRouteFor 的 fail-closed、探测的 2.5 秒上限、三档路由一个字都没改;下一步要设备取证,
  步骤写在 devdocs/05 的 W44 一节。

验证:assembleDebug 成功;testDebugUnitTest 1156 例 / 0 失败 / 0 错误(新增 6 例全绿)。
2026-10-05 15:49:48 +08:00
AskaEth 866a3c37cd docs(05): 补记十五·补——W41 回归重跑成功并找到一个真缺口:WS 断/HTTP 通时消息进了队列而不是回落 REST,且探测请求根本没发出(含 dump 决定性判据与下一步该查的位置) 2026-10-05 15:29:54 +08:00
AskaEth 4460ca9277 docs(05): 锚点补记十五——W43(中性文案逐字 + 重试静默的阈值/记账/清零口径与一处待过目边界)、主人四条裁决的落点、以及一次没跑成的 W41 回归(如实记为未验证,附可复用的 WS 拦截代理与教训) 2026-10-05 15:23:04 +08:00
AskaEth 3c1d3fd44c merge: W43 —— 超限告知改入口中性措辞,补发反复失败就静默(静默不等于丢弃) 2026-10-05 15:22:34 +08:00
AskaEth 5556573986 feat(chat): W43 超限告知改入口中性措辞,补发反复失败就静默
① 超限告知(ShareRules.truncatedImagesNotice)改成**入口中性**措辞
原句以「分享的图片」开头,那是一句只对其中一条路成立的限定:W42 起这个函数同时被图片入口
用着(图片按钮已改多选),而图片入口里根本没有「分享」这件事。所以句子不再指认动作的来处,
只说两条路都成立的三件事:**有上限(4 张)、超出的没发、发出去的是前 N 张**。新句逐字:
「一次最多 4 张,超出的没发,只发送前 N 张」,N 就是真发出去的张数。
刻意不写「失败」「错误」:被上限截掉不是一次错误,是上限本身,那族措辞会让用户以为丢了东西。
两条路仍然共用同一句(只有一个出处),没有为此分裂成两份文案。

② 补发「多次重试失败就静默」(主人 2026-10-06 定案)
W40/W41 之后未连接时普通发送会进补发队列并给一句柔和告知,连接恢复后自动补发;主人新加的
规矩是**不要一直刷屏**:同一条(或同一批)反复重试都失败时,停止再弹告知,静静地等。
- 判据抽成纯函数 queuedSendNoticeAllowed(toldThisRound, failedResends):**静默闸优先**,其次是
  既有的「一轮从空到空只说一次」(W40 那条一字未改)。它只回答该不该说,从不碰队列。
- 阈值 QUEUED_RESEND_SILENCE_THRESHOLD = 3。理由:不许取 1~2(那等于根本没告知);第 1 次失败
  就是「刚进队列」那一刻,必须说;第 2、3 次仍允许开口(连接抖动后还是没连上,用户需要重新
  知道);第 3 次仍未成功说明已在反复失败里,再说只是把同一件事刷第二遍,从此停下。
- 记账口径:本会话的一个**补发批次**(= 队列从空到非空的那一段),与告知本身的记账同口径,
  不新造第二个概念;按单条记账会漏掉「每条只失败一两次、整批却一直没发出去」这种要静默的情形。
  落在 ChatViewModel 的实例字段上:VM 是按会话建的,所以天然按会话,也不引入全局单例——别的
  会话的反复失败不该把本会话的告知掐掉。
- 计什么:只计「补发没能上路、内容仍留在队列里」那种失败(它是唯一会反复发生的一种);已被
  服务端裁决 / 已回滚的那两类是一次性了结(内容退回输入栏并给了明确原因),不计数。
- 清零时机:**补发真的成功一次**就归零(WS 帧交出去、或回落 REST 被服务端收下)⇒「静默之后
  连接恢复并补发成功」之后,下一次离线**又能重新告知**,不会永久闭嘴。
- 静默不等于丢弃:这一个数只影响说不说,队列内容与补发的下一步一个字节都没动,连接一好照旧
  按序补发(W38/W40/W41 那条硬要求照旧成立);气泡上「排队中 · 还没连上」的小标也一直挂着。

既有的两句队列告知(排队中那一句 / 离线那一句)一字未改。

测试:ShareNoticeTest 与 ImageSendPlanTest 的超限断言改成新事实(不删断言);新增
QueuedSendSilenceTest 钉住那条规则的四个边(第一次失败告知、阈值前仍告知、阈值后静默、成功后
清零)以及「阈值不许取 1~2」「静默不动队列」两条性质。
assembleDebug 成功;testDebugUnitTest 1150 测试 / 0 失败 / 0 错误(基线 1143,本批新增 7)。
2026-10-05 15:21:24 +08:00
AskaEth f3ee2e0d7b docs(05): 锚点补记十四——W42 图片入口改多选(N 张按既有切分落成 N 条消息、切分抽成唯一纯函数由输入栏与分享共用),并登记两处待主人过目(N>=2 时偏好只套第一条、超限文案带「分享的」字样) 2026-10-05 14:42:20 +08:00
AskaEth 4355ebddea merge: W42 —— 图片入口改多选,N 张图按既有切分落成 N 条消息 2026-10-05 14:41:46 +08:00
AskaEth 5a9f52a069 feat(chat): 图片入口改多选,N 张图按既有切分落成 N 条消息
主人 2026-10-05 定案「那肯定要多选」:图片按钮从 PickVisualMedia(单选、回一个 Uri)换成
PickMultipleVisualMedia(系统 Photo Picker 的多选形态,仍然零权限,不退回落 SAF / 存储权限)。
文件按钮那条 OpenMultipleDocuments 一个字没动。

为什么复用分享路径那一段(不另起一套发送逻辑):
分享落地(ChatViewModel.sendShared)早就把「一条消息只能带一张图 ⇒ N 张图 = N 条消息」做完了,
那段切分本来内联在它自己的 forEach 里,不是可复用的纯函数。这次把判据抽成纯函数 imageSendPlan
(ui/chat/ImageSendPlan.kt,跟 SharePayload.kt 与 SendQueue.kt 一样的写法,可单测),投递抽成
emitPlannedMessages(私有),输入栏那条路与分享落地那条路共用同一段:两处各写一份循环,迟早会在
「哪条带正文 / 哪条带附件 / 哪条清草稿 / 超过上限怎么办」上分叉。每张图各自的解码 / 缩放 / 重编码
与「内联还是上传」的取舍仍旧逐张走既有实现(deliver 与 deliverImage),一行没改。

为什么 N 张 = N 条(而不是一条带多张):
契约里 chat.image 是单图字段,一次上行只能带一张,所以多选进来的 N 张只能落成 N 条消息:
第一条带正文 + 第一张图 + 全部附件,其余每条一张图,顺序 = 用户在多选器里选的顺序。契约与服务端
一个字没动。

上限与告知沿用分享路径的口径:
上限仍是 ShareRules.MAX_IMAGES(4)。超限那几张不静默丢,如实告知;那句文案抽成
ShareRules.truncatedImagesNotice,分享入口与选图入口共用同一句(逐字未改,ShareNoticeTest 钉住)。
系统选择器自己会把可选张数卡在 4 上,只有回落成 ACTION_OPEN_DOCUMENT 的设备(它表达不了最多几张)
以及「草稿里已有几张、再选几张」这两种情形会真超,那时由入口与投递两处说出同一句。

其余口径与今天一致:
输入栏最多攒 4 张(到顶后不再显示图片入口),每张各自可移除;取消选择什么都不做;
发送时第一条带正文并清草稿(N 条是一次发送,草稿只清一次),失败的那一条只把自己那一张还给输入栏。
一次发送切出的多条里只有第一条套用「她忙时发消息」偏好——第 2 条起声明打断就是打断自己刚起的
上一轮(与分享路径不声明同一条理由),N = 1 时与今天逐字节相同。

顺带:
- ChatUiState.draftImage(String?)改为 draftImages(List<String>),rollbackPending 把失败那张补回
  待发列表(不再覆盖),输入栏渲染成每张各自带移除键的一排预览;
- QueueFullOutcomeTest 里断言单图的四处改成新事实(断言一条没删);
- 新增 ImageSendPlanTest,覆盖 N = 0 / 1 / 2 / 4 / 5(超限),并钉住附件只跟第一条、顺序不重排、
  超限那句与分享路径同一句。
2026-10-05 14:41:21 +08:00
AskaEth 97992bdb6b docs(05): 锚点补记十三——W41 三级阶梯(未连接先探 health,通就回落 REST 发出去、不通才进队列),含 Offline 为何不探测、gapCursor 那个被补掉的洞、以及图/附件走不到这条路的边界 2026-10-05 14:35:14 +08:00
AskaEth eb22521269 merge: W41 —— 未连接时先探 health,通就回落 REST 发出去,不通才进队列(三级阶梯) 2026-10-05 14:34:12 +08:00
AskaEth 9f77fb469c feat(chat): W41 未连接时先探一次健康端点,通就回落 REST 发出去,不通才进队列
主人第二次定案:「未连接状态你就不能拿 httpAPI 尝试吗?」→ 明确为「可以走 health 端点
验证连通性」。W40 的「未连接就进队列」保留为最后一级,队列、气泡小标与那套柔和告知原样复用。

## 为什么先探测再决定,而不是直接排队
网关只有一条:POST /api/v1/chat 与 /ws 是同一个端口上的两条车道。握手被挡(代理不转发
Upgrade、中间设备掐长连接)时 REST 往往是通的,直接排队等于把「WS 这条车道暂时不通」当成
「后端不可用」,让用户白等一次重连——而那一刻这条消息本来就能发出去。

## 三级阶梯(判据是纯函数 sendRouteFor,见 ui/chat/SendFallback.kt)
1. WS 就绪 → 照旧走 WS。成功路径的字节与状态机一个字没变:sendThroughLadder 的第一件事
   仍然是 sendChat,前面不加任何判断、不挂任何起点。
2. 未就绪 → 探一次 GET /api/v1/health(2.5 秒整通上限,只看 2xx;失败 / 超时 / 抛异常一律
   按不通处理,fail-closed)。通 → 用既有的 POST /api/v1/chat 把这条发出去(短文本也走
   REST);不通 → 进队列。
3. 回落 REST 失败:网络类与超时 → 进队列;服务端给过裁决的(明确拒绝、401)与配置类错误
   (证书 / 明文被禁 / 地址格式 / 解析不出来)仍旧回滚加文案,不装成「还没连上」。
4. 补发走同一个函数(fromQueue=true):阶梯只有一份实现,两条路不会分叉成两种行为。

Offline 那一档直接进队列、连探测都不做:它的语义是握手 401 且续期也失败(会话已知是死的),
而健康端点不鉴权(Cyrene_v2/internal/services/gateway/routes.go:51)必然回 200 —— 探了只会
发出一次注定 401 的 REST,把 W40 那句「已排队不会丢」换成一次回滚加一句误导人的鉴权错误。

## 为什么重发是安全的
回落与补发用的都是那条乐观气泡的 localId,也就是上行幂等键 clientMsgId,一个字节都不新铸。
服务端按 (session_id, client_msg_id) 唯一索引仲裁:重复到达只回 chat_duplicate /
duplicate:true,不再落库、不再起回合(契约 v0.56 §3.13)。于是「超时了但服务端其实已经
收下」这种最难判的情况不需要本端猜。

## 回复怎么回来
那一刻没有 WS 连接,内容帧没人收,靠重连时的正向增量补回来(gapCursor → catchUp →
loadAfter → absorb)。本 VM 从没连上过时 gapCursor 是 null、重连那一步什么都不会拉,所以
回落发出去且服务端已受理之后补记一次游标(armGapCursorForFallback,只在它还是 null 时补)。

## 其他
- 探测结果不缓存,每条消息最多探一次(只有 WS 没把帧交出去才问);WS 就绪那条路上一次探测
  都不发。
- 补发从同步循环改成单飞协程:阶梯里要探测、要发 REST,会挂起,「两个触发点都在主线程上
  所以不可能交错」这条旧论证不成立 ⇒ 改用 drainingQueue 单飞闸加串行出队,出队仍在有结论
  之后。
- 离线时的上界:探测不通就直接进队列、一次 REST 都不会发,用户最多等探测那一段(约 2.5 秒);
  回落那一次 REST 另有 10 秒上限(stream:true 的回执本该毫秒级回来)。
- 新增测试 SendFallbackTest(17 例):优先级、fail-closed、Offline 不探测、回落失败分档、
  探测超时落在 2 到 3 秒、有图或有附件的消息压根不进这层阶梯。
- devtools/fake-backend 补上 GET /api/v1/health,否则本机跑假后端时这条阶梯会退化成直接排队。
2026-10-05 14:33:34 +08:00
AskaEth 7b981dd88a docs(05): 补记十二补齐——W40「未连接进队列」的真机回归结果(断线时不退回不报错、恢复后只补发一条),并记下新 AVD 的实测点击坐标 2026-10-05 14:08:28 +08:00
AskaEth 3611455357 docs(05): 锚点补记十二——AVD 调参第二轮(缩小到 1080x2340、vCPU 降到 5、删 skin 才让 hw.lcd 生效),并记下「无窗口会降级成软件 GL,反而更慢」这条与直觉相反的实测 2026-10-05 14:00:56 +08:00
AskaEth 31e8e31679 merge: W40 —— 普通发送未连接时进队列(不再回滚),连接恢复后按发送顺序自动补发 2026-10-05 14:00:10 +08:00
AskaEth 67b2e58fdf docs(05): 锚点补记十一——W40 普通发送未连接时进队列(主人定案落地),并登记 REST 未覆盖与三条待办
记下这一批的口径与证据,供下一个会话接续:

- 分支 feat/W40-send-queue-offline(基线 e60ae7a),代码与测试提交 df9fc85;未合并、未推送。
- 证据:assembleDebug 成功;testDebugUnitTest 1118 / 0 失败 / 0 错误 / 0 跳过(83 套件)。
  本批没有真机回归。
- 判据:wsSendOutcome 三档(Dispatched / Queued / Failed);补发触发点是连接状态流变成
  Connected 那一刻,无轮询;顺序 = 用户发送先后顺序;只发一次的保证点是队头加出队恰好一次,
  第二道是复用同一个 clientMsgId。
- 状态表达:不新增第七态(等连接仍是 ③ 排队中),气泡小标加成「排队中 · 还没连上」,
  另有一条柔和的 sendQueueNotice 告知(一轮队列只说一次,Offline 那一档说的是
  重新登录 / 检查服务器地址)。
- REST 通道(长文 / 图片 / 附件)本批未覆盖,仍走回滚加既有网络错误文案,理由与影响面写在
  补记里。
- 待办里更新了四条:真机回归、REST 要不要也排队(需主人定案)、Offline 立刻告知、
  以及那一处刻意未动的失败文案字面量。
2026-10-05 13:58:20 +08:00
AskaEth df9fc85f76 feat(chat): 普通发送未连接时进队列(不再回滚),连接恢复后按发送顺序自动补发
主人 2026-10-05 裁决:普通发送在「未连接」时进队列。

此前 deliver 拿到 sent=false 就 rollback:气泡摘掉、正文退回输入栏,外加一句「还没连上,
这条没发出去,请稍后手动重试」。内容是没丢,但把「网络还没好」说成了一次要用户自己负责的
失败,而且他还得对着输入栏再发一遍。这一批按 W38「分享落地等连接」那套语义重做普通发送:
不报错、内容不丢、连接恢复后自动补发。W38 的落地时机、PendingShareHolder.take() 的原子语义、
ChatSocket 的连接状态机一个字都没动。

① 三档处置:未就绪就排队(新纯函数 wsSendOutcome,ui/chat/SendQueue.kt)
判据的输入只有两个:sendChat 的回值,以及这一刻 socket 自己的连接状态。sent=true ⇒ Dispatched
(帧已经交给 socket,成功路径一个字没动:不打标记、不说告知、不写任何状态);sent=false 且
连接就绪 ⇒ Failed(连接是好的,那 false 只可能来自 socket 对象已空 / 已被 cancel / 序列化抛
异常,「发送失败」在这一刻是事实 ⇒ 走今天那条 rollback + SEND_FAILED_MESSAGE);sent=false 且
未就绪 ⇒ Queued(ChatSocket.send 第一行就按状态挡掉,帧连序列化都没做、服务端一个字节都收不到,
所以既不回滚也不进 error 横幅)。未就绪四档(Idle / Connecting / Reconnecting / Offline)
一视同仁:主人的原话是「未连接就进队列」,Offline 只是不会自己变好,内容仍然一个字节都不会丢,
两档的差别只在告知措辞(这一点与 W39 给分享落地定的规矩一致)。

② 队列与补发:顺序与「只发一次」的保证点
deliver 的 WS 分支只改了「未就绪」这一支:queueForConnection 把这条留在待发态
(ChatUiState.queuedForConnection,条目只有 localId 与投递那一刻定下的并行声明;正文 / 图片 /
附件仍从那条气泡上现取,同一件事不留第二份真相源,也就不存在两条真相分叉)。触发点只有一个:
socket 状态变成 Connected 那一刻(onConnectionChanged,由状态流驱动、没有任何轮询),外加
入队之后的一次竞态兜底(连接恰好在投递与入队之间就绪时,那一次补发会扑空)。补发从队头开始,
FIFO 顺序 = 用户发送的先后顺序。
「只发一次」的保证点是「队头 + 出队恰好一次」:队头只在这一步有了结论(发出去了 / 真失败 /
气泡已经不在了)之后才被 dropQueuedSend 摘掉,所以重复的触发拿到的是同一个键、不会多出第二次
出队;flushQueuedSends 是同步函数(一个挂起点都没有),两个触发点在主线程上不可能交错。
第二道保证在服务端:补发复用同一个 clientMsgId(绝不铸第二个),真重复到达也只会被回
chat_duplicate,不会落成第二条消息。补发又失败时按今天的失败路径处理(回滚 + 文案),
绝不静默吞掉。已知边界(写在 KDoc 里):打断声明不随补发重放——补发时在跑的那一轮早就不是
用户按发送时看到的那一个了,隔半分钟再去打断可能毁掉他没见过的一轮。

③ 状态表达:仍是六态,③ 里多一句更准的话
不新增第七态:等连接的这条走六态里的 ③ 排队中(phaseOf 的判据从「收到过入队回执」扩为
「收到过回执,或本端因未连接把它留住了」),两类排队的区别由气泡小标说清——新纯函数
pendingBubbleFooter 给 ③ 加四个字:「排队中 · 还没连上」。为什么挂在气泡上而不只是那句告知:
告知八秒就收走,而这是个会持续几十秒的状态,牌子撤掉之后光看「排队中」分不出是「她已经收下、
前面还排着队」还是「连都没连上、一个字节都没上路」。柔和告知走新通道 ChatUiState.sendQueueNotice
(与 shareNotice 分开:两条描述的是不同的动作,可以同时挂着),文案 SEND_QUEUED_NOTICE 是
「还没连上,这条排队等着,连上后自动发出」;Offline 那一档换 SEND_QUEUED_OFFLINE_NOTICE
「现在处于离线,需要重新登录或检查服务器地址;这条还没发出去,已排队不会丢」——不提「连上」
「自动发」,那一档没有会自动到来的连接。不刷屏:一轮「队列从空到空」只说一次(连续几条离线
消息不会每按一次发送就弹一遍),记账在 queueForConnection。

④ 未覆盖:REST 通道(长文 / 图片 / 附件)保持今天的行为
POST /api/v1/chat 那条路仍是失败回滚 + 既有网络错误文案,本批不动。两条理由:一是它给不出
「未连接」这个信号(拿到的只是一次网络错误,而请求可能已被服务端收到、也可能没有),
判据不可靠;二是要补发就得重跑图片重编码与附件上传换存储名,而本地 URI 的读授权可能已经失效。
代价:未连接时发长文 / 图片 / 附件仍要用户手动重发一次;影响面仅这一条通道,WS 那条(短文本,
日常绝大多数消息)已经覆盖。

⑤ 未连接时不再有的那句话
「还没连上,这条没发出去,请稍后手动重试」(W39 加的 SEND_NOT_CONNECTED_MESSAGE)与它的判据
sendFailureMessage 一并删除:回滚那条路现在只在连接就绪时才走,这句话已经没有出口,留着就是
一段描述旧行为的死文案。SendFailureMessageTest 的断言改成新事实(未就绪 ⇒ 排队,而不是回滚
加那句话)并连同新的队列判据并入 SendQueueTest,一条断言都没删。另把「空输入什么都不发生」
这条边界抽成纯函数 hasSendableContent,输入栏的 send 与投递口的 emitAndDeliver 共用同一条判据
(行为不变,只是原本写在两处的同一个 if 现在可单测,也不会再分叉)。

测试:新增 SendQueueTest(21 条:三档判据穷举 WsState.entries、两档告知措辞、队列顺序与出队
恰好一次、气泡小标与六态、空输入边界)。assembleDebug 成功;testDebugUnitTest 1118 条
(1101 减 4 加 21),failures 0 / errors 0。
2026-10-05 13:57:54 +08:00
AskaEth e60ae7aecc docs(05): 锚点补记十——W39 三处措辞不诚实已修(告知不再两句叠加 / 未连接与发失败分开说 / Offline 不再承诺自动发出),并登记两处刻意未做与一条主人定案项 2026-10-05 13:43:01 +08:00
AskaEth b55d220e28 merge: W39 —— 分享告知不再两句叠加;未连接与发送失败分开说;Offline 不再承诺「连上后自动发出」 2026-10-05 13:42:15 +08:00
AskaEth a763592ace fix(chat): 分享告知不再两句叠加;未连接与发送失败分开说;Offline 不再承诺「连上后自动发出」
三处「对用户不够诚实」的措辞缺陷,各自独立修复。行为上只多了一处「少说一句」,
其余语义一字未改。

① 分享告知两句叠加(ChatScreen.shareNoticeFor)
实机读到的原文是「有 2 个文件读不到,未发送;分享的内容是空的,没有可发送的内容」,
读起来像两个不同的问题。可这两句说的是不同的免责,而「认下了地址却读不到」本身就蕴含
「没有可发送的内容」——叠上去一个字都没多说。故丢件计数为真时不再追加「内容是空的」那句;
其余组合保持今天的行为(只有丢件计数时只说丢件计数;「读不进来」与丢件计数说的是两件事,
照样并列)。KDoc 里记下了这一档今天的真实出口:worthDelivering 要求 isEmpty 的分享必须
有丢件计数或 contentUnreadable 才值得往下走,而后者已被「读不进来」那一档接走,
所以那句加了前提之后一次都不会再出现。

② deliver 的 WS 分支:连接未就绪不等于发送失败(ChatViewModel)
sendChat 回 false 有三种成因(连接状态不是 Connected / socket 对象已空或已被 cancel /
序列化抛了异常),此前共用一句「发送失败,请重试」,用户无从分辨「这条根本没上路」
与「发出去了但失败了」,而后者在连接未就绪时不是事实。现在按失败那一刻的连接状态分开说:
不是 Connected 时说「还没连上,这条没发出去,请稍后手动重试」,仍是 Connected 时逐字保持
「发送失败,请重试」。回滚照旧(摘掉气泡、内容退回输入栏、记下幂等键),没有自动重发,
普通发送的其它语义没动。判据抽成纯函数 sendFailureMessage(WsState),用例覆盖
WsState.entries 的每一个取值。连接状态取 socket 自己的 StateFlow——那正是 sendChat 判的值,
不用界面镜像过来的 ChatUiState.connection,免得晚一帧读到 Connected 反而说错话。

③ WsState.Offline 不再等一句永不到来的「连上」(ShareLanding / ChatScreen)
Offline 是终态(握手 401 且续不上、或缺少 token / 服务器地址),它不会自动重连。
对它说「连上后自动发出」,用户等的是一个永远不会到来的连接——拿一句不兑现的承诺糊住
一个需要他动手的状况,比不说更糟。新增 ShareLanding.TellOffline:等够久、同一轮只说一次
这些时机与可恢复的那几档完全一致,只有措辞不同:「现在处于离线,需要重新登录或检查
服务器地址;分享的内容一个字节都没发出去,不会丢」。W38 的落地时机一个字没改:
仍然不 take()、不发送,连接恢复后照旧自动补发。哪档配哪句由纯函数 shareWaitingNotice 定,
不再散在 Compose 的 when 里,于是「Offline 换口径」这件事不需要组合界面就能测。

测试:ShareNoticeTest 把「两句都在」的断言改成新事实并穷举同族组合;ShareLandingTest 新增
Offline 的差异、时机与文案用例;新增 SendFailureMessageTest 覆盖每一种 WsState。
assembleDebug 成功;testDebugUnitTest 1101 条,failures 0 / errors 0。
2026-10-05 13:41:52 +08:00
AskaEth 4eaee04255 docs(05): 补记九补齐 W38 的真机回归——断线分享得到柔和告知且内容不丢,恢复连接后自动补发且只发一次 2026-10-05 13:25:08 +08:00
AskaEth f15f0bf9bc docs(05): 锚点补记九——W38 分享「等连接」缺陷已修(四态纯判据 + 只在该发时才取走内容)、W37 设备验证通过、并行误标的设备证据、以及两条测量教训
顺带登记三处未修的小账:告知文案两句叠加、deliver 对未就绪与发失败仍不分、WsState.Offline 缺少专门的措辞。
2026-10-05 13:22:10 +08:00
AskaEth d8228fb315 merge: W38 —— 分享落地必须等到连接就绪:超时不再等于发送失败,内容不再从队列丢失,连接恢复后自动补发 2026-10-05 13:21:26 +08:00
AskaEth aeb37458e8 fix(chat): 分享落地必须等到连接就绪,超时不再等于发送失败
为什么超时不能等于发送失败
WS 还没连上时投递的分享(ACTION_SEND)此前走成了「发送失败,请重试」,内容还从待发队列里
永久消失。来处是「等连接」那句 withTimeoutOrNull 的返回值被丢掉了:超时与成功走同一条下游,
照样 take() 再 sendShared → deliver → ChatSocket.sendChat 回 false ⇒ ChatViewModel 落到
rollback(localId, "发送失败,请重试")。实机取证(2026-10-05 11:56):GET /ws/chat status=200
前后的窗口里投递分享,界面上是失败横幅 + 正文退回输入栏,而后端零请求(POST /api/v1/chat
一条都没有,也没有任何 error 帧)——那条横幅把「一次都没尝试发出去」说成了「发送失败」。
ShareIntake.take() 是原子出队且没有归还路径:那一次「其实一次都没发出去」的失败之后,内容
既不在 holder 里、也不在服务端,只剩回滚进输入栏的那一份(多图分享里除首条之外,连输入栏
都回不去)。重试资格因此一起丢了。所以判据只有一条:没连上就一个字节都别动——不 take()、
不发送、不产生任何 error 横幅。

为什么取走时机必须晚于连接就绪
短文本走 WS chat 帧,而分享冷启动必然撞上握手还没完成;帧发不发得出去完全取决于握手结果。
所以等待里不再有「到点就照发」这一档,只有「连上才发」:连接恢复之后由连接状态自己把这条
落地重新触发一遍。

改法
- 判据抽成纯函数 shareLanding(ui/chat/ShareLanding.kt,可单测):没有待发 ⇒ Nothing;已连接
  ⇒ Send(压过后两条,且返回的是单值动作,所以发送与告知不可能同时发生);未就绪且还没等够
  ⇒ KeepWaiting;未就绪、已等够、还没说过 ⇒ TellWaiting;已经说过 ⇒ KeepWaiting(不刷屏)。
- ChatScreen 的那个 effect 把连接状态并进键,连接后来就绪会自动重跑、自动补发;「不会重复
  发送」仍然由 ShareIntake.take() 的原子性兜住——哪怕某一次拿着已经过期的快照进来,take()
  给出的也只是队列里的下一条或 null。
- 等太久只做一件事:经既有的柔和通道 ChatViewModel.showShareNotice 说一句「还没连上,分享的
  内容会留着,连上后自动发出」(不是带 X 的 error 横幅),同一次等待只说一次。
- SHARE_CONNECT_TIMEOUT_MILLIS 改名 SHARE_WAIT_NOTICE_MILLIS:它不再是发送的时限,只是
  「等多久才值得说那一句」;「等够久」的计时只跟「队列里有没有东西」与「连上了没有」走,
  连接在 Connecting / Reconnecting 之间抖动不重置它。
- 普通发送路径(输入框、图片、附件)一行未动。

测试
- ShareLandingTest(新,7 条):未就绪在任何连接状态下都恒不 Send(不取、不发);未就绪且没
  等够恒 KeepWaiting(也不告知);等够了只说一次、说过了就不再刷屏;已连接恒 Send(压过告知);
  无待发恒 Nothing(任何连接状态下都不做事);文案必须说清「还没连上」与「自动发出」,且不含
  「失败」「重试」。

assembleDebug 与 testDebugUnitTest 全绿(1091 tests / 0 failures / 0 errors)。
2026-10-05 13:21:09 +08:00
AskaEth 44b58db58a docs(05): 锚点补记八——分享路径设备验证成绩单(文本热/冷启动、超长走文件三条通过;图片受阻于工具)+ W37 说明 + 三条新环境坑(黑屏/adb root 冲掉 reverse/system_server 崩)+ 并行误标问题的取证结论
把「adb 造不出带授权的 URI intent,别再花时间」与「黑屏会让 input tap 全部落空、先确认屏幕是亮的」写成纪律。
2026-10-05 13:13:05 +08:00
AskaEth a2350a6d5f merge: W37 —— 分享认下字符串形态的 EXTRA_STREAM(String[] / ArrayList<String>),并在一条内容都没读进来时如实告知,不再静默 2026-10-05 13:09:22 +08:00
AskaEth 0764bc269c docs(02): 客户端答复回执十九——给出那次分享失败的精确时间锚点(11:56:08 握手成功后 ~10 秒内)与「无 error 帧、帧根本没发出」的证据链,并采纳「把允许发送绑在握手完成之后」
同时确认 kind/leg 的用法边界(kind 只做展示、归属只看 messageIds),以及语音按 v0.67「契约先行、未落地」处理。
2026-10-05 13:09:18 +08:00
AskaEth 6cdf111419 fix(share): 认下字符串形态的 EXTRA_STREAM,并在一条内容都没读进来时如实告知
为什么字符串形态必须认
真实世界里 EXTRA_STREAM 有三种形态:规范推荐的 Uri(多选时 ArrayList<Uri>)、String[]、
ArrayList<String>。Android 上确实有一部分发送方(历史遗留 / 跨平台壳应用)在用后两种,而本端
此前完全认不出:getParcelableExtra 遇到 String[] 只往 logcat 打一句 ClassCastException 再回
null,getParcelableArrayListExtra 同理。实测 am start ... --esa android.intent.extra.STREAM
uri1,uri2 投两张图进来就是这个样子。

为什么静默是 bug
上面那次分享的结局是:后端零请求、库里零新增、界面连一句软告知都没有。用户点了分享,没有任何
东西告诉他"没读进来"还是"在路上"——这是最糟的那种失败形态,所以本次一并消灭:带着内容迹象却
一条可用内容都没提取出来时,走既有的柔和通道说一句"这次分享的内容没能读进来,没有发送任何内容"。
EXTRA_TEXT 与 EXTRA_STREAM 都为空(用户确实没分享任何东西)时仍然保持静默,那条边界有测试钉住。

做了什么
- ShareIntake 改成读 EXTRA_STREAM 的原始值再按形态分派(Bundle.get 不做类型检查),Uri / Uri
  数组 / String / CharSequence / 数组 / 列表一并摊平;顺带不再往 logcat 丢那两行 ClassCastException。
- 字符串形态只认 content://:那是本端新开的一条读入口,送进来的只是一段文本,没有任何框架级担保。
  file:// 等于让发送方替本端指定一个本地路径、由本端拿自己的权限去读它;http(s):// 是网络地址,
  收下它等于让第三方的一行文本指定一个 URL 由本应用代取。被拒的条目逐条跳过,不让整次分享失败。
- 优先顺序与去重不变:EXTRA_STREAM 的规范形态 -> 字符串兼容形态 -> ClipData(仅前者全为空时兜底),
  trim / 去空 / 去重,先来的先算;重复项既不算截断,也不把真图挤到上限之外。
- 「带着内容迹象」的判据取 EXTRA_STREAM 的原始值(由调用方读了原始值判出来,含形态本端认不出的那种),
  不是「认下来的地址」——否则「有值但一条都认不出」恰恰会被判成「什么都没分享」而继续静默。
- SharedContent 新增 contentUnreadable 与 worthDelivering:后者是唯一判据,ShareIntake 用它决定
  要不要往下接,shareNoticeFor 用它守卫"什么都没有就一个字都不说"那条边界,两处不会走岔。
- 分类(图片 vs 文档)、落 cache、截断与拷贝失败的既有语义一概不动。

测试
- ShareExtractionTest:字符串形态的顺序与逐条跳过、两种形态并存去重、scheme 白名单表、
  读不进来 / 形态认不出 / 静默三条边界。
- ShareStreamExtrasTest(新):按真实对象验形态分派(String[] / ArrayList<String> / String /
  CharSequence / 认不出的形态不猜但照样算带着内容),并接到提取层验一次端到端。
- ShareNoticeTest(新):"没能读进来"成句且不说成发送失败、正常落地一个字不说、空白分享仍然静默。

assembleDebug 与 testDebugUnitTest 全绿(1084 tests / 0 failures / 0 errors)。
2026-10-05 13:05:20 +08:00
AskaEth ef257c3167 docs(05): 锚点补记七——并行两路实机验通(三条判据)+ leg 误报并行已修(W36)+ 假后端横幅两档验过 + 模拟器调参(iowait 196%→14%)+ 分享接入其实早已实现
把「别用 leg 判并行、只看 turn_begin.kind」写成纪律,并记下 ANR 判据的正确做法(拉 trace 看主线程栈,别先怀疑代码)。
2026-10-05 12:01:18 +08:00
AskaEth 405388e325 docs(02): 客户端答复——回执十八那两个问题的答案(都不解析正文标记、单段样式零差异)、把 turn_begin.kind 立为形状依赖(角标已改以 kind 为准)、并行两路实机验通、分享发送失败一次的观察
为什么不提交后端会话的在途追加:他们正往 02 里写回执十八(未提交),按 02 的协作纪律处理——先另存他们的补丁、把文件还原到 HEAD、只提交我方这一节,随后再把他们的内容原样贴回末尾。两边都不丢,也不把他人的在途工作算作我的提交。
2026-10-05 12:01:14 +08:00
AskaEth 39a63f8d30 fix(chat): 并行路角标改以 turn_begin.kind 为准,不再拿 leg 冒充并行
leg 是服务端**会话内的路序号**:beginLegLocked 对每一条新路都自增 legSeq,
普通串行回合照样占号——会话里发第二条普通消息,leg 就是 2。而角标旧判据是
leg >= 2,于是那条串行消息被挂上「并行路 2」,凭空声称存在并不存在的并行。
「是不是并行路」真正的判据是 turn_begin.kind(turn = 普通串行回合,
parallel = 忙时另起的一路);解析层本来就把它读出来了,只是绑定时丢掉了。

- TurnBinding 新增 parallel;withTurnBegin 收下 kind,由 isParallelLegKind
  判定:只有逐字等于 parallel 才算,turn、缺席、空串、大小写不同一律不算。
- 合并规则:新建按 kind 定;同 turnId 重复到达时取或(old.parallel || 本帧
  是并行),一旦为 true 就永远是 true。与 closed 同一条纪律——单调的既成事实
  不许被后到的帧翻回去;也顾及 steer_ack 可能先建一条没有 kind 的占位表、
  随后才由这一路自己的 turn_begin 补上并行的现实顺序。
- 降级:kind 不来的老服务端上 parallel 恒为 false,角标一枚都不挂——宁可少挂
  一枚,也不拿 leg 去猜、去多说一句假话。
- legBadgeOf 改成同时看 parallel 与 leg,三处调用点(提问侧、回复侧、流式气泡)
  都跟着传 parallel;文案仍是「并行路 N」。

测试:ParallelTurnsTest 补 kind=turn / kind=parallel / kind 缺席三档、重复帧的
合并(含「已收口的并行路不被重新打开也不被降级」);TurnStreamsTest 把
「leg>=2 就挂标」的断言改成以 kind 为准,并补上那条真机回归——会话里第二条
普通消息(leg=2、kind=turn)在提问侧与回复侧都不挂角标。
2026-10-05 11:59:21 +08:00
AskaEth 6e00697110 feat(chat): 分享进来的消息加一枚「用户分享」渲染层角标
背景:ACTION_SEND 分享进来的内容在会话里与手打的完全看不出区别,主人要求「可识别」。
与后端定过的红线(devdocs/02 的 F4、ShareRules 的「为什么正文一个字都不加」)要求人类可见
标记只能放在客户端渲染层,所以本次正文、content 与任何上行字段一个字节都没动。

改了什么:
- ChatViewModel 的 emitAndDeliver 多一个 localIdPrefix 参数(默认 local-,手打那条路逐字不变),
  sendShared 的每一条投递都传 share-:多张图会发多条消息,它们都是分享进来的,所以每条都带。
  前缀只加在幂等键前面,后面仍是随机 UUID,唯一性不受影响。
- MessageSource.kt 新增两个常量与两个纯函数:isSharedClientMsgId 只认 share- 且要求后面还有尾巴
  (恰好 share- 一律 false:本端铸键永远是 share-uuid,没有尾巴的键不是本端铸的,不能替一条
  来路不明的消息声明「这是你分享的」);isSharedMessage 在它之上加一道只认 user 侧的闸。
- MessageBubble 的 bubbleFooter 多一个 shared 参数,角标 [用户分享] 紧挨来源角标拼在角标行里,
  沿用这行既有的字级与颜色,不新造样式、不用 Material3,也只对用户侧气泡出现。
- 新增 SharedMessageBadgeTest:判据的六类边界取值与角标行里的落位。

为什么用幂等键而不是正文标记:clientMsgId 由服务端原样回填,是客户端读得回来的既有字段,
「这条怎么进来的」写在它上面不用新增任何协议,也不污染用户正文。
2026-10-05 11:30:56 +08:00
AskaEth 5d4888ab71 docs: 历史回填决策落地——做(带三条约束:先 dry-run / 留回滚点 / 一次性不留常驻逻辑),02 给后端请求、05 记决策 2026-10-05 11:17:15 +08:00
AskaEth 108feefac4 docs(05): 锚点补记六——文档已拆分(含新的文件地图与写回规则)+ <p> 规整上线(5de708a,我方零改动)+ 三件待办 + 一条待主人拍板(历史回填) 2026-10-05 11:15:53 +08:00
AskaEth 681ae96a9c docs(02): 收下后端会话的追加(回执十七·补2:<p> 规整已落地 5de708a,写入侧收口、我方零改动)+ 合并 W34 的拆分 2026-10-05 11:14:22 +08:00
AskaEth 95e657e704 merge: W34 —— 拆分 02/05 两份过长协作文档(历史部分归档,内容零丢失) 2026-10-05 11:14:16 +08:00
AskaEth 98b2274e68 docs(devdocs): 二次瘦身——02 里已确认完结的登记条目整块归档,05 收回两段纪律
02-前后端协同事项.md:860 行 -> 454 行;02-archive-历史回执.md:1274 行 -> 1681 行
- 把已确认完结的登记条目整块搬进归档:A(A1~A5)、B(B1~B6)、C(C1~C3)、D(D1~D5)、E(E1~E6)、F3~F5、G1~G4、H1,共 8 块 30 条,按原顺序插在归档前言之后、回执一之前
- 主文件保留:标题与用途、顶部 P0 置顶块、归档说明、登记规范、仍未闭环的登记(F1/F2/F6 与 G5)、附:状态一览表、最近 5 条往来
- 唯一改动原文处:状态表 C1 行的指针「见本文 §C1」改成「见 02-archive-历史回执.md §C1」
- 归档前言新增一行,说明这批条目的来路,以及 F/G 两节的主标题与未闭环条目仍在主文件

05-开发进度与交接.md:601 行 -> 624 行;05-archive-历史批次.md:437 行 -> 418 行
- 从归档里捞回两段纪律(本轮新增约定/坑、派 agent 进 worktree 的硬规矩),放进「坑与勘误」节末尾,各加一个小标题
- 锚点节里加一行指针:历史批次的细节已移到 05-archive-历史批次.md

零丢失的证据
- 逐行多重集比对(对基线 b0b73d0):02 的非空行 1560 -> 1568,正好 +8,即新增的说明行;C1 那处是同一行整行替换,一删一增净零
- 05 的非空行 846 -> 851,正好 +5,即 2 个小标题 + 1 行锚点指针 + 2 行归档前言
- 除上述新增行与空行计数漂移外,行集合与原文件完全一致,无任何内容行丢失或重复
- 12 个代表字符串的 grep -c 拆分前后相等,含跨主文件与归档的 pinned DESC 排序串、834ef36、title must not be blank 三条
- 搬迁全程用 sed 按行区间原样切割、未改写文字;行尾统一 CRLF

未跑 Gradle(纯文档搬运),未 push。
2026-10-05 11:13:28 +08:00
AskaEth cb5fc0c605 docs(devdocs): 拆分 02/05 两份过长协作文档,历史部分归档,内容零丢失
02-前后端协同事项.md:2126 行 → 860 行
- 历史回执(回执一~十六及同时段已闭环的登记)整体搬到新建 devdocs/02-archive-历史回执.md(1274 行)
- 主文件保留:标题与用途、顶部「待后端处理的 P0」置顶块、登记规范、A~I 全部登记条目、附:状态一览表、仍未闭环的登记(2026-10-04 深夜续那条待主人拍板的口径冲突)、最近 5 条往来
- 置顶块之后新增 5 行归档说明:历史回执去向 / 新回执照旧写在本文末尾 / 已闭环的老登记会定期挪进归档 / 本文旧指针去归档里找对应小节
- 不动置顶块、不动状态一览表、不动未闭环的登记,后端会话仍可照旧在本文末尾追加回执

05-开发进度与交接.md:1035 行 → 601 行
- 历史批次细节与已过时的验收现场记录搬到新建 devdocs/05-archive-历史批次.md(437 行):本轮追加(W4–W7)、W8–W10、W11–W12、W13–W14、W15–W16、装机验收轮、分享到 App 验收轮、W27
- 主文件保留全部「★ 恢复锚点」性质的节(当前状态与待办、锚点补记一~五、纪律补记)与当下要用的节(当前状态、构建、验证方式、凭据处理红线、环境要点、后端、模拟器、易忘的实现约定、管理区约定、C-2 验证结论、坑与勘误、后端协同、实机验收方法、三条待拍板项定案)

零丢失的证据
- 全部搬运用 sed 按行区间原样切割,未改写任何既有文字;新增内容只有 02 主文件那 5 行归档说明与两份归档文件开头的 2 行说明
- 逐行多重集比对(awk 计数 + diff):除上述新增行与个别空行外,原文件与新「主文件+归档」的行集合完全一致,无增无删
- 8 个代表字符串的 grep -c 拆分前后相等:679cee0 3=3、18381f6 1=1、guardrail_input 3=3、internal/config/runtimeconfig/models.go:300-311 1=1、回执十七标题 1=1、devtools/fake-backend/ 4=4、W15-retry-2-EDITED 3=3、ChatImageBudget.SERVER_MAX_CHARS 1=1
- 行尾统一为 CRLF(与 devdocs 下其余文件一致)

未跑 Gradle(本次纯文档搬运),未 push。
2026-10-05 11:10:03 +08:00
AskaEth b0b73d063b docs(05): 补一条硬纪律——合并 worktree 后当场清理(本批 8 个全残留、主人先发现的)+ 假后端进程同样是遗留 2026-10-05 10:44:27 +08:00
AskaEth 54e7b35e60 docs(02): 回执十七答复——p 半包裁定=b 写入侧规整(我方零改动、历史一起变好);思考流等「在哪开」;⏳ 帧已上线 ⇒ 视为阻塞解除,开始真机验证 2026-10-05 10:35:50 +08:00
AskaEth 9e6a6b395c docs(02): 把「标签包裹」从观察升级为明确请求——主人要求问「能不能修」,附三条可选路径与必须的裁定 2026-10-05 10:26:43 +08:00
AskaEth 83ea5ddff7 docs(02): 请后端确认两件事——思考流(reasoning.kind 在 anthropic 组上非法,开关在协议原生 thinking 形状里,谁决定?)+ 一条回复没拆气泡(产出侧 <p> 包裹不完整,非客户端解析问题) 2026-10-05 10:25:28 +08:00
AskaEth e1223fdee5 docs(05): 锚点补记五——忙时并行整批(W28-W33 + FB2)+ 思考流排查结论(reasoning 键在 anthropic 组上非法,开关在协议原生 thinking 形状里,待查)+ 环境与教训 2026-10-05 10:23:54 +08:00
AskaEth c3cfef75bb merge: FB2 —— 假后端加 --hold-replies(收到消息不自动落库),让「忙时被拒」的横幅可稳定复现 2026-10-05 10:23:40 +08:00
AskaEth c73dd532ff feat(devtools): 假后端加上行挂起开关 --hold-replies,让「忙时被拒 / 排队 / 队列满」可稳定复现
为什么
客户端手里还挂着一条没发出去的乐观气泡时,收到「队列满 / 忙时被拒」会怎样,判据是
ChatViewModel.queueFullOutcome(本会话待发 0 条 ⇒ 刻意什么都不说、1 条 ⇒ 回滚 + 横幅、
≥2 条 ⇒ 一个气泡都不动、只提示「可能没发出去」)。但假后端收到上行 chat 或
POST /api/v1/chat 时会立刻把那条用户消息落库并广播 message_persisted,客户端拿
clientMsgId 一比就把乐观气泡收走(consumePendingFor)—— 一个往返之后待发列表就空了,
那三档里只剩「0 条」能凑出来。真后端忙时是先排队或先拒绝、不会立刻落库,所以这是假后端的
建模缺陷,本次修它。

加了什么(只动 devtools/fake-backend/,不碰 app/ devdocs/ 构建文件)
- 启动开关 --hold-replies(默认关):收到上行消息只记录、不落库、不广播 message_persisted。
  上行帧照常进 out/upstream.jsonl、state.wsUpstreamByType 照常计数;回复路径
  (--stream-chunk / --auto-reply / /__control/reply 自己的 persist)一概不受影响 ——
  关的只是「上行那条的落库」这一件事。
- 运行期开关 POST /__control/hold-replies?on=1|0,便于在同一个进程里做「先挂着 → 再落库」两段。
- 显式补落库 POST /__control/persist?session=&id=<holdId|messageId|clientMsgId>&index=N:
  不给选择器就按 FIFO 落最旧那条;落库时才盖 createdAt、才认领幂等键(与「队列满的判定发生在
  认领之前」一致),广播的 message_persisted 与默认路径同形状。挂起列表在 GET /__control/state
  的 heldMessages 里。
- 挂起的消息在挂起时先占号(msg-xxxxxx),所以 REST 回执里的 messageId 与之后真正落库的是同一个。

三处 message_persisted 广播点各自改成了什么
- 原 :657(WS 上行 chat 之后那条)与 原 :1235(REST POST /api/v1/chat 之后那条):受本开关控制,
  挂起时整条跳过(仍记录上行帧)。
- 原 :773(send_reply_round 里助手消息的落库)刻意不动:那是回复路径,本来就有自己的 persist
  开关,本开关不去关它 —— 任务里也明说不要把回复路径一并关掉。已实跑验证:开关打开时
  reply?persist=0 不落库、reply?persist=1 照旧广播 message_persisted。

拒绝场景(任务第 2 项):不需要新造
/__control/pending-full(载荷 {sessionId,mode,max,clientMsgId?,messageId?},与客户端
PendingFullData 逐字对应)、/__control/error?code=chat_busy(带 clientMsgId/messageId)与能发
任意帧的 /__control/frame 早就在了,形状也不缺东西。本次只实跑复核(三帧都在探针那边看到原样帧体、
回执 delivered=1)并写进 README,没有改它们的形状。

默认行为未变
--hold-replies 默认关,state.holdReplies=false、heldMessages=[];关着时三处广播、add_message 的
id 分配(新增的 message_id 参数只在挂起补落库时给值)、REST 回执形状逐字与改动前一致。

验证(本机实跑,端口 18099/18098,绝不用真后端的 10942)
挂起档:ws_probe 发 chat 后 2 秒里零回帧、out/downstream.jsonl 里 message_persisted 计数 0,
而 upstream.jsonl 与 wsUpstreamByType.chat 都记到了;POST /__control/persist?id=local-1 之后才在
活着的连接上收到 message_persisted(delivered=1,id 正是挂起时先占的 msg-000001)。REST 路径同理:
/api/v1/chat 的 202 回执时刻下游流水没有那一帧,2 秒后 persist 才有(时间戳可对)。pending_full /
error{chat_busy} / __control/frame 三帧都投递成功。运行期 on=0 → 立刻落库、on=1 → 挂住,双向有效。
默认档回归:立即落库、灌消息分页、复位都照旧。

README
新增第 6 节「场景四:忙时被拒 —— 让『待发气泡还挂着』可复现(--hold-replies)」:写清乐观气泡被
一个往返收走这个机制、开关怎么用、一条可复制的命令序列(起服务 → 发 chat 挂住 → 注入 pending_full →
期望的横幅文案),以及帧上带不带 clientMsgId/messageId 时客户端四种不同的处置。原第 6-10 节顺延为
7-11 节,并在开关表、控制端点表、记录文件节、已知不确定节各补了对应内容。
2026-10-05 10:23:09 +08:00
AskaEth 1f6c16c590 merge: W33 —— parallel_limit 提示加「本端确在并行档」的闸 + 查证并发额度横幅(结论:代码无缺,是验证条件问题) 2026-10-05 10:13:08 +08:00
AskaEth f2fbdff9a7 W33:并行落回排队的告知加一道本端档位闸 + 忙时被拒那条横幅的只读查证
一、改了什么
加一道闸:pending_ack{reason:"parallel_limit"} 那句降级告知(「她正忙,这条没能另起
一路…」),只在本端当前的「她忙时发消息」档位确实处于「并行」档(InterruptMode.Parallel)
时才给。判据扩了既有纯函数 parallelLimitNoticeFor 的签名(reason + mode 一起判),
本端不在并行档时一律返回 null;调用点在 onEvent 的 PendingAck 分支,档位沿用
currentInterruptMode() 现读(偏好是用户在设置页当场切的东西,缓存住就留一个静默不生效
的窗口),因为 onEvent 不是 suspend 函数,这一句起一个协程去读(与 ensureMessageKnown
同一形态),不起常驻订阅。

二、为什么
pending_ack 是广播帧,每条已认证连接都收得到。极端情况下「别的端在同一会话声明并行
又落回排队、而本端并未选并行」时,本端也会收到这一帧并说那句话 —— 这就是误报。
可那件事与本端无关:用户没选并行时,他这条消息本来就是普通排队、他预期的就是排队
⇒ 落回排队不是意外 ⇒ 不说并不构成静默(与「绝不静默」不冲突)。反过来,在那个时刻
说一句「这条没能另起一路」会让用户以为是自己刚发的那条出了岔子;误报会教用户不信任
这条提示,代价比少说一句大。

三、只读查证(主人点名:忙时被拒那条横幅到底会不会出现)
结论:不缺,pending_full 不带 clientMsgId 时共三档落位,其中两档都写 error,横幅照常
渲染 ——
  · 恰好一条待发气泡 ⇒ 回滚档,error = QUEUE_FULL_MESSAGE(她正忙且队列已满,这条没
    发出去,请稍后手动重试);
  · 两条及以上 ⇒ 说不准档,error = QUEUE_FULL_UNKNOWN_MESSAGE(她忙不过来,这条可能
    没发出去),两个气泡一个都不动;
  · 两档都带 errorIsQueueFullNotice = true(柔和档配色),横幅由 ChatScreen 同一条
    error 通道渲染。
只有「零条待发气泡」那一档是 Ignore,按设计什么都不写:没有气泡会挂住,也就没有要说
的,不能凭空弹一句 —— 理由在 queueFullOutcome 的 KDoc 与 QueueFullOutcomeTest 的两条
测试里(既有,非本次新增)。即「没带 clientMsgId」不等于「说不准档」,本次查证没有
发现需要改代码的地方。

四、测试
新增 3 条(ParallelLimitNoticeTest):追加队列档 + 该 reason ⇒ 不提示;打断档 + 该
reason ⇒ 不提示;再加一条把三档一起摆出来的汇总断言(只有并行档给文案)。「并行档 +
该 reason ⇒ 提示」由既有的那条测试覆盖(现在显式传 InterruptMode.Parallel)。总测试
1030 → 1033,0 失败 0 错误。
2026-10-05 10:12:41 +08:00
AskaEth 79fcd803a0 merge: W32 —— 忙时被拒那条横幅调柔和(只调那一档,真错误仍红)+ 并行额度落回排队时如实说一句 2026-10-05 09:56:22 +08:00
AskaEth e85209eebb fix(chat): 忙时被拒那条横幅调柔和(只它,真错误照旧红)+ 补 pending_ack 的并行降级告知
一、忙时被拒的横幅改用柔和档颜色(它不是错误,不该刺眼)
改了什么:那句告知(她正忙且队列已满,这条没发出去,请稍后手动重试)一直走 state.error 这一条通道,
ChatScreen 里那条 Banner 的调用点对所有 error 一律用 colorScheme.error(正红)。现在给 ChatUiState
加一位 errorIsQueueFullNotice(只决定配色),忙时被拒的两条入口(WS 广播 pending_full 与定向
error{chat_busy} 的队列满处置、REST 409 chat_busy 的回滚)把这一位写成 true,ChatScreen 那一处调用
据此在 onSurfaceVariantSummary(同文件里分享/附件/定位三条告知用的那档柔和色)与 error 之间选色。
为什么:主人裁决形态不变(摘乐观气泡 + 顶部横幅如实告知 + 正文/图片/附件还给输入栏 + 重发复用幂等键),
但红色不要太醒目——忙时被拒不是坏了,是真没排上,红得像故障会吓人。而它与真错误(登录失效、网络失败、
服务端 4xx、图片与附件处理失败……)共用 error 这同一条通道,直接改那个调用点的颜色会把真错误一并调柔和,
所以先分档再选色:写 error 的每一处都显式带着这一位,边界写进了 ChatUiState.errorIsQueueFullNotice
与 Banner 的 KDoc,免得后来人统一改色。

二、消费 pending_ack 的 reason,如实告知「并行没排上、已按普通排队走」
改了什么:reason 字段在解析层早就原样保留(WsEvent.kt 的 PendingAck.reason),但一直没有任何地方消费它
——收到它时界面与普通排队毫无区别。新增纯函数 parallelLimitNoticeFor(reason)(已知值给一句话,未知值 /
缺席 / 空白给 null,不为不认识的 reason 编文案)、常量 PARALLEL_LIMIT_REASON 与 PARALLEL_LIMIT_NOTICE,
以及状态位 ChatUiState.parallelLimitNotice(与 shareNotice / attachmentNotice 同一套机制:8 秒自动收走、
用柔和色、不给关闭按钮),在 WS 的 PendingAck 分支里接上。
为什么:设置页新增并行档之后,开关 ON 不等于一定起第二路——服务端 CHAT_MAX_PARALLEL_TURNS 小于 2 或
额度已满时会让它落回排队,并回 pending_ack{reason:"parallel_limit"}。用户选了并行却看不出区别就是静默,
违反项目铁律「绝不静默」;而这条不是错误、是降级,所以措辞把三件事说清(并行没排上、这条没丢、她做完
手上这句就轮到它),配色与上面那条忙时告知同一档。

测试:新增 ParallelLimitNoticeTest 8 条(reason 的三档穷举 + 横幅温度的边界:忙时被拒为柔和、真错误不
继承那份柔和)。assembleDebug 与 testDebugUnitTest 全绿:1030 个测试,0 失败 0 错误(基线 1022,新增 8)。
2026-10-05 09:55:53 +08:00
AskaEth bb4bb583d5 docs(02): 收下回执十六(形态冲突裁决=调柔和 / turn_id 要加 / 并行开关归前端 / 三处更正经核对均不影响我方 / 文档搬家)+ 我方答复五条 2026-10-05 09:51:01 +08:00
AskaEth 4847d74d87 merge: W31 —— 设置栏「她忙时发消息」追加第三档「并行」(忙时上行 chat.parallel=true,绝不发 interrupt) 2026-10-05 09:38:00 +08:00
AskaEth a776e9f608 feat(settings): 设置页「她忙时发消息」追加第三档「并行」——忙时不打断、另起一路(chat.parallel=true)
主人要求:在既有的「追加队列 / 打断」之下再追加一档「并行」。三档语义互补——追加队列进队列等
(默认档,逐字节不变)、打断先发 interrupt 再发这条、并行不打断、另起一路同时处理。

改了什么:
- AppSettings:InterruptMode 加第三个值 Parallel(wireScope = null, wireParallel = true),并给枚举
  补出第二根线 wireParallel(wireScope 只管 interrupt 的 scope,表达不了「要声明并行」这件事)。
  既有两个值的 name 与落盘键 interrupt_mode 一个字未动——改名会让已存偏好读不出来并静默回落默认档。
  落盘字符串 → 档位的映射抽成顶层纯函数 toInterruptMode(AppSettings 本体要 Android + DataStore,
  单测里起不来),新增的第三个名字能正确读回,未知字符串仍回落 AppendQueue(既有行为未变)。
- 判据抽成纯函数 parallelForSend(mode, busy) 与 parallelWireValue(declare):前者与
  interruptScopeForSend 共用同一个忙判据(ChatUiState.busyMode != null),只在会话正忙时为真。
  规范 §2.2 没写空闲时要不要也声明——空闲时这条本来就是它自己的回合,声明与不声明在服务端是同一
  件事,故取与打断同一个判据(最小惊讶:空闲时三档逐字节完全相同)。
- 两条互斥由枚举取值本身保证,不是靠约定:并行档 wireScope 是 null ⇒ 绝不发 interrupt;另外两档
  wireParallel 是 false ⇒ 绝不带 parallel 键。默认档「追加队列」线上零差异由此结构性成立。
- 发送路径接线:ChatViewModel.deliver 只读一次偏好、同时喂给两个判据(读两次会在用户切档的瞬间
  出现「既打断又并行」);ChatSocket.sendChat 与 ChatRequest 各加一个 parallel 参数,false 一律
  翻成整键省略——显式 false 会被序列化成 parallel:false,那是「显式否认」而不是「未声明」,
  默认档就不再逐字节不变。
- 设置页:新选项标题「并行」,summary 说清后果——不打断她正在说的这一句、两路各算一遍会多花一份
  算力与 token(主人裁决:峰值 token 近翻倍可接受,但用户有权知道)。UI 仍是 Miuix,与同栏另外
  两条逐字同形,勾选标记位置相同。
- 分享路径同样不套用这一档偏好(W26 的 allowInterrupt 因此更名为 applySendPreference,它现在管
  的是整档偏好):分享是批量投递,逐条索要并行会被服务端 2 路额度把同一次分享劈成两种待遇,且每
  条各算一遍;用户也没按过发送键说这条要并行。单张图时同样不声明,规则才能一句话说清。

为什么:主人 2026-10-05 的裁决(真两路并行)需要客户端有一个显式声明入口;W30 已备好 chat.parallel
的线上形状(默认 null = 整键省略),本次只把设置页这一档与发送路径接通,上行仍是纯增量——没选
「并行」的用户线上一个字节都不变。

验收:assembleDebug 通过;testDebugUnitTest 通过(1022 测试 / 0 失败 / 0 错误;基线 1004,新增 18:
ParallelForSendTest 13 条 + InterruptScopeTest 1 条 + InterruptModeTest 4 条)。
2026-10-05 09:37:26 +08:00
AskaEth 82d3ca3953 docs(02): W30 客户端半边登记(结构性降级保证)+ 一条与规范的形态冲突请后端裁决(被拒消息:红气泡 vs 我方既有的横幅+回滚)+ 三条待确认 2026-10-05 09:12:20 +08:00
AskaEth 005720575a merge: W30 —— 忙时并行的客户端半边(六态 + 灰态 + (sessionId,turnId) 两级分桶),帧侧全部可降级 2026-10-05 09:11:19 +08:00
AskaEth 29299b190e feat(chat): 忙时并行回合的客户端半边 —— 六态 + 灰态 + (sessionId, turnId) 两级分桶 + 新帧消费(全部可降级)
依据:Cyrene_v2/DevDocs/_specs/忙时并行-规范草案.md(§1 行为 / §2 接口 / §4 主人裁决)。
后端 v0.65 尚未落地这些帧,所以本次的新键与新帧全部按可降级实现:帧不来、字段缺席时,
App 在今天的真后端上行为与改前逐字相同(下面的「降级保证」逐条给了证据)。

一、改了哪些文件

新增(纯逻辑,可单测)
- app/src/main/kotlin/top/yeij/cyrene/domain/chat/ParallelTurns.kt
  两级分桶键 TurnBucket(sessionId, turnId) 与 turnBucketOf / closingBucketOf;
  六态枚举 TurnPhase 与 turnPhaseOf;灰态收口判据 closesAnswering;
  消息↔回合绑定 TurnBinding 及其纯函数(withTurnBegin / withSteeredMessage / closingTurn /
  allClosed / bindingOfMessage);路小标文案 legBadgeOf。
- app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt
  StreamingLeg(bucket, StreamingState) 与 withLeg / beginLeg / finishingLeg / withoutLeg;
  六态在状态上的推导 ChatUiState.phaseOf(消息 / 乐观气泡);
  路小标与「跳到提问」的查表 legBadgeForMessage / legBadgeForTurn / jumpIndexForTurn。

修改
- data/api/dto/MessageDto.kt:HistoryMessageDto 增可选 turnId(历史行 / 落库行都用它)。
- data/api/dto/ChatDto.kt:ChatRequest 增可选 parallel(REST 与 WS chat 同形)。
- data/ws/WsOutbound.kt:chat 增可选 parallel、interrupt 增可选 turnId,省略即整键不进线上。
- data/ws/WsEvent.kt:新增 turn_begin / turn.end / steer_ack 三个事件与其载荷;
  session_busy / session_idle / pending_drain 增可选 activeTurns,session_interrupted 增
  activeTurns + cancelledTurns,pending_ack 增可选 reason。
- ui/chat/ChatViewModel.kt:流式解析器与「本回合见过结构化正文帧」由单值升为按桶的 map/set,
  新增「本会话最近见过的 turnId」锚点;消费 turn_begin / steer_ack / turn.end;
  助手落库行按其 turnId 收口灰态并只摘掉它自己那一路的流式气泡;排队回执记进 queuedKeys。
- ui/chat/MessageBubble.kt:MessageBubble / PendingBubble 增 phase 与 legBadge,
  正文气泡增灰态配色,新增并行路小标(可点即跳);角标文案抽成纯函数 bubbleFooter。
- ui/chat/ChatScreen.kt:按桶取每一路的流式状态并渲染;传入六态与小标;忙碌行加「并行 N 路」。
- 新增 4 个测试文件(共 82 条):ParallelTurnsTest、TurnStreamsTest、
  WsEventTurnFramesTest、WsOutboundParallelTest。

二、为什么这么做

- 界面必须按 (sessionId, turnId) 两级分桶(规范 §1.7 第 1 件硬要求):两路并行时两个不同
  turnId 的 message.delta / thinking.delta / tool_call 会交错到达,照老代码「一律累加到当前那
  一个流式气泡」会把两条回复糊成一条(规范 §1.4 点名的那件事)。现在每一路各有一份解析器、
  一份思维链、一份工具卡表、一个流式气泡。
- 灰态(④ 已读、未答)的唯一权威收口事件是 turn.end(规范 §1.2)。本端另外在 stream_end
  (既有的「整轮结束」帧,v0.36 起带 turnId)与「同 turnId 的助手落库行」上也收口:
  前者在 turn.end 到位之前是唯一能收口的帧,后者是规范给的兜底。三者顺序不定、重复到达都无害
  (纯函数幂等)。
- 六态判定与收口判据的不变量都抽成纯函数,可直接单测;判定顺序即证据强度:
  被拒 > 已绑定未收口 > 已绑定已收口 > 本地乐观气泡 > 排队回执。

三、字段缺失时的降级保证(本次最要紧的一条)

- 全链路没有一处「turnId 为空就不渲染」。turnBucketOf 在帧壳没给 turnId 时先回落本会话最近
  见过的 turnId、再回落匿名桶(turnId 为空串)⇒ 所有无 turnId 的帧落在同一个桶里,
  那就是老代码那唯一一个流式气泡。
- turn_begin / steer_ack.turnId / turn.end 不来 ⇒ 一条绑定都建不起来 ⇒ 六态里只可能出现
  ①②③⑤,灰态根本不会进入;界面与改前逐字相同。
- activeTurns 缺席时不动这个计数(null 是「不知道」,0 是「确实空闲」,两件事不许互相顶替);
  pending_ack.reason 缺席就是 null,绝不伪造。
- 上行三个新字段(chat.parallel、interrupt.turnId、ChatRequest.parallel)本端今天一个字节都
  不发:真并行要用户显式声明,而声明入口不在本次范围内。服务端照旧走排队 + 回合内 steer。
- 已被单测钉住的证据:
  ParallelTurnsTest「所有无 turnId 的帧落进同一个桶」「两个空白 turnId 不算匹配,绝不收口」;
  TurnStreamsTest「单路时长度恒为 1」「没有任何 turn 帧时已落库=②、本地气泡=①」;
  WsEventTurnFramesTest「老形状的帧解出来新键一律为 null(不是 0、不是空串)」;
  WsOutboundParallelTest「省略时那条帧逐字节相同,且不带该键」。

四、等 v0.65 的部分

- turn_begin / steer_ack.turnId / turn.end 的真实到达,历史行的可选 turnId,activeTurns /
  pending_ack.reason / cancelledTurns 的真实取值。
- 真并行的触发入口(谁在什么时候把 chat.parallel 置 true)——本次只备好线上形状,不接 UI。
- 仍未决且影响客户端的两项:graph_update 两路互相顶掉(规范未决 10,等 design/19 裁决);
  按 turnId 单独核算用量(规范未决 14,可选)。

五、规范里与本端既有定案冲突的一处(如实登记)

规范 §1.1 ⑥「被拒」要求气泡带红色「未送达 · 会话正忙」并可重试;本端被拒(pending_full /
定向 chat_busy)的既有定案是摘掉乐观气泡 + 横幅如实告知 + 正文还给输入栏(可再按一次发送,
幂等键保证不会落成第二条消息)。既有行为由现有测试钉住,本次不改;六态判定里保留了 Rejected
档并由单测直接覆盖,只是本端的现实路径走的是回滚那一支。

验证:assembleDebug 绿 + testDebugUnitTest 绿(0 失败 0 错误 0 跳过)。
2026-10-05 09:10:45 +08:00
AskaEth d0bb43c87c docs(02): 收下后端回执十五(忙时并行规范已定稿 + 主人三条裁决 + 语音切片上线 + Docs 同步中)+ 我方答复:已开工,帧侧全部做成可降级 2026-10-05 08:58:44 +08:00
AskaEth 5e6dc76ae8 docs(02): 转达主人的第三种「忙时发消息」方案(并行两路)+ 流程要求(开工前先定行为与接口规范,客户端在此之前不动代码)+ 三条 API 文档过期的可核查证据 2026-10-05 08:28:58 +08:00
AskaEth 0d50bc82ca docs(02): 收下后端回执十四(IM 文件的 documents 缺口已修 679cee0 / viewer 永久不做 / 语音只差 SILK 样本)+ 我方答复五条 2026-10-05 08:26:10 +08:00
AskaEth ac3f63f8c8 merge: W29 —— stream_chunk 改整段语义 + 空壳消息如实说一句 + 分享超长走文件 + 图片 URL 兜 host 2026-10-05 08:15:27 +08:00
AskaEth be1c372d99 fix(W29): stream_chunk 语义订正、空壳消息如实说明、分享长正文走文件、图片 URL 兜住 host
一、stream_chunk 由「增量」订正为「整段」(后端回执十三 §1)
   后端确认三件事:生产里已无生产者、delta 是整段 res.Text、不会与结构化帧并存。
   W26 那版照 message.delta 把 delta 追加进同一气泡是错的——真到达会把同一段内容追加成重影。
   改法:同一回合内已经收到过结构化帧(message.start / message.delta)就整帧忽略;否则先用
   StreamingContentParser.reset 清缓冲,再把这一整段喂进去,即用它整段替换当前流式气泡的正文。
   兜底分支保留(后端明确不建议删:那是未知帧不丢内容的兼容路径)。顺带把「回退路径没有
   message.start、所以 freshTurnIfFinished 不触发」这条边界在同一个分支里补上(一行级别的收口)。

二、空壳消息不再渲染成空气泡
   主人报的 bug 根因在服务端(微信发来的文件没有挂到消息的 documents 上,content 也是空),
   已登记后端;但客户端拿到的确实是一条什么都没有的消息,此前渲染成空气泡,用户只能猜。
   新增纯函数 isContentlessUserMessage(role 是 user 且 content 空、无 image、无 documents、
   无 thinking 才成立,任何一项有内容都不成立,assistant 一律不成立),成立时在气泡位置改为
   一句如实说明「这条消息的内容没有被传过来」:灰底灰字(沿用文件里既有的灰态小字),措辞只陈述
   本端确定的事实,不猜原因。判据放渲染层(MessageBubble),因为它纯属显示,服务端说什么就存什么,
   不在组装层改 DTO。

三、分享进来的超长正文走文件(主人已拍板)
   分享的文本超过现成的 TEXT_AS_DOCUMENT_RUNE_LIMIT 时,落成一份 .txt:写进分享缓存目录、
   上传 POST /documents、随 chat.documents 发,正文那一栏留空。判据抽成纯函数
   DocumentDelivery.planSharedText(正文是否超阈值 + 附件位是否还有空)。
   附件位被分享进来的文件占满(一个回合 5 份)时不做转换:正文照正文发,并在提示里说清
   「本想作为文件发送,但附件位已占满」——绝不为了塞下 .txt 挤掉用户明确要发的文件。
   落盘失败同样退回内联并说一句。组合与图片的关系:那份 .txt 就落在正文原来所在的那一条上
   (第一条带图,其余每条一张)。
   明确不动的:用户自己在输入框里打的字那条路,长正文照旧按正文发(devdocs/05 的定案第 3 条),
   这两件事不是同一件。

四、历史图片的同源绝对地址按当前 baseUrl 兜住 host(F6①)
   POST /upload 的 url 是按请求时的 r.Host 拼的绝对地址且原样落库,换服务器地址后旧图取不回;
   后端明确认可客户端做「保留 path、替换 host」。新增纯函数 ImageHostRebase.apply:只认
   http(s)://任意host/api/v1/files/一段名字 这一种形态,替换 scheme 与 authority、path 逐字节保留;
   第三方 CDN、data:、content:// 与 file://、相对路径、裸文件名、带查询串或百分号编码、多一段路径的
   一律原样返回。渲染历史图片前在 ChatScreen 传入 AuthManager.currentBaseUrl,全屏查看拿到的也是
   替换后的地址。逆作用写进 KDoc:换了指向不同数据的 host 会 404,那是诚实的失败,比永远取不回好。
   不改落库数据、不改上传、不碰 documents。

测试:新增 ImageHostRebaseTest 与 ContentlessMessageTest,DocumentDeliveryTest 补分享长正文的判据;
stream_chunk 那条分支是 VM 里的状态机分支,没有硬抽成纯函数(不为测试拧弯架构),改动写在 KDoc 里。
2026-10-05 08:14:33 +08:00
AskaEth c0e78371cb docs(05): 锚点补记四——W28 共享 socket(含装机验收证据与两个固有代价)+ 假后端测试台 + 一条硬教训(问一句比猜一天值)+ 登录页 IME 结案 2026-10-05 08:08:19 +08:00
AskaEth 7b97440973 merge: W28 —— 进程内共享一条 ChatSocket(连接数 N+2 → 1),SessionsViewModel 为唯一 owner 2026-10-05 08:03:50 +08:00
AskaEth 276b01113b refactor(ws): 进程内共享一条 ChatSocket,访问 N 个会话不再留 N+2 条连接
改了什么
- AppContainer:删掉「每次调用都新建」的 createChatSocket(),改成进程内唯一的 val chatSocket
  (by lazy)。三个调用方全部改取它,工厂方法一并删掉——不留「还能再造一份」的入口。
- ChatViewModel / SessionsViewModel / AdminViewModel:原本各自建一条连接,现在都改为订阅同一条。
- 连接的开与关收敛成唯一一处主人:SessionsViewModel 的登录态订阅
  (LoggedIn 则 connect()、LoggedOut 则 close())。
  * ChatViewModel 不再 connect()(它随切会话就被销毁,不该掌握连接的生死);
  * AdminViewModel 不再 connect() / close(),包括原来「viewer 时关掉自己那条连接」那条分支——
    这三帧服务端本就 fail-closed,本类另有逐帧角色门,断开连接多挡不掉什么,却会掐掉别的屏幕。
  * AdminViewModel 登出仍然只丢日志基线(行为不变)。
- 三个 VM 的 onCleared 一律不再 close(共享连接不能被任何 VM 关闭,否则切会话 / 关管理页会把
  主会话流式、抽屉未读、管理页三帧一起掐断)。
- KDoc 同步改:AppContainer(为什么可以共享 / 为什么必须唯一 / 开与关的主人是谁)、ChatSocket
  (它不是某个屏幕的私有连接、谁都不许 close;events 的 replay 恒为 0 从「安全」升为「必需」)、
  三个 VM 的类注释与 onCleared。

为什么行为不变
- 服务端广播、不按会话订阅:改前 N 条连接各收全部帧,改后一条连接收全部帧,每个 VM 收到的帧集合
  逐帧相同——这是本次重构安全的唯一依据。isForSession、各事件分支、重连与退避逻辑、下行帧语义、
  UI 一个字都没动。
- baseUrl / token 每次重连现读,connect() 幂等(wantConnection 已置位即返回),所以登出重登、
  换账号都不需要重建实例。
- 连接数量:改前访问 N 个会话 = N+2 条连接、N+2 个 OkHttpClient(每个连接自带一个),改后恒为 1 条。

如实记一处非逐帧等价的细节(已核过,无功能影响):改前每条连接各自握手,服务端每次都会回一帧
history;改后只有首次连接会收到。晚于连接建立才创建的 VM 因此少收一帧 history,而该帧对非主会话
经 historyForSession 过滤后本来就是空(只用来收掉 loading),对主会话与 REST 首屏是同一份内容的
冗余来源,且新 VM 的 pending 为空、reconcilePending 是空操作。

验证:assembleDebug + testDebugUnitTest 全绿(891 个用例,0 失败 0 错误 0 跳过)。
2026-10-05 08:03:09 +08:00
AskaEth 27d5680d21 merge: FB —— 可编程假后端测试台(devtools/fake-backend/,纯 Python 标准库),解锁 after 补洞 / 打断帧 / stream_chunk 三条端到端验证 2026-10-05 08:03:03 +08:00
AskaEth 86a2217858 feat(devtools): 新增可编程假后端(devtools/fake-backend),用来造「特定服务端行为」的端到端场景
为什么要它
客户端有三类行为只在特定服务端行为下才验得到,而真后端不可编排、造场景的代价高到不可做:
1. after 正向增量补洞:要造「WS 握手只回放最近 50 条、库里却有 120 条」这个洞,用真后端得灌 51 条消息、
   每条跑一次模型回合。假后端可以精确、免费、可复现地造出来(--history-replay N)。
2. 打断帧:客户端在「设置=打断 + 会话正忙」时会发 interrupt{scope:"current"}。要验它真的发出去了,
   就得看上行帧——假后端把每条上行帧原样记进 out/upstream.jsonl,这是唯一的第一现场。
3. stream_chunk 回退路径:真后端可能永远不走那条路,假后端可以用 --stream-chunk 指定走。

加了什么
- devtools/fake-backend/fake_backend.py:纯 Python 3 标准库(无第三方依赖)。实现了客户端启动到渲染
  用得到的那一段 REST(login/refresh/logout、sessions 列表与增删改、messages 分页与搜索、read、
  messages 编辑删除、meta/enums、personas、chat)加一个手写 WebSocket(GET /ws/chat?token=…,
  握手后立刻发 history 帧)。鉴权头 X-API-Version / Authorization 一律不校验,但会记进 out/http.jsonl
  (Authorization 与 ?token 只留 sha256 指纹)。
- 控制端点 /__control/**(不碰 /api/v1/**):灌消息(不调模型)、下发 session_busy/idle/interrupted、
  触发一轮回复、pending_full、定向 error、任意帧、踢掉客户端 WS、404 session_not_found、复位、看状态。
  目的是让「造场景」与「看结果」都不需要人手点 UI。
- devtools/fake-backend/ws_probe.py:最小 WebSocket 探针(标准库手写握手 + 帧收发 + ping),
  用来证明握手返回 101 / 收到 history 帧,并往连接上发任意上行帧对账。
- devtools/fake-backend/README.md:起法(Windows + Git Bash)、客户端怎么指过来(adb reverse +
  http://127.0.0.1:18080)、每个场景的可复制命令序列、端点速查、形状出处表、以及「它不做什么」。

边界(README 里也写明了)
不校验鉴权、不落盘成持久数据、不调模型、不是产品的一部分、不实现契约全集(admin 面、附件上传、
observability 推送等一律 404),并且不要拿它当后端行为的事实来源——所有响应形状都是照客户端代码
读出来的(README 第 9 节逐条列出文件:行号),不代表真后端真的这么回。
默认端口 18080,刻意避开真后端的 10942。

形状来源(举几个关键例子):信封 ApiEnvelope.kt:8;鉴权头 ApiClientFactory.kt:115-118;
WS 地址 ChatSocket.kt:287-295;SessionSummaryDto 与排序 SessionDto.kt:16-50 加线上夹具
SessionDtoRealPayloadTest.kt:36-38;after 升序 / before 倒序 / q 与 after 互斥 SessionsApi.kt:71-101;
limit 默认 50 上限 200 MessageRepository.kt:149-150;枚举表逐字取自 MetaDtoRealPayloadTest.kt:24;
404 session_not_found ChatViewModel.kt:1802-1808;stream_chunk 回退 WsEvent.kt:205-228。

验证(本机实跑,输出见提交后的报告)
curl 打通 login/sessions/messages(limit/before/after/q 与三个 400 互斥面)、meta/enums、personas;
ws_probe 握手拿到 101 与匹配的 Sec-WebSocket-Accept,并收到 history 帧;灌 120 条后握手只回放最近 50 条
(msg-000071..msg-000120),用 after 游标正向走两页正好补回 msg-000021..msg-000120、第三页为空;
--stream-chunk 下下发的是 stream_chunk×3 + stream_end(没有 message.* 帧);打断帧原样落进
out/upstream.jsonl;ping/pong 与 HTTP keep-alive 均正常。
2026-10-05 08:01:48 +08:00
AskaEth a0085802dd docs(02): 把「微信文件任何客户端都看不到」提到文档最顶部的 P0 置顶块(主人亲自报的 bug)+ 补一个必须说清的分叉:字节到底落盘了还是只内联进上下文 2026-10-05 07:58:20 +08:00
AskaEth ae49853029 docs(02): 登记新需求——IM 渠道的语音消息要能被读到(前端那半我们做,IM 那半请后端比照 IM-1 做 ASR 入站) 2026-10-05 07:54:55 +08:00
AskaEth 691b073e32 docs(02): 回应后端回执十三(stream_chunk 改整段语义、F6① 客户端兜 host、viewer 摘项)+ 登记一条新 P0:IM 渠道的文件没有挂到消息的 documents 上 2026-10-05 07:54:45 +08:00
AskaEth 6c2cc57cdb docs(02): 状态表维护(C1/C3/D4 早已闭环)+ 新增一张「待后端回应」清单
主人早上问「需要后端做的事都写进文档了吗、本地还有没有独立事件」,我借这次盘点把协作文档收口:

1. **状态表维护**:C1(语音轮次不落库)、C3(voice_output 字段)、D4(无未读状态)三行原写
   「待确认」,其实**早已解决**——C1/C3 由契约 v0.59 §3.15(语音改走与文本同一条编排路径、
   voice_output 补 sessionId/deviceId),D4 由 v0.37(unreadCount + 已读游标,客户端已接入)。
   按正文依据打勾,并写明「客户端至今没有语音 UI,所以 C1/C3 我们从未验证过」这一诚实边界。
   F6 那行从「待确认」改成「问过、后端尚未回应」,让「谁在等谁」一眼可见。

2. **新增「客户端待后端回应的清单」**(文档末尾):只列我们问过、还没拿到明确答复的七条——
   stream_chunk 三点确认 / F6① upload url 绑 host / viewer 凭据路径或确认永远 501 /
   并行 tool_use 配对的 P0(他们说在修)/ llm_usage.session_id 接线(同上)/
   IM 入站文件的真机验证(只能主人做)/ 以及这次状态表维护本身请他们复核。

   每条都写了「要什么」与「我们这边的处置」:例如 F6① 明确**不做绕行**(不猜 base、不重写历史
   URL——那会把「服务端说是什么」变成「客户端以为是什么」);viewer 那条给了二选一
   (给铸造路径,或明确永远 501,那我们就不再把它当验收项挂着)。

本次提交只动文档。
2026-10-05 07:41:27 +08:00
AskaEth 219c50b7c2 docs: 自主推进一批的登记(假开关 / 静默丢正文 / 分享自打断 全修完 + 一条口径冲突要主人拍板)
主人睡前说「按主线继续推,或者先按模块检查」,我把两件都做了:先做了一次只读自审
(假开关 / 死代码 / UI 规范 / 文档漂移,四张带「文件:行号」的清单),然后按清单修了一批。
本批全部是「界面上或文档上声称能做到、代码里其实没接线」这一类问题。

devdocs/05:新增「锚点补记三」,写清——
- 本批 5 条落地(W25 打断模式真接线 / W26 stream_chunk 不再静默丢正文、错误横幅可关闭、
  分享不再自相打断、两处死代码 / W27 删只被测试养的 API、公式去重、03 四处更正 /
  WsEvent KDoc 更正);
- 三个「假开关 / 静默」的真相与各自的修法;
- 两条查完发现不是 bug 的(「抽屉最后消息预览不刷新」与「session_interrupted 丢弃 N 条」),
  附证据,免得下次返工;
- 装机验证:错误行的关闭叉验通(x2/x3 截图),以及一条我刻意没验的(登录页 IME ——
  要清空 App 数据才能回到登录页,万一登不回去今晚所有装机验证能力一起没了,风险不对称);
- 本批没做的:连接所有权(共享 socket)只写设计要点与七条验收清单,不动手;
  DocumentDelivery 那组「正文转文件」API 不删也不接(口径冲突);
- W26 留下的一条边界(回退路径没有 message.start,freshTurnIfFinished 不触发)。

devdocs/02:追加客户端登记三条——
1. 请后端确认 stream_chunk 今天还会不会发、delta 是增量还是整段、会不会与结构化帧并存
   (我们按契约的「非流式回退路径」实现,但那条路可能已经名存实亡);
2. 告知我们开始发 interrupt{scope:"current"} 了(窄触发条件、默认档零变化、分享不打断、
   以及「两条道无强序」这个我们坦承的边界);
3. 登记「超限走文件」的口径冲突(05 的定案说做的是「用户打的字自动变附件」不做,
   主人后来点名要做 —— 两者多半不是一件事),不替主人合并,所以那组 API 原样留着。

本次提交只动文档,无代码改动。
2026-10-04 22:40:43 +08:00
AskaEth 85a922c01b docs(ws): 更正 SessionInterrupted.dropped 的过时 KDoc(本端全部路径下它恒为 0)
原来的注释写「当前任务取消、pending 清空,dropped 为丢弃条数」——那是 `interrupt` 只有一种形状
(等价于今天的 `scope:"all"`)时的口径。契约 v0.56 起 `scope:"current"` 只取消在跑回合、**保留**
待处理队列,该档 `dropped` 恒为 0。

而本端「追加队列」档一个 interrupt 都不发、「打断」档发的是 `current`(见 feats/W25,cfdc094),
所以 `dropped` 在客户端的全部现实路径里恒为 0 —— 「已打断,丢弃 N 条」那句话在本端根本不可达。

注释里写明这一点,免得后来人照着一句不可达的文档去实现一个用不上的计数。

只改注释,无行为变化。
2026-10-04 22:40:07 +08:00
AskaEth b7ac6b8853 merge: W27 —— 删只被测试养的 availablePages + 图片 base64 上限公式去重 + 03 四处文档更正 2026-10-04 22:37:25 +08:00
AskaEth 3c0ed16ee7 merge: W26 —— stream_chunk 不再静默丢正文 + 错误横幅可关闭 + 分享不再自相打断 + 两处死代码 2026-10-04 22:37:24 +08:00
AskaEth eacc65acc9 fix(chat): 四件可见修复 —— 回退正文不再丢 / 错误横幅可关 / 分享不自杀 / 清两处死码
1) stream_chunk 不再被静默丢掉(整段正文丢失风险)
   - ChatViewModel.onEvent 新增 WsEvent.StreamChunk 分支,把 delta 照 MessageDelta 的同一形态喂进
     流式解析器。
   - 依据:后端契约写明「未映射/无法确定的事件仍走 stream_chunk + stream_end(非流式回退路径)」
     (Cyrene_v2/Docs/api-client.md §3.1)。原先它落到 onEvent 末尾的 else -> Unit 被静默吞掉 ——
     服务端一旦走进回退路径,整段回复正文一个字都不显示,而且没有任何信号说明正文丢了。
   - stream_chunk 不带 index(那是 message.start 的字段),所以不读 index;解析器与 MessageDelta
     一样按需创建,按「同一个当前流」处理。
   - 未改 WsEvent 的解析形状,只加消费分支。

2) 错误横幅可以关掉了(两个 dismissError 不再是死方法)
   - ChatViewModel.dismissError / SessionsViewModel.dismissError 原本全仓零调用方,现各接上关闭
     入口,并各补 KDoc 写明边界:清掉的是本端已经显示的那条错误,不代表服务端那边没事;不做自动
     消失。
   - ChatScreen.kt 的 private Banner 增加可选 onDismiss,有它时右侧挂 MiuixIcons.Basic.Close
     (带 contentDescription);只有 state.error 那条传 viewModel::dismissError,分享 / 附件 /
     定位那几条如实告知保持原样(它们自己会收走)。
   - SessionDrawerContent.kt 的错误行改成行内布局 + 右侧关闭按钮,样式随它已有的行(footnote2、
     同一水平内边距、同款 IconButton 灰度);新增参数 onDismissError,由 ChatHomeScreen 接
     sessionsViewModel::dismissError。
   - 错误内容本身、错误出现的时机、是否自动消失,一概未动。

3) 多图分享不再互相打断
   - 根因:W25 把「她正忙时按发送」接到发送路径后,deliver 每次投递前都会发一条 interrupt 帧
     (scope=current);而分享 N 张图 = N 次投递,第 2 条起就把前一条刚起的回合取消掉了,
     最后只有最后一张被回答。
   - 改法:emitAndDeliver 增加 allowInterrupt: Boolean = true(默认值保持现状),deliver 按它决定
     是否调 interruptRunningTurn();sendShared 的两条投递都传 false。
   - 只有一张图也传 false:分享是「一次把东西倒进去」的批量动作,自己打断自己一定是错的;
     「分享从不打断」是一句话,「超过一张才不打断」得先解释那个分界。
   - 单条发送(send)的口径一字未动。

4) 两处死代码
   - 删掉 ChatViewModel.clearDraftDocuments()(复核为全仓零调用方)。没选「在输入栏加清空全部入口」:
     附件芯片本来就每枚带叉、上限只有 5 枚,逐个移除已经覆盖全部场景;输入栏里再加一枚清空按钮是
     噪音,而留着一个没人接的公开方法只会让人误以为它是活的。
   - 删掉 ChatScreen 的 onLogout 参数(函数体内一次都没用到)与 ChatHomeScreen 的对应传参。
     真正的登出路径(CyreneApp -> ChatHomeScreen -> SettingsScreen)原样可用。

验证:assembleDebug 与 testDebugUnitTest 均 BUILD SUCCESSFUL;单测 892 条、0 失败 0 错误、0 跳过
(本次未新增测试:① 的判定是 when 的一个分支、抽不出自然的纯函数,不为测试把架构拧弯)。
2026-10-04 22:36:47 +08:00
AskaEth 805d8b530a refactor+docs(W27): 删掉只被测试养着的 AdminAccess.availablePages,让 ChatImageBudget 的公式只留一处,并就地更正 devdocs/03 四处过时表述
清理(零行为变化)
- 删 domain/admin/AdminAccess.kt 的 availablePages():全仓(含 app/src/test)调用点 0 处 ——
  main 里只有它自己的声明、测试里 2 处(AdminAccessTest 那 1 条用例一并删掉)。它早已被
  ui/admin/AdminHomeScreen.kt 的做法取代:遍历 AdminPage.entries + 每行 canAccess 决定是否禁用。
- ChatImageBudget.SERVER_MAX_CHARS 原来把 base64Chars 的 ((n+2)/3)*4 又内联重写了一遍,
  改为 base64Chars(SERVER_MAX_DECODED_BYTES) + PREFIX_ALLOWANCE_CHARS(一处公式、一处真相)。
  代价:该常量由 const val 变 val(const 不允许调用函数)。取值逐位不变 = 6990572,由
  ChatSendPolicyTest 的独立重算断言与 ChatDtoRealPayloadTest 对服务端真实文案的断言继续钉住。

文档(devdocs/03,一律删除线保留原文 + 更正括注,不动别人的回执段落)
- §4 重连补全:02/A5 的 after 正向增量已接入(loadAfter + ChatViewModel.catchUp,28c3142)。
- §4 忙碌/队列 UI 映射:客户端只发 scope:"current",该档 dropped 恒为 0、all 从不发,
  所以「已打断,丢弃 N 条」在今天的客户端里不可达。
- §5 mode 回落:mode 已落库(后端 b686d5f),MessageBubble 按 mode 分支。
- §4 打断模式:补上 W25 接线的依据 cfdc094 / feat/W25-interrupt-scope。

devdocs/05 追加 W27 小节;另记下 data/ws/WsEvent.kt:272 的同类过时 KDoc(不在本轮允许改动的
文件清单里,未动)。

验证:assembleDebug 通过;testDebugUnitTest 891 条 / 0 失败 / 0 错误(改动前 892,删掉 1 条)。
2026-10-04 22:35:20 +08:00
AskaEth e4c6c7c72a merge: W25 —— 把设置页的「打断模式」真正接到发送路径(默认档零行为变化) 2026-10-04 22:31:52 +08:00
AskaEth cfdc094c30 fix(chat): 设置项「她正忙时按发送」不再空转 —— 发送前按偏好决定是否先打断
设置页那个开关此前没有任何发送路径读它:interruptMode 全仓只出现在 AppSettings
(定义 / 读写)与 SettingsScreen(渲染文案),于是把它切成「打断」后行为与「追加队列」
一模一样,ChatSocket.sendInterrupt 更是零调用方(死 API)。这正是一直挂在文档
devdocs/02 协同项 A2 上的欠账(后端 v0.56 已支持 scope,客户端没接)。

现在按契约 v0.56 §3.1 接上,判据抽成与发送路由同类位置的纯函数
interruptScopeForSend(mode, busy: Boolean):String?

1. 追加队列(默认档)⇒ 恒定返回 null,一个 interrupt 帧都不发,线上请求与今天逐字节相同;
2. 打断(wireScope = "current")且会话正忙 ⇒ 这条消息之前先发 interrupt{scope:"current"}
   (只取消在跑回合、保留待处理队列,队列里的消息按编排器续跑,一条都不丢);
3. 打断但会话空闲 ⇒ 不发(命中空闲会话是 no-op,白发一条帧只多一次往返,还会在 logcat
   里制造「这里打断过」的假象)。

「正忙」只认服务端 session_busy 帧给的 busyMode != null,不用 queueDepth——队列深度
非零不等于有回合在跑(pending_ack / pending_drain 也会动它)。

接线点在 ChatViewModel.deliver() 的通道分支之前(app/src/main/kotlin/top/yeij/cyrene/
ui/chat/ChatViewModel.kt)。放在那里而不是 emitAndDeliver 开头是刻意的:上面每一段本端
拒绝(附件超限 / 图片处理失败 / 附件上传失败 / 引用不合法)都会「回滚、什么都不发」,
先打断她的回合再什么都不发是凭空的破坏。每次投递只经过那里一次,所以一次投递最多一条
打断帧,任何后续分支与重试都不会重复发。

偏好每次发送现读(不缓存、不订阅 Flow):「在设置页切成打断、然后立刻发的那一条」正是
本次要修的场景,缓存值要等 Flow 推到才更新,冷启动那一下必然没到——那就等于留了一个
静默不生效的窗口。读失败(IO / 损坏)回落默认档,也就是最少动作的那一档,与 AppSettings
把未知取值解成默认档是同一条纪律。

打断帧发不出去(只有「WS 未连接」一种原因)静默、不阻断本次发送:本次消息的成败与它
无关(REST 通道不看 WS 连接),且一直有既有的回执 / 回滚路径如实上报;反过来「打断失败」
没有对应得上的动作(重试等于把消息再发一遍,正是幂等键在防的事),喊一声只会误导。
于是打断没生效 = 行为退回默认档,是用户看得懂的状态,不是被藏起来的事。

测试:新增 InterruptScopeTest 8 条(两档 × 忙闲四格穷举 + 默认档零行为变化 + 偏好回落
方向),全仓 884 → 892。assembleDebug 与 testDebugUnitTest 全绿(0 失败 0 错误)。
设置页那两条文案原本就与 scope:current 的真实语义相符(「已排队的消息保留,继续依次
回答」),未改一个字。

文档:02 的 A2 状态表、05 的 A2 条目都写着「客户端尚未接入该字段」;03 里「打断模式 →
UI 限制一次只发一条」的前提(interrupt 会清空 pending)也随之失效,三处一并补记更正。
2026-10-04 22:29:57 +08:00
AskaEth b8f0833137 docs: 收下后端回执十二 + 登记本轮三条接入结论 + 05 锚点补记二(含两条新教训)
devdocs/02(与后端会话共用的协作文档):
- 收下他们的**回执十二**:knowledge 的根因(参数角色缺席 + 收尾静默分支)与修法、并行
  tool_use/tool_result 配对的新 P0(客户端不需要适配)、契约 v0.64 的只读
  GET /admin/llm/usage、IM 入站文件「能拿到字节」(真机验证只能由主人做)、
  llm_usage.session_id 恒为 NULL。
- 追加**我方登记**:v0.63 那两条(reason / 404)都已接入并推送;after 正向增量的两条口径;
  及一条要他们确认的**我方主动口径** —— unreadable 且 mime 是 text/ 时让 reason 压过正文渲染。
- 他们点出的三处旧口径(1049/1110/1309 行)我核过,**都在他们自己的回执段落里**,按同一条规矩
  请他们自己更正,我们没有动手。

devdocs/05(我方进度与交接):
- 锚点更新:HEAD、884 测试、本轮 5 条落地、待办重排、家务办完。
- 新增「本轮未验证的」一节:把**验过的**与**没验过的**分开写(404 兜底验通;after 只验到「真请求
  打出去了」、补洞端到端仍未验;W24/W23 的设备侧只验到能开的程度)。
- 两条新教训:① 那个「未响应弹窗」是模拟器/宿主机被拖垮(日志里系统自己的
  PointerEventDispatcher 卡了 37 秒),uiautomator dump 要省着用;② **一次性告知不能挂在会被销毁的
  状态上** —— 404 兜底第一版就是这么悄悄失效的,只有真机看一眼才能发现。

本次提交只动文档,无代码改动。代码侧本轮四个提交(28c3142 / 1794e53 / b5c0b13 / 2d382b1)都已推到
origin/android。
2026-10-04 22:22:51 +08:00
AskaEth b5c0b13210 fix(chat): 「会话已删」那句解释必须由不会被销毁的那一层持有
真机验收 404 兜底时发现的真问题:**切换本身是对的,但用户看不到任何解释**。

- logcat 链路完整:`GET /sessions/web:{已删}/messages?limit=50` → `404` →(约 0.9 秒后)
  `GET /sessions` → 界面切回主会话。也就是说「退回主会话 + 如实说一句」里的**前半句成了**。
- 但**后半句一个字都没显示**:那句横幅原来挂在 `ChatUiState.sessionGone` 上,而
  `onSessionGone` 一被调用,`activeSessionId` 就被收敛回主会话、`ChatScreen` 的 `key` 随之变化,
  承载它的那个 `ChatViewModel` 连同状态**立刻被销毁** —— 横幅来不及组合出来就没了。
  (这正是「把一次性告知放在会被销毁的状态里」的经典坑,值得单记一条。)

改法:
- `ChatUiState.sessionGone` 保留,但只当**触发信号**(KDoc 已改写说明这一点);
- 文案由 `ChatHomeScreen` 的 `remember` 持有(它活得比会话 VM 久),通过新的
  `sessionNotice: String?` 参数注入 `ChatScreen`,仍然走页面上那同一排 `Banner`,
  8 秒后自己消失(它描述一次刚发生的动作,不是持续状态);
- `onSessionGone` 里**先**立起告知、**再**刷新列表——顺序反了同样什么都看不到。

验证:装机复测通过,红字横幅如期出现在主会话页顶部
(证据 `app/build/e2e-shots/w7-gone-2.png`),logcat 链路 404 → `GET /sessions` 完整。
assembleDebug 与 testDebugUnitTest 全绿(884 测试 / 0 失败,与改动前同数:本次只动了接线与文案,
没有可新增的纯函数)。
2026-10-04 22:20:59 +08:00
AskaEth 1794e53d1c merge: W24 —— 编辑弹层只显示纯文本(原文含标记时如实告知会被替换,绝不静默丢旁白) 2026-10-04 22:08:08 +08:00
AskaEth 2d382b11cd merge: W23 —— 接入契约 v0.63 的 documents[].reason(只在下发时原样显示,不做整句判断) 2026-10-04 22:08:06 +08:00
AskaEth 66c2291312 feat(chat): 编辑框只显示纯文本 —— 真有标记时才剥,且弹层明说会替换掉什么
主人拍板:编辑单条消息时输入框里只放纯文本,不再让用户对着 <p> / <action> / <narration> 改字。
红线是「不许静默丢东西」,所以这次不是「把标记一剥了事」:

- 新增纯 Kotlin 模块 domain/chat/MessageEditText.kt(无 Android / Compose 依赖)。
  原文不含已知标签时逐字节恒等:不 trim、不折叠连续空行、不换换行符;含标签时用
  domain/parse(项目里唯一的解析器,不另写正则,免得两套口径分叉)按原始片段顺序拼出可见纯文本,
  正文与旁白/动作一并保留,气泡之间空一行;一个气泡都解析不出就回落成原文(宁可不剥)。
  hasMarkup 只在「保存真的会替换掉什么」时为 true。
- MessageActionState.Edit 新增 markupReplaced;startEdit 只算一次 editableMessageOf
  (不放进 state.update 的 lambda 里),draft 拿纯文本、markupReplaced 拿原文带不带标记。
- MessageEditDialog:markupReplaced 为 true 时在计数行下方多一行小字,写明「保存后这条消息只剩
  你写的这段文字,原来的旁白与动作会被替换掉」;为 false 时一行都不多显示。计数与保存禁用逻辑不变。
- 保存提交的就是 draft 本身,不把标记猜着装回去:任何回填都要先猜「旁白该插回哪一句之间」,
  猜错就是静默移动她说过的话,用户还看不出发生了什么(理由写在 submitEdit 与弹层的 KDoc 里)。
- 新增 MessageEditTextTest(21 例):纯文本恒等(空串、首尾空白、连续三个换行、CRLF、
  以及 1 < 2 / <div> / <P> / <p2> 这类「有尖括号但不是标记」的普通文本)、线上真实标记串、
  纯旁白、带属性的 p 标签、head/tail 旁白的先后顺序、气泡间只空一行、畸形未闭合不丢字。

为什么这仍然值得做:主会话 64 条 user 消息全是纯文本,所以对今天的真实数据这是一条恒等变换——
它是防御性口径,不是修一个正在发生的 bug;也正因如此,纯文本路径必须逐字节不变。
2026-10-04 22:07:40 +08:00
AskaEth e4915a6904 docs(02): 收下后端回执十一(四点确认 + knowledge 缺陷已立案 + IM-1/U1 在实施)
后端会话直接写进了本机 devdocs/02(与主树共用同一份文件),这里把它落成一次提交,
免得长期挂在工作区、也便于将来对时间线。

回执十一的四点:sess-* 不加 404 例外(与我们的答复一致);documents[].reason 他们不会擅自改文案
(要改会同时升契约并回执);404 session_not_found 的用法确认;日期更正已互相认账。

另两条与我们相关的:
- 我们登记的 knowledge 工具失败(E2E-DOC 那条,纯文本也复现)他们已按更正后的口径立案排查,
  修复推库后会补一条回执(含根因文件:行)。他们同时要求「无论回合因何结束都落一条可见的
  assistant 消息」,边界保留:被 interrupt 取消 / 被 stale gen 取代 / 静默内部流程仍不落库。
- IM-1(各适配器入站内联受支持格式的文件字节)与 U1(llm_usage 落库 + 只读汇总端点
  GET /api/v1/admin/llm/usage,后续契约 v0.64)在实施中 —— 后者对我们只有将来做管理页时才算增量。

本次提交只增文档,无代码改动。
2026-10-04 22:01:01 +08:00
AskaEth 28c314262e feat(chat): 接上 A5 正向增量 after,并用 404 session_not_found 把「会话已删」如实说出来
改了什么:
- SessionsApi.listMessages 加可选 after(契约 §1 行 6)。before / q / after 三者互斥是服务端的
  硬约束,本端三个调用点各只给一个;after 为 null 时 Retrofit 整个省掉这个参数 ⇒ 既有三条路径
  的线上请求逐字节不变。
- MessageRepository 加 loadAfter:同一个端点,服务端按【升序】返回(与 before 的倒序相反),
  仍统一转成升序的 PagedMessages。顺手去掉 toPagedMessages 参数名里那个已经不对的 newestFirst。
- ChatViewModel:
  · 新增 newestKnownAt(从状态现算的 after 游标)与 absorb(只并消息与乐观气泡,不碰
    loading / loadingOlder / hasMore / error —— 一次静默补拉不该把上拉位置和错误横幅掀掉)。
  · 掉线那一刻记下游标、重新连上时用它做一次正向增量。记录时机必须在「掉线」而不是「重连后」:
    重连的 history 帧会把最近 50 条灌进来、游标随即被推到「现在」,那时再取就取不到那个洞了。
    「再拉一页最新」永远补不回中间断掉的那一段,这正是 after 存在的理由(02/A5 的欠账)。
  · ensureMessageKnown 先用增量(只要新来的那几条),还认不出来才退回整页重拉。
  · 循环推进抽成纯函数 loadForwardPages:游标必须严格前进(同毫秒并列且超过一页时推不动,
    契约 §A5 明写的已知边界)+ 轮数上限 20,两道防死循环的闸都由单测钉住。
- 404 session_not_found(契约 v0.63):首屏 / 上拉 / 增量三条读路径都判,非主会话就置
  ChatUiState.sessionGone;ChatScreen 据此显示一句实话并回调 ChatHomeScreen 重拉会话列表
  (refresh 内部会把「指向已删会话」的 activeSessionId 收敛回主会话)。
  主会话不置位:那说明部署本身出了问题,该显示的是普通错误横幅,而不是一句假的「已切回主会话」。

为什么:
主人那次「图片凭空消失、别的端也看不到」的真相,就是会话在别处被删掉之后,客户端把它渲染成了
一条空会话 —— 让用户对着空白猜。现在有了机器可读的 404,就能如实说一句并把人带回主会话。
另一半是重连补洞:一直是「再拉一页最新」在顶着,断线期间超过 50 条必丢中间那一段。

验证:assembleDebug + testDebugUnitTest 全绿,846 测试 / 0 失败 / 0 错误。
较基线 831 新增 15 条(MessageAfterRepositoryTest 5 条走真 OkHttp 断言线上 URI;
ChatRecoveryTest 10 条钉住 404 判据与增量循环的两道闸)。
设备侧验证(点开看到那句实话、断线补洞)另行做,不在本次提交的断言范围内。
2026-10-04 22:00:55 +08:00
AskaEth e5fa1b871f 接入契约 v0.63 的 documents[].reason:把「读不到的原因」原样送到附件预览弹层
后端 902b6ee / 契约 v0.63 给 historyMessage.documents[] 的元素加了可选字段 reason:
只在 status == "unreadable" 时下发(Go omitempty,extracted 时整个键缺席),内容与后端
注入模型清单里的那 10 条是同一份字符串,最长的一条 40+ 字。

改了什么
- DocumentRefDto 加 reason: String? = null。documents[] 在 WS 握手 history、
  message_persisted、GET /messages 三处共用这一个 DTO,所以一处解析三条路全覆盖。
  KDoc 写明三条口径:可选、只在 unreadable 时下发、是后端原话所以客户端原样显示,
  且绝不做整句相等判断(需要分支只允许用稳定子串)。
- DocumentChipModel 加 reason,由 DocumentChips.of(ref) 取 ref.reason 去掉首尾空白后
  非空才留,否则归一成 null——一句空话会顶掉弹层里的通用说明,比没有更坏。
  芯片外观一个字没改:detail / statusLabel 保持现状(unreadable 仍显示「读不到」)。
  理由:那句话太长,塞进一行芯片只会被 Ellipsis 切掉半句,比不显示更糟。
- DocumentPreviewGate.plan 增加 reason 参数:非空白时直接作为正文区那句说明原样返回
  (NotText(reason)),压过本端按 mime / 扩展名的裁决;DocumentTarget.previewPlan 顺着
  chips.reason 把它带进裁决。预览弹层本身一行代码都没改——它渲染的就是 plan.message
  那句话,所以 reason 从 DTO 到用户看见的那句话整条链路上没有任何改写或截断。
- 配套单测 17 条。

为什么这么改
- 服务端那句原因已经断言「无法抽取正文」,它比本端按类型编的通用说明(只知道类型、
  不知道到底为什么)准确;替它改写、归纳或截断就是丢掉服务端唯一一次说清原因的机会。
- 它也不该与正文并存:服务端说正文抽不出来,本端再把字节当 UTF-8 渲染,产出的正是
  正文闸要消灭的「看着像文件坏了」。
- reason 缺席时(契约 v0.63 之前的历史消息)产出与改之前逐字相同:兜底那句话、判据、
  文案全都不动。

测试(新增 17 条)
- DocumentDtoTest +5:unreadable 带 reason 逐字解出;extracted 元素没有该键 -> null;
  v0.62 风格的缺键载荷 -> null(不解码失败、不替服务端编一句话);清单外的未知分类原样
  保留;显式 null -> null。
- DocumentChipTest +5:reason 带进模型;句中标点与换行原样、只去首尾空白;空白串 ->
  null;绝不进芯片副行(detail 逐字不变);待发附件恒为 null。
- DocumentPreviewTest +7:reason 逐字成为正文区那句话(含未知分类,不丢不改不换兜底);
  只 trim 一次再原样显示;缺席时与改前逐字相同(拿 refusalMessage 对比);
  空白 reason 不改任何行为;text mime + reason 不再假装有正文。
- assembleDebug 通过;testDebugUnitTest 848 个测试 0 失败 0 错误 0 跳过。

已知代价(已写进 KDoc,供人拍板)
服务端判为读不到、但字节其实下得动的附件(例如超限的纯文本)会失去 App 内预览,
「用其它应用打开」与「保存到下载目录」两条出口照旧完整可用——与「未知 mime 判为非文本」
是同一笔不对称账。
2026-10-04 21:57:14 +08:00
AskaEth a84d473e2f docs: 收下后端回执十(契约 v0.63:documents[].reason + messages 对已删会话回 404)+ 答复 sess-* 提问 + 锚点补记两条新待办 2026-10-04 21:51:08 +08:00
AskaEth 9f92a8ade5 docs(05): 恢复锚点更新 —— 记上凭据落地位置(dev-secrets.local.md,gitignore)、新 HEAD、模拟器启动参数 2026-10-04 21:50:31 +08:00
AskaEth d2169c6174 chore(gitignore): 忽略 dev-secrets.local.md(本机开发凭据,主人授权落地但不进仓库) 2026-10-04 21:48:37 +08:00
AskaEth 92ee7fedbd docs(05): 落「恢复锚点」(当前状态 / 已完成模块清单 / 待办 / 家务)—— 便于上下文压缩后接续 2026-10-04 21:46:56 +08:00
AskaEth 647cf47cf2 docs(05): 更正 ① W16 搜索的结论 —— 本来就是好的,先前「设备上不可用」是我的测量假象(含教训:动代码前先用像素确认症状) 2026-10-04 21:46:26 +08:00
AskaEth 3ff4f45166 docs: 更正文档里的日期标注 —— 本轮都发生在 2026-10-04 深夜,之前误写成 10-05(含后端回执九那一行) 2026-10-04 21:42:37 +08:00
AskaEth c67aa2da48 docs(05): 存档本轮装机验收的设备证据(W18/W15/W19 通过、W16 确认不可用、四条新坑) 2026-10-04 21:41:45 +08:00
AskaEth e8c6389fde docs(02): 收下后端 GC 策略与回复 + 更正 knowledge 工具失败的范围(纯文本也复现)+ 一条 adb reverse 方法论 2026-10-04 21:41:20 +08:00
AskaEth df6378395e merge: W21 — 重试记忆加 10 分钟时间上界(多槽不会跨小时误撞旧键) 2026-10-04 21:40:23 +08:00
AskaEth 8938fffd08 feat(chat): 重试记忆的槽加 10 分钟时间上界 —— 别把用户新打的同内容当成重试
多槽让失败记录跨很多次发送存活,于是开了个单槽时代没有的窗口:用户几小时前失败过一句
「你好」,之后新打一遍「你好」(心里当的是一条新消息),指纹却与那条旧记录一字不差,
keyFor 命中旧键 ⇒ 服务端回 duplicate ⇒ 这条新消息根本不发出去,屏幕上看着像「按了没反应」。
给每个槽加一道时间上界,窗口就被限死在 10 分钟内;而「刚失败完,用户马上再按一次发送」
这个真正需要幂等的场景一秒都没被削掉。

- RetryKeyMemory: 新增公开常量 SLOT_TTL_MILLIS = 10 分钟(测试拿它做断言,不硬编码);
  RetryKeySlot 增 atMillis;remember(fingerprint, localId, atMillis) 与
  keyFor(fingerprint, nowMillis, mint) 都在入口先按时间淘汰过期槽、再做自己那件事,
  写入时最后才看条数上限(过期槽不占 8 条的额度);同指纹重复失败更新键与时刻而不是留两条;
  forget(localId) 与时间无关,签名与行为不变;FIFO 上限 8 与 TTL 两条规则同时生效。
- 边界口径:正好等于 TTL 不算过期(判据是 now - atMillis > TTL,严格大于),理由写在类注释里,
  测试把它钉住。
- ChatViewModel: 只改两个调用点,喂 System.currentTimeMillis()(emitAndDeliver 的 keyFor、
  rememberRetryKey 的 remember;forget 的三处不动),并按新语义更新相关 KDoc。
- RetryKeyMemoryTest: 既有用例补上时刻;新增 7 条覆盖「过期槽不复用且被淘汰」「TTL 边界」
  「remember 刷新时间戳」「过期的 A 不影响新鲜的 B」「TTL 先于 FIFO 上限生效」。
2026-10-04 21:37:45 +08:00
AskaEth 7a6f6b0863 chore: .gitignore 补 .kotlin/(Kotlin 构建产物,之前没忽略,git add -A 会误扫) 2026-10-04 21:29:14 +08:00
AskaEth ea16f48be2 merge: W19 — 单条消息的编辑与删除(D3) 2026-10-04 21:28:22 +08:00
AskaEth 351891714a feat(chat): 单条消息的编辑与删除(D3)
契约 §1 行 6a/6b 的两个端点与 §3.8 的两帧,整条链接完(不含「重发」——契约
明确没有重发端点,客户端用编辑后的内容再 POST /chat 即可,本模块不做)。

数据层
- SessionsApi 增 PATCH / DELETE api/v1/messages/{id}(与既有消息端点同源,因此
  MessageRepository 能直接复用、不必动 AppContainer),MessageRepository 增
  editMessage / deleteMessage,走既有 apiCall / Result 风格。
- 响应形状照契约不照旧文:PATCH 的 data 直接就是消息 DTO(没有 message 包一层),
  DELETE 回 {deleted: id};编辑内容按「非空、≤4000 rune(码点,不是 length)」
  预校验,不合格一个请求都不发。

帧解析
- WsEvent 增 message_edited / message_deleted,形态照 message_persisted 那一套:
  会话归属先取帧壳、其次取消息自身的 sessionId,未知键安全、未知帧与坏帧安静忽略、
  永不抛。
- message_edited 的 data 就是消息本身(与 message_persisted 的 data.message 不同形),
  照抄那一套会解出 null——这一点由单测钉住。

VM
- 按 id 原位改 content / 原位移除。两条路各自只有一个落地出口:本端 PATCH/DELETE 的
  成功回执与随后到达的广播都调同一对纯函数,而它们幂等(同一内容做两次返回同一实例)
  ⇒ 「响应 + 广播」双到达收敛成一次,不需要去重表、也不会出现两套状态源。
- 编辑只对 role == user 开放、删除对 user 与 assistant 都开放,判据是同一份纯函数,
  气泡(隐藏入口)与 VM(再挡一次)共用。
- 失败一律如实:弹层还开着就留在弹层里(输入不丢、可原地重试),弹层已被关掉就挂到
  页面顶部那行横幅,绝不静默。

UI
- 长按气泡打开动作菜单;由于正文包在 SelectionContainer 里、长按正文同时也是选字手势
  (两者抢同一个长按,谁先消费由运行时决定),另给一枚看得见的「⋯」
  (content-desc 消息操作)作为同一条入口,避免只有知道的人才会用。
- 编辑弹层:原始 content 作初值 + 码点计数 + 取消/保存;删除必须二次确认。
- ChatScreen 只加 24 行接线(两个回调 + 两个弹层的渲染)。

测试 +49(766 → 815,全绿):帧解析 14、纯逻辑 15、Retrofit 层 7、失败文案与落点 13。
2026-10-04 21:26:52 +08:00
AskaEth 5d8ab15c0e docs(02): 登记 unreadable 缺原因字段 + 文本层 PDF 的措辞别扭(均已如实处理,未替服务端编话) 2026-10-04 21:18:05 +08:00
AskaEth 1f9d1dde01 merge: W18 — App 内预览对非文本拒收(跟上 v0.61 的 .pdf) 2026-10-04 21:17:21 +08:00
AskaEth 48e41886ba feat(documents): App 内预览对非文本拒收 —— 跟上 v0.61 的 .pdf
后端 v0.61 把文档白名单 22 → 23 项(新增 .pdf)并改成(扩展名, 字节嗅探)成对裁决,
读侧 MIME 如实下发 application/pdf。而 W11 的附件预览是把字节当 UTF-8 文本渲染的
(那时白名单里除 .pdf 外全是文本类)⇒ 一旦点开别的端发来的 PDF 附件,预览就是一整屏
二进制乱码,像文件坏了。这次堵的是这个兼容洞,不是新功能。

- 选文件器加 application/pdf:原先刻意不列(那时必被后端 415 拒),现在 PDF 在选择器里
  不该是灰的,服务端收得下。
- 新增 DocumentPreviewGate(纯函数):mime 非空时只看服务端那次字节嗅探(以 text/ 开头
  判为文本,其余一律非文本,未知 mime 也判非文本——两侧代价不对称,误判成文本正是本闸
  要消灭的乱码);mime 缺失/空白时按扩展名兜底(白名单里只有 .pdf 是非文本),这条兜底
  服务的是待发附件(它只有本地 URI 与显示名)。大小写不敏感并剥掉 ; charset 参数。
- 弹层新增 plan 参数:判为非文本时正文区留空、只留一句人话并指路两条出口——弹层照常打开,
  文件名/体积/字数/状态与两条出口一个不少,也绝不假装加载失败(字节其实是取回来的,
  只是本端不渲染它)。
- 两条出口刻意未动:打开用 DTO 的 mime(PDF 就该交给 PDF 阅读器,不用通配符),保存继续
  传 */*(W11 实测结论:SAF 会按 mime 给文件名补扩展名)。
- 测试 766 → 775(DocumentPreviewTest 新增 9 条:闸的判定矩阵、扩展名兜底与大小写、
  未知 mime 拒收、待发附件接线、以及 PDF 的打开类型不是通配符)。

未做(缺口留给主代理登记 02):DTO 只有 status=unreadable,没有服务端那句原因文案
(含「图片型/无文本层 PDF,需要 OCR」)⇒ 客户端沿用既有通用文案「读不到」,
不自己拼一句假装是服务端说的。
2026-10-04 21:16:29 +08:00
AskaEth 020687a82b docs(02): 收下后端回执八(v0.60 存储端点 / v0.61 PDF 文本层 / K1 API Key 落盘 / 两条行为变化) 2026-10-04 21:13:14 +08:00
AskaEth 440e441b31 merge: W17 — 重试记忆改多槽(每条失败各留自己的幂等键) 2026-10-04 21:12:19 +08:00
AskaEth d3fc0c8005 feat(chat): 重试记忆改多槽 —— 每条失败各留自己的幂等键
RetryKeyMemory 从单槽改成多槽(slots: List<RetryKeySlot>,上限 MAX_SLOTS = 8,FIFO 淘汰最旧)。
单槽只记得最近一次失败那条,而用户的重试顺序不必和失败顺序一致:他重试 A 又失败、再回头去重试
更早失败的 B 时,B 的槽早被顶掉,那次重发只能铸新键 —— 而 B 那次失败完全可能是「服务端已经收下、
客户端没看见」,于是又落成第二条消息,正是本机制要消灭的重复。

- remember:同指纹先摘掉旧的、再追加到末尾(同一条又失败一次是更新它的键,不是并列两条),
  超过上限从头部丢最旧的,且刚写进去的那条一定留得住。
- forget:按 localId 清;一条都对不上就原样返回同一个实例;同 localId 万一有多条则一并清掉。
- keyFor:命中只消费那一条、其余槽全部保留;未命中铸新键且一条槽也不清 —— 单槽时代「未命中也要
  清空」在多槽下会把别的失败记录一起抹掉,这条已显式写进 KDoc 以防后人照着旧注释改回去。
- ChatViewModel:代码逻辑一个字节没动(三个调用点的签名都没变),只把提到「单槽」「下一次发送就
  被消费」的 KDoc 改成与新实现一致;值比较指纹、正文不归一、晚到的 message_persisted 刻意不清键
  这几条 W15 纪律原样保留。
- 测试:RetryKeyMemoryTest 由 10 条重写为 16 条。删掉「未命中即清空记忆」那两条旧断言(新语义下
  不再成立),新增:两条失败各自原样重试都落回自己的键(两种重试顺序各一条)、重试 A 不抹掉 B 的槽、
  同指纹重复 remember 只留一条且键是最新的、超上限 FIFO 淘汰最旧且刚写的仍在、未命中时全部槽保留、
  命中消费后其余槽保留。

验证:assembleDebug 与 testDebugUnitTest 均通过(--rerun-tasks 全量重跑):760 → 766 测试 / 0 失败 / 0 错误。
2026-10-04 21:11:45 +08:00
AskaEth b579732db6 docs: 落 W15/W16 批次(重试复键的取舍、会话内搜索的形状)+ 六条契约口径登记 2026-10-04 20:50:23 +08:00
AskaEth 46ac15a538 merge: W16 — 会话内消息搜索(顶栏入口 + 搜索面板 + 点结果定位) 2026-10-04 20:45:09 +08:00
AskaEth 23b46f9310 feat(chat): 会话内消息搜索 D2 —— 顶栏入口 + 搜索态面板 + 点结果定位
改了什么
- data/api/SessionsApi.kt:messages 端点加可选 q(契约 §1 行 6:q 是同一个端点上的
  查询参数,不是新端点)。不传 q 时 Retrofit 整个省掉该参数,既有调用线上请求逐字不变
  —— 这条用真 HTTP 服务端读原始 URI 钉住,而不是靠读代码相信。
- data/repository/MessageRepository.kt:新增 search()(走既有 apiCall/Result 风格,
  倒序返回统一转成升序的 PagedMessages),以及 normalizeSearchQuery(只 trim:
  不转义 % _ \、不改大小写 —— 这两件事服务端已做,两边都做必然错一边)。
  空查询不发请求(契约里空 q = 未传,那会把整段历史当成搜索结果回给调用方)。
- ui/chat/SessionSearchModel.kt(新):全部纯逻辑 —— 命中片段投影(先过 domain/parse
  这一处解析器再拼回纯文本,绝不把标记当富文本;标记里才有的词只显示不高亮)、
  结果合并去重排序(倒序,与聊天页的升序 MessageOrder 刻意相反)、游标推进、
  「这条在不在窗口里」的判定、失败文案。
- ui/chat/SessionSearchViewModel.kt(新):只属于聊天页搜索态的小 VM,带代际号,
  连打两次回车时慢的那个请求回来不会顶掉新关键词的结果。
- ui/chat/SessionSearchPanel.kt(新):全屏搜索面板 —— 自动聚焦输入框(IME 搜索键与
  放大镜都能提交)+ 结果列表(角色 / 时间 / 命中片段,BasicComponent 包 PreferenceCard)
  + 往下翻加载更早的命中 + 未搜/搜索中/空结果/失败 四态分开渲染。
- ui/chat/ChatScreen.kt:只加接线 —— 顶栏搜索入口、面板叠层、返回键先关搜索、
  点结果之后的如实告知。

为什么这么做
- 搜索态独立成小 VM:ChatViewModel 正被另一个并行模块改动,而搜索不改消息窗、不发消息、
  不碰 WS,没有任何理由挤进去。
- 面板叠在聊天页之上而不是替换它:底下的 LazyColumn 必须保持挂载,点一条命中要
  animateScrollToItem 定位,而 LazyColumn 一旦被移出组合,滚动就落空。
- 命中不在「已加载窗口」里时不插一条临时气泡,而是如实提示(比一个假的跳转诚实):
  聊天列表是升序倒过来喂给 reverseLayout 的,凭空插一条几千条之前的消息会出现在列表最底
  ——也就是「刚刚发出的位置」——与它自己的时间戳矛盾,还会带偏跟随底部的判断;
  而把消息真的并进窗口要改 ChatViewModel,本模块不许。
- 失败绝不渲染成「没有找到」:两者都是一张空列表,但一个该换关键词、一个该修网络或权限。
- 返回键先关搜索:新处理器注册在 ChatHomeScreen 那几条之后(优先级更高),
  且搜索没开时是 disabled,抽屉 / 管理页 / 设置页的既有层级不变。

测试 706 -> 737(+31:纯逻辑 25 + 数据层 6)
2026-10-04 20:44:03 +08:00
AskaEth 8b907036db merge: W15 — 手动重试同一条内容时复用原幂等键(改过的内容照旧铸新键) 2026-10-04 20:28:40 +08:00
AskaEth af11a94614 fix(chat): 手动重试同一条内容时复用原幂等键,改过的内容照旧铸新键
W13 顺手查出的「文档与实现不符」:PendingMessage 的 KDoc 一直写着「重试复用同一个
localId」,而 emitAndDeliver 每次发送都新铸 localId —— 若某次失败其实是「服务端已经收下、
客户端判成失败」(队列满 / 超时 / 回执丢失),用户手动重试就会铸新键、被服务端当成第二条
消息落库,用户看到重复。这正是幂等键(契约 v0.56 §3.13 / 协同 G4)要防的事。

- 新增 domain/chat/MessageFingerprint.kt:正文 + 图片本地 URI + 附件本地 URI 的内容指纹。
  用纯值相等而不是哈希(摘要碰撞会让两条不同消息共用一个键,后一条被服务端判重丢掉),
  正文一个字都不归一(只改一个空白字符也算改过 —— 归一正是会把两条真实消息并成一条的操作)。
  附件只看本地引用:displayName / sizeBytes 是元数据,同一份文件两次查询之间可能一次报得出
  一次报不出。
- 新增 domain/chat/RetryKeyMemory.kt:单槽重试记忆,三个纯函数(remember / keyFor / forget)
  加一个判定结果类型。remember 只在回滚时调用;keyFor 是唯一的消费点,指纹相等即复用、
  不等则铸新键(两支架都消费掉记录);forget 按 localId 清,避免两条同时在飞时互相踩。
- ChatViewModel:所有回滚路径(发送失败、队列满被拒、附件与图片上传失败、图片处理失败、
  附件超限……)本来就汇到一个 rollback,现在在那里按指纹记下那条的键;队列满判定的 Rollback
  档同样记、Adopt 档清。emitAndDeliver 改用 keyFor 取键。完成回执与 duplicate / 收编按
  localId 清(正常那一路在发送时已消费,这几步是兜底)。落库消息到达时刻意不清:一条晚到的
  message_persisted 恰恰是「服务端其实收下了」的证据,留着键才能让重试真正幂等。
- duplicate 路径复用既有 alignDuplicate / applyQueueFull 的 Adopt 档,没有另造对齐逻辑。
- 测试 706 → 729(+23,0 失败):指纹 13 条(正文空白 / 换图 / 增删与换序附件 / 元数据不算
  内容 / 快照语义),重试记忆 10 条(复用只在指纹相等时发生、用掉即消费、编辑后不再复用、
  forget 只清得动那一条、以及 duplicate 收口的端到端那条)。
2026-10-04 20:27:00 +08:00
AskaEth 76d49b73d1 docs(05): 落 W13/W14 批次与实机验收、W13 的两条文档不符、W14 取字节无 logcat 的证据改法、回执七要点 2026-10-04 19:52:57 +08:00
AskaEth f10984622c merge: W14 — 点气泡里的图片进全屏查看,并接上「用其它应用打开 / 保存到下载目录」 2026-10-04 19:48:52 +08:00
AskaEth 557ef3f524 feat(chat): 点气泡里的图片进全屏查看,并接上「用其它应用打开 / 保存到下载目录」
气泡里的图以前点了没有任何反应。现在点图进一个铺满全屏的弹层:fit-center 渲染、双指捏合
缩放(以双指中点为不动点)与单指拖动平移、双击在 1x 与 2x 之间切换(以双击点为不动点)。
位移与缩放全部按内容边界钳制——fit 态下拖不动(内容没超出容器)、放大后拖不到露出空白、
缩放恒在 1x..3x(下界比任务书的 0.5x 更紧:1x 就是「整张图刚好放得下」,再缩只是变小加留白)。
双击或关闭即复位(缩放状态活在弹层的组合里)。退出有右上角关闭按钮与系统返回键。

弹层底部两条出口与 W11 的附件预览同源,缓存与落盘复用 DocumentCache:图片也落在
cacheDir/documents/ 下,所以 FileProvider 的暴露面一个字节都没变(file_paths.xml 只改了注释,
没有新增暴露路径)。
- 用其它应用打开:声明图片自己的 mime(字节嗅探优先,其次平台声明的 image/ 前缀、名字扩展名,
  兜底 image 通配),经 FileProvider 交出去并带 FLAG_GRANT_READ_URI_PERMISSION;系统里没有
  应用能开时如实说一句并指路保存,绝不静默失败。
- 保存到下载目录:CreateDocument 一律传通配类型,否则 SAF 会按具体 mime 给文件名补扩展名
  (W11 实机抓到的 .md 变 .md.txt 那个坑;图片这边同样会变成 x.jpg.png)。

字节来源分三条,判定只发生在一处(ImageSource.of):服务端能力 URL(GET /api/v1/files/{name},
读侧免鉴权,所以用一条裸 OkHttp 客户端,而不是挂了 AuthInterceptor 的那条链——把 token 发给
一个免鉴权端点只多一个泄漏面)、内联 data: URI(字节就在字符串里,不联网)、本地 content 或
file 协议 URI(现读并拷进缓存,因此自己刚发的那张本地图也能交给别的 App)。

缓存名从服务端 URL 推,但要先过安全闸:单段、非 . / ..、不含分隔符与 NUL、不含百分号、扩展名
在图片白名单里;任何一条不满足就回落到按字节内容寻址——绝不拿一个不可信的名字拼路径。

加载态 / 失败态 / 就绪三者分明:取字节失败给一句话加一个重试入口;字节拿到了但解码不出来
(例如服务端被代理换成了 HTML 错误页)另给一句话,绝不画成一张空白图。重试把两条路都重来一遍。

测试 634 到 676(+42):来源判定、缓存名的安全闸、字节嗅探、打开与保存的类型选择、失败文案、
缩放平移的全部数学,以及图片缓存层的命中 / 回落 / 失败不留残留文件 / 真的走一次本地 HTTP
(JDK 自带的 HttpServer,验 200 / 404 / 0 字节三种响应)。纯装配部分(Dialog、手势识别、
Intent 投递、SAF 落盘)没有单测,留给实机验收。
2026-10-04 19:48:18 +08:00
AskaEth 9e039863fa docs(02): 收下后端的回执七(大附件护栏修复上线、API Key 仅内存表、IM 文件边界、v0.60 预告) 2026-10-04 19:43:52 +08:00
AskaEth 9854cc477d merge: W13 — 队列满时定位并处置那条待发气泡(pending_full / 定向 chat_busy 的 clientMsgId) 2026-10-04 19:42:49 +08:00
AskaEth 3b4a790e30 feat(ws): 队列满时定位并处置那条待发气泡(契约 v0.59 / 协同 G5)
WS 上行没有 REST 那样的同步回执:队列满时服务端只广播一条 pending_full,而载荷里既没有
「被拒的是哪一条」也没有任何可对齐的键,于是那条乐观气泡永远挂「发送中…」,用户不知道它
其实根本没发出去。后端 v0.59(G5)已经把这两件事补齐:pending_full 载荷新增两个可选键
clientMsgId(被拒那条消息的幂等键,未携带则整键缺席)与 messageId(仅当该消息此前已落库 /
已认领时才回填,服务端绝不在此新铸 id),并新增一条只发发送者的定向帧
error{code:"chat_busy"},与 REST 的 409 chat_busy 同码。本端把这两条接上。

data/ws/WsEvent
- PendingFull 增 clientMsgId / messageId:缺席就是 null,绝不拍成空串(否则收编路径会去
  对齐一条不存在的消息)。
- Error 帧增 code / clientMsgId / messageId,并从 data.sessionId 取会话归属(定向帧的帧壳
  上没有 sessionId)。code 是开放集合:未知码原样保留,不抛、也不吞掉整帧。
  未给定向 error 另立事件类——它与既有 error 是同一个 type,一个 type 无法用两个类表达;
  两者语义的分叉放在下面的判定函数里,保持「一个帧类型一个事件类」的既有约定。
- PendingFullData / ErrorData 同步扩键,全部可选且默认 null。

ui/chat/ChatViewModel
- 新增纯函数 queueFullOutcome(判定)与 ChatUiState.applyQueueFull / rollbackPending
  (状态迁移),两者都不碰协程,逻辑不埋在协程里、可直接单测。既有 rollback 改为委托
  rollbackPending,行为逐字不变。
- 判定顺序即证据强度:clientMsgId 命中时,messageId 非空 => 收编(复用 alignDuplicate:那条
  其实早就落库了,不是失败、不回滚、不把正文/图片/附件退回输入栏),缺席 => 回滚那一条并把
  内容还给输入栏;clientMsgId 有值但不命中 => 放手(键只属于某一次具体发送,回滚任何一条都是
  误伤);clientMsgId 缺席(老服务端)=> 沿用 toolExecutionTargetIndex 的先例,只在待发恰好
  一条时回滚它,两条及以上不猜、但也不装作没事,只如实提示一句。
- 广播 pending_full 与定向 error{chat_busy} 会同时到达发送者,两者共用 onQueueFull;
  幂等靠状态本身——第一次已把气泡移出待发列表,第二次判定自然落到 Ignore,不会回滚两次、
  也不会提示两遍。
- REST 409 chat_busy 的文案与 WS 侧合并成同一句(契约把两条入口定为同一个 code),
  并点明「这条没发出去、请手动重试」:全链路没有自动重试。

测试 +30(634 -> 664,0 失败 / 0 错误)
- WsEventPendingFullTest:两个新键的有/无、老形状仍可解析、会话闸、无载荷与坏帧不抛。
- WsEventErrorFrameTest:chat_busy 定向帧全字段、可选键缺席为 null、未知 code 原样保留、
  只带 code 的帧仍可解、只有 message 的老帧行为不变、坏帧不抛。
- QueueFullOutcomeTest:三种事实各自的判定与状态迁移(收编不动输入栏 / 回滚归还正文图片附件 /
  不猜时一个气泡都不动),以及键对不上、空键、同一帧处理两次的幂等。

未验证:本端没有设备也没打过真后端,队列满的实机触发路径见交接报告;VM 里
「判定 -> 既有动作」的映射只由编译与纯函数单测覆盖(构造 ChatViewModel 需要 Android
环境的 AppContainer,单测里没有先例)。
2026-10-04 19:41:40 +08:00
AskaEth 8633ec9f06 docs(05): 落 W11/W12 批次与实机验收、SAF 双扩展名 bug 的修复、GMS ANR 与 dump 重试两个新坑 2026-10-04 19:28:53 +08:00
AskaEth ffa7ed2661 fix(chat): 保存附件时不要被 SAF 补成双扩展名
实机复现(2026-10-05):把服务端附件 `809320…e.md` 保存到下载目录,落盘成了
`809320…e.md.txt` —— 因为 `CreateDocument` 传的是服务端**字节嗅探所得的基类型**
(`.md` 也会报 `text/plain`),而 SAF 的 Downloads provider 会按该 mime 给文件名**补扩展名**。

改为传 `*/*`:没有可补的扩展名,原扩展名因此保真。
**「用其它应用打开」那条路刻意不改** —— 那里要的正是「能读懂这段内容的类型」,照旧用嗅探所得的 mime。

实机复核:同一枚附件保存后落盘为 `809320…e.md`(95 字节,与源一致),不再出现 `.md.txt`。
2026-10-04 19:27:53 +08:00
AskaEth 60b0eacafd merge: W11 — 附件芯片可点开预览 + 「用其它应用打开 / 保存到下载目录」两条出口(FileProvider) 2026-10-04 19:22:25 +08:00
AskaEth 27b6910009 feat(chat): 附件芯片可点开预览,并给「用其它应用打开 / 保存到下载目录」两条出口
点一枚附件芯片此前完全没有反应。现在点它会开一个 App 内预览弹层,弹层里再给两条出口。

- FileProvider(authority = ${applicationId}.fileprovider,res/xml/file_paths.xml 只暴露
  cacheDir/documents/):把 file:// 交给别的 App 在 API 24+ 会抛 FileUriExposedException,
  必须走 content://;因此 cacheDir 下真正要交出去的那一层才被开出来。
- 新增 data/repository/DocumentCache.kt:缓存路径推导 / 命中判定 / 原子落盘是可单测的纯逻辑;
  DocumentCacheStore 把「服务端按存储名下载」与「本地待发附件」两条来源汇成同一份文件 + 字节。
  服务端存储名是内容寻址(sha256[:32]+ext)且读侧响应 immutable,所以命中即永不失效、命中时
  一个请求都不发;先写 .part 再改名、失败删干净——半截文件被下一次「命中」当成完整内容,
  是最坏的一种静默错误。
- 新增 domain/chat/DocumentPreview.kt(纯逻辑):截断(首 256 KB 或 5000 行,先到者为准;
  字节切点不切坏多字节 UTF-8)、正文分块(200 行一块)、来源判定(服务端 vs 本地,只有一处)、
  打开用的 MIME(只用 DTO 里字节嗅探所得的 mime,绝不按扩展名猜)、「没有 App 能开」的裁决与
  文案、取字节失败的分类文案。
- 新增 ui/chat/DocumentPreviewDialog.kt:Compose Dialog + Miuix 内容。正文用 LazyColumn 分块渲染
  (5000 行不塞进单个 Text),每块各自 SelectionContainer;加载态与失败态分明(失败给重试,
  绝不渲染成空文件);截断明写「只显示前 N,完整内容请用…」,不静默截断;复用 DocumentChips
  显示类型/体积/字数/状态,不另写一套标签;status 为 unreadable 不挡预览(那是模型读不到,
  文件本体一直在)。
- ui/chat/MessageBubble.kt:芯片整枚可点(点击区是那枚芯片本身,不是里面的两行文字),
  并带 contentDescription「查看附件 <name>」。
- ui/chat/ChatScreen.kt:预览状态 + 取字节 + SAF 保存 + ACTION_VIEW 打开。没有 App 能开时
  如实告知并指路保存,绝不静默失败。

契约缺口(需登记 02):服务端按内容寻址落盘,DTO 里没有原始文件名,保存到下载目录只能用
<32hex>.<ext>——弹层里已把这一点写在界面上,没有替用户编一个假名字。

测试 567 → 610(+29 DocumentPreviewTest / +14 DocumentCacheTest),0 失败 0 错误。
2026-10-04 19:21:31 +08:00
AskaEth a0d6e3479f docs(02): logs REST 已接上(闭环)+ 五条契约口径建议补写 + 铸不出 viewer 账号的验收缺口 2026-10-04 19:20:15 +08:00
AskaEth a7dbee847e merge: W12 — 「最近日志」接上 logs REST 引导读取(补齐只显示进页面之后那段日志的缺口) 2026-10-04 19:19:18 +08:00
AskaEth 642bdb13f0 feat(admin): 「最近日志」接上 logs REST 引导读取 —— 补齐 W10 只显示进页面之后那段日志的缺口
W10 接上的 `observability_log` 帧以「订阅那一刻的最新条目」为锚、不回灌历史,而客户端没有
`GET /api/v1/admin/observability/logs` 的接入 ⇒ 概览页的「最近日志」只能显示进页面**之后**产生的
条目。本次把 REST 引导读取补上,并把 W10 那条取舍(「一帧都没到过 → 整段不渲染」,为的是不把
「没有推送」说成「没有数据」)改成契约要求的「**REST 成功即渲染、帧是增量**」。

- **取数**:`AdminApi` + `AdminRepository` 接上该端点,显式发 `limit=200`(与客户端保留窗口、
  帧的单帧上限同源);**不暴露** `?level=`——它是服务端「最低等级」过滤,只在服务端裁剪一次,
  会饿死界面上那排要来回切换的等级档,所以一律取全量、由客户端本地过滤(契约 §3.12 亦然)。
- **DTO 统一**:REST 与帧同形(后端同一个 `gateway.AdminLogEntry`),因此把
  `data/ws/ObservabilityLogEntry.kt` 挪进 `data/api/dto/AdminDto.kt` 并改名 `AdminLogEntryDto`
  (顺带与 `AdminMetricsDto` / `AdminTraceSpanDto` 对齐),删掉旧文件——不留下两份等价形状。
- **合并纯函数**:日志改成「去重 → 按 `time` 倒序 → 截窗口」。三步顺序不能换:窗口是**位置**
  截断,先截再排会让一条更旧的条目挤掉更新的那条;排序用解析出的 epoch 秒 + **稳定排序**保住
  同秒条目的到达顺序(服务端 `time` 只有秒精度);时间判不出来的条目落到尾部但**不丢**。
  trace 那条路径不动(span 有服务端写入序号,重排反而把顺序排乱)。
- **界面**:`logs` 改为可空——null = 还没有基线(不渲染);非 null(含空列表)= REST 读过且成功
  ⇒ 渲染,于是「加载中 / 不可用 / 暂无日志 / 列表」四种表现分明,未知等级条目照旧一律保留。
  三路的失败原因也分开记了:日志挂了不再让 trace 那块显示「不可用」(那是把一次成功说成失败)。
- **角色分档**:新增纯函数 `canReadObservabilityLogHistory`(日志历史与概览页同档,
  v0.40 的 viewerAuth 只读监控档 ⇒ **viewer 也读得到**),与只发 owner/admin 的帧门刻意不对称。
  ⇒ viewer 看得见历史、收不到增量,界面不得因此报错或报空(区块是否渲染只看历史这条门)。

测试 567 → 591(DTO 边界、合并去重/排序/窗口、角色分档、仓库线级请求与 501/403/坏载荷)。
`assembleDebug` 与 `testDebugUnitTest` 均通过(591 测试 / 0 失败 / 0 错误)。未做真机与真后端验收。
2026-10-04 19:18:42 +08:00
AskaEth c94ea09b59 docs(02): 登记纯附件回合(Android 端首测)没有助手消息 + 两次 knowledge 工具失败 2026-10-04 19:12:52 +08:00
AskaEth e59ef08cf9 docs(05): 落 W8–W10 批次状态、模拟器与该轮自动化的坑、契约 v0.59、附件预览欠账 2026-10-04 19:09:10 +08:00
AskaEth ca5596a732 fix(chat): 附件芯片改换行排布 —— 多附件不再被裁到屏幕外
主人 2026-10-04 在主会话里发现「用户消息显示有问题」:带 2~3 个附件的用户消息里,
第一枚芯片就吃掉了整排——服务端存储名是 32 位十六进制 + 扩展名(如 `36a4aba0…5524808.txt`),
一枚约 267dp,而芯片排是「横向可滚的单行 + `widthIn(max = 320.dp)`」,于是第二枚只剩一条边
露在屏幕外。看的人不知道还有别的附件,也看不出那一行可以滚。

改为 `FlowRow` 换行排布(用户消息整排贴右 `alignEnd`),N 枚芯片全部可见;代价是 5 枚会占
2~3 行——比「藏起来」值。去掉随之失效的 `CHIP_ROW_MAX_WIDTH` 与两个 import,
并同步 `ChatScreen` 里待发芯片排的注释(同一处行为)。

实机复核:主会话那条三附件消息现在渲染为三行,三枚芯片与各自的
「TXT · 4 B · 4 字 · 已读入」全部可见(截图 `app/build/e2e-shots/chips-after.png`)。
2026-10-04 19:08:26 +08:00
AskaEth 2369dd2115 meta(docs): devdocs/ 纳入版本库 —— 交接与协同文档不再只存在于本机
主人 2026-10-04 指示「项目仓库也可以写进去」。因此把 devdocs/ 从 .gitignore 移除并首次入库:

- `05-开发进度与交接.md` —— 跨会话交接锚点:当前状态、易忘的实现约定、踩过的坑、实机验收方法
- `02-前后端协同事项.md` —— 与后端会话的**交换文档**(两边都会读写:我们登记缺口,对面写回执)
- `01/03/04/06/07` + `DESIGN.md` —— 路线、客户端细化方案、构建环境、迁移说明、给后端会话的交接

**凭据扫描已过**:无账号口令、无 token/refreshToken 明文、无 API key 或私钥。
文档里只记「登录凭据不写进文档,问主人」这条口径本身。

(此前忽略它的理由已不成立:文档现在承担跨会话交接与前后端协同两件正事,
只留在本机磁盘会随机器丢失,而这批内容的价值恰恰在「后来人读得到」。)
2026-10-04 19:00:18 +08:00
AskaEth 29733914a4 docs(documents): 契约 v0.59 已发布 —— 更正 W6 那批「未发布/待发布」的过时注释
Cyrene_v2/Docs/api-client.md 已发布 v0.59(§1.2「文档存储与摄取契约」+ 端点表 16/17),
而 W6 那批文件的 KDoc 仍写着「契约尚未发布 / 待发布」,会误导后来人以为形状没有契约背书。

已逐条核对:我们实现的形状与已发布契约**完全一致** —— multipart 部件名 `file`、
201 首次 / 200 + `deduped:true` 去重、单文件 20 MiB、单回合 ≤5 条、上行只收 `name`、
`url` 是相对路径、`mime` 是嗅探基类型、`documents` 数组含 `chars`/`truncated`/`status`、
0 个附件时该键省略、4 个错误码。白名单在契约里也已更正为 22 项(与代码一致)。

纯注释更正,零行为变更。
2026-10-04 18:53:10 +08:00
AskaEth ab76798bff merge: W9 — 文档附件接到 UI 与发送链路(选文件 / 待发芯片 / chat.documents / 分享分流) 2026-10-04 18:48:42 +08:00
AskaEth 30180ab1e5 feat(chat): 文档附件接到 UI 与发送链路(选文件 / 待发芯片 / chat.documents / 分享分流)
把 W6 做好的「文档进对话」数据层接上 UI 与发送路径,产品决定按 05 的定案执行:
**不做长正文自动转文件**、**附件与正文各自独立存在**、上行**只给服务端存储名 `name`、≤5 条**、
带附件一律走 REST。

改了什么(按清单):
- AppContainer:照 cachedUploadApi 的写法加 documentsApi + documentRepository,**与 chatApi 同
  baseUrl / 同 token·401 策略**(上传拿到的 name 要交给同一个后端的 /chat,不同源必然 404)。
- domain/chat/DocumentAttachment.kt(新):DraftDocument(待发附件,只带本地引用,**不带字节**)、
  admit()(去重 → 20 MiB 单份预检 → 5 份上限,被挡下的**都计数**、绝不静默裁)、
  readBounded()(超限回 null、IO 异常原样抛——「太大」与「读不到」必须分得开)。
- domain/chat/DocumentChip.kt(新):附件芯片的展示换算(类型 / 体积 / 注入字数 / 状态),
  unreadable(读不到)与 truncated(注入被预算截断)**两句文案分开**,未知 status 原样显示。
- domain/chat/DocumentDelivery.kt:加 planForCount(text, 份数)——同一套判定矩阵,但只看份数,
  因为 VM 手上还没有字节(最多 5×20 MiB 一次性读进内存不可接受)。plan() 委托给它,行为不变。
- ChatViewModel:ChatUiState 加 draftDocuments / attachmentNotice;send() → deliver() 走
  DocumentDelivery 判定 → 逐份上传(保留扩展名、200+deduped 当成功)→ payload(names) 为 null
  时**提示而不截断** → sendViaRest 带 documents;失败与正文、图片一起回滚(附件还给输入栏)。
- ChatScreen:SAF OpenMultipleDocuments 入口(MiuixIcons.File + contentDescription「添加文件附件」),
  MIME 列表贴近后端文本白名单的语义(含 octet-stream 以免「后端收得下、用户选不到」),
  输入栏上方一排可移除芯片;发送按钮在只有附件时也可用(纯附件回合服务端支持)。
- MessageBubble:documents 渲染成芯片(名字中略保住扩展名 / 类型·体积·字数·状态),
  unreadable 整枚转灰并明写「读不到」;空列表不占位。PendingBubble 显示本地待发件。
- ShareIntake + ShareRules:EXTRA_STREAM 按 ContentResolver.getType 分流——image/ 前缀走既有
  图片路,其余(含 null)走**文档路**(拷进 cache、带显示名与真实字节数)。此前一律当图片拷,
  一份 .md 只能得到「图片处理失败」。**Manifest 一行未改**:后端明令拒绝 html/SVG/zip/脚本,
  声明全通配会造出「点进去必然失败」的入口,text/plain 已能接住 .txt/.md/.log。

测试:485 → 536(+51,0 失败 0 错误)。
2026-10-04 18:44:23 +08:00
AskaEth 93941cd1ea merge: W10 — 接上可观测性下行帧(概览页实时刷新 metrics / trace / 日志) 2026-10-04 18:42:58 +08:00
AskaEth b5e6f78910 feat(admin): 接上可观测性下行帧,概览页实时刷新指标 / trace / 日志
契约 v0.55 §3.12 的三帧(`observability_metrics` / `observability_trace` / `observability_log`)
此前在本端没有 handler,落到未知帧被静默丢弃,概览页只能「进页面 / 手动刷新各拉一次 REST」。

- 解析(data/ws):三个事件类 + `dispatchObservability` 分支,载荷按契约逐字对齐
  (metrics = 扁平本体、trace = `{spans}`、log = `{entries}`),帧壳 `sessionId` 有就透传。
  载荷缺席时**整帧安全忽略**——metrics 回落成默认值就是一份全零快照,会把界面上的真实指标
  擦成 0;未知帧、坏帧仍安静丢弃(回归用例钉住,绝不抛)。
- 消费(ui/admin):AdminViewModel 自持一条 socket(照 SessionsViewModel 的做法:登录且
  角色够档时连、登出 / 角色降档 / onCleared 关),把三帧合并进概览状态。REST 仍是兜底真源:
  进页面与手动刷新照旧各拉一次全量;每次「连上」(含首次连接与断线重连)后再重读一次——
  断线窗口里的帧永久丢失,而服务端只为「有在线 owner/admin 连接」的凭据保留订阅,
  重读同时也重建订阅(契约 §3.12:读取即订阅握手)。
- 角色门在客户端也判(domain/admin 新增纯函数 `canReceiveObservabilityFrames`):这三帧服务端
  fail-closed 只发 owner + admin,与概览页自身的 Monitor 档是两条门,viewer 既不开连接也不合帧。
- 日志只有推送这一个来源(本端没有 logs REST 端点):一帧都没到过时整段不渲染——不写
  「暂无日志」,那等于把「没有推送」说成「没有数据」;等级过滤按契约在客户端本地做。
- 合并与过滤是 ui/admin 下的纯函数(新帧在前、按业务键去重、窗口 100/200),单测覆盖。

测试 485 → 514(+29),0 失败 0 错误;`assembleDebug` 通过。
2026-10-04 18:42:23 +08:00
AskaEth f24877d53b merge: W8 — 设置页「版本」行可点进「关于」页(含作者署名) 2026-10-04 18:35:11 +08:00
AskaEth 3ed2377b3c feat(设置): 「版本」行可点进「关于」页
设置页的「关于」分组里,「版本」此行此前点了没反应——它是全页唯一
没有 onClick 的行。现在它带 ChevronForward(与管理员入口行同一种「可
进子页」提示)并可点,进入新的「关于」页。

「关于」页只有四项:应用名(Cyrene)、版本、一句话简介、作者
(AskaEth),自己带 Scaffold + 返回按钮,观感与设置页一致。版本号复用
BuildConfig,不写死;页面里不放任何链接 / 邮箱 / 版权年份——这些信息
没有人提供过。

返回层级照项目既有的手写布尔状态 + 分层 BackHandler 模式(本项目无导航
库),新增 showAbout:
- 关于 → 设置 → 聊天;
- 设置页的处理器条件是 showSettings && !showAbout(与关于页互斥),
  所以在关于页按返回只退一层,不会一步跳回聊天;
- 抽屉 / 设置 / 管理页既有层级未动。

版本文本抽成纯函数 versionText(versionName, versionCode),设置页与关于页
共用一份格式,并由 VersionTextTest 钉住输出。

测试:485 → 487(新增 VersionTextTest 2 条),0 失败 / 0 错误;
assembleDebug 通过。
2026-10-04 18:31:03 +08:00
AskaEth a6b4bae2a0 merge: W4 — 概览页「按路径」分组折叠(可读性) 2026-10-04 18:09:18 +08:00
AskaEth a80dac7a7e merge: W6 — 文档进对话数据层(/documents + chat.documents,v0.59 形状) 2026-10-04 18:08:20 +08:00
AskaEth f91a8558ea feat(admin): 概览「按路径」改成分组可折叠列表 —— 26 条路径不再挤成一行
`perPath`(真实响应 26 条)此前被 `formatCounts` 压成一行右对齐文本,手机上极密。
本次只改呈现层,取数(DTO / Retrofit / ViewModel)一字未动。

- domain/admin/AdminPathStats.kt(新增,纯函数):分组键 = **剥掉 `/api/v1` 后的首段**
  (`/api/v1/admin/llm/catalog` → `/admin`,`/sw.js` → `/sw.js`,`/` → `/`)。
  规则是纯字符串推导、**没有任何路径清单**:服务端加路由不用改客户端
  (`/api/v1/quantum/*` 自动成 `/quantum` 组);逐段比较,`/api/v1x/foo` 不会被误剥成 `/x`。
  剥离只作用于分组键,行里显示的仍是完整路径。
  排序:组按命中次数倒序、组内同样倒序,**两处同数都按路径升序**——指标页每次刷新都会重排,
  没有稳定次序读者就跟丢了。`hits`(次数)与 `pathCount`(条数)分开,是组头的两个数。
  长路径(段数 > 4)展示时折叠中间段、保留尾部 3 段:`…/admin/observability/metrics`;
  尾部才是区分同类路径的部分,整串在手机上会被尾部省略号吃掉、两条路径就长得一样了。
- ui/admin/AdminOverviewScreen.kt:「按路径」从 `countRows` 里挪出来单独成块——
  区块标题给「26 条 · 326 次」,卡片内一行一组,组头「N 条 · M 次」+ 可旋转箭头,
  整行可点即展开/收起;组内一行一条路径,右侧计数右对齐、路径等宽字体 + 中间省略。
  **条数 > 4 的组默认收起**(真实数据里 15 条 admin 路径收成一行组头),小分组默认展开省一次点击;
  折叠状态只记**用户动过的组**(`remember { mutableStateMapOf() }`,放在 LazyColumn 之外),
  刷新拿到新数据不会被上一轮点按锁死。
  只用 Miuix 现成组件(`PreferenceCard` + `BasicComponent` + `HorizontalDivider` +
  `MiuixIcons.ChevronForward` 旋转 90°),**没有自造折叠控件**;路径行用内容重载自己画 Text
  才能拿到 `maxLines = 1` + `MiddleEllipsis`(收 `String` 的那个重载只会尾部省略)。
  失败态不变:`metrics` 缺席时整块不渲染(上面那张「指标不可用」卡已经说明了原因),
  只有 `perPath` **请求成功且为空**时才给「暂无路径统计」。

测试:353 → 370 全绿(+17,AdminPathStatsTest):空输入 / 单条(不产生组头)/ 真实 26 条 →
10 组且总次数 326 = 同一份真实响应里的 `totalRequests`(分组不吞路径也不重算次数)/
未知前缀自动成组 / 基址逐段剥离的边界(`/api/v1x/foo`、基址本身)/ 组与组内的排序与并列次序 /
长路径折叠(两条兄弟路径折叠后仍不同)/ 折叠默认值的阈值边界(4 条展开、5 条收起)/
显式切换双向覆盖默认。

未验证(无模拟器、也不许碰 adb,只有构建 + 单测):
- 观感(组头取 `primary`、路径等宽字号取 `footnote1`、子行缩进 12dp、箭头旋转)**没有截图**,
  取舍待主人拍板;
- `BasicComponent` 对长路径会不会折行没有实机确认,所以路径文本额外做了「段级中间折叠」,
  不依赖库的省略行为;
- 深色模式未单独验证,颜色全部取自 `MiuixTheme.colorScheme`(与页面其它卡片同源)。
2026-10-04 18:07:22 +08:00
AskaEth 557cdb213d merge: W5 — 新建会话时选角色(POST /sessions {personaId}) 2026-10-04 18:07:03 +08:00
AskaEth 095d6e781e feat(data,domain): 文档进对话数据层(v0.59 待发布形状 + 该不该走文件判定)
契约 v0.59 尚未发布(api-client.md 最新仍是 v0.58),形状依据
devdocs/02 回执五「契约形状修正」,并用后端已落库切片逐字段核对:
407c934(S1 存储端点)、a4789ec(S3-B 网关入口)、5b2b385(S3.5 多值化)。

新增:
- data/api/DocumentsApi.kt:POST /api/v1/documents(multipart 字段 file)、
  GET /api/v1/documents/{name}(Bearer owner|admin;@Streaming 取字节)
- data/api/dto/DocumentDto.kt:文档形状的唯一落点(v0.59 若不同只改这里)
- data/repository/DocumentRepository.kt:200 + deduped:true 当成功;url 是相对
  路径故不做图片那套绝对化校验;不做本地白名单/大小上限(服务端才是裁决者)
- domain/chat/DocumentDelivery.kt:该不该走文件的判定 + chat.documents 载荷构造
- domain/chat/DocumentStatus.kt / DocumentFailure.kt:状态与四个错误码的可分支映射

改动:
- ChatDto.ChatRequest 增 documents(默认空列表 ⇒ 旧请求逐字节不变,有字面量钉子)
- MessageDto.HistoryMessageDto 增 documents(缺键与空数组都解成空列表,覆盖
  后端「0 个」尚未定死的两种形状;WS message_persisted / history 回放共用该 DTO)
- ChatSendRouting.channelFor 增 hasDocuments(默认 false):带附件强制 REST,
  因为 WS 帧 4096 字节硬上限超限是直接断连,且文档一律走 REST

为什么不做 UI/VM:UI 与 ViewModel 文件由并行的分享入口模块占用,接线由主代理统一做。

测试:353 → 413(+60:DTO 12 / Repository 17 / Delivery 21 / Failure 10),
assembleDebug 与 testDebugUnitTest 均通过。
2026-10-04 18:06:27 +08:00
AskaEth 4834905b24 merge: W7 — 分享到 App(ACTION_SEND / SEND_MULTIPLE + 进程级暂存,登录后落地) 2026-10-04 18:06:17 +08:00
AskaEth 217cfe3d5f W5: 新建会话时选角色(persona)
改了什么:
- `POST /api/v1/sessions` 支持可选 `personaId`:null(或全空白)走**不带 body** 的重载
  (与选角之前逐字节相同),非空才发 `{"personaId":"…"}`;新增 CreateSessionRequest /
  CreateSessionResponse.personaId / SessionSummaryDto.personaId。
- 新增**用户侧**角色目录取数:PersonaApi + UserPersonaDto + PersonaRepository(沿用 apiCall/Result)。
  刻意不用 admin 的 PersonaSummaryDto/AdminApi:用户侧不下发 admin 专属 thinking,admin 端点还要管理档。
- 抽屉「+」不再直接建会话,而是先开角色弹层(「默认角色」+ 各角色,角色名 + character 简述,
  失败/加载中各自有话说);确认后按所选创建。角色表拿不到时降级为「只有默认角色」,不挡建会话。
- 会话行摘要前缀该会话绑定的角色名:能拿到角色表就显示名字,拿不到就原样显示 id,未绑就不显示。

为什么:
- 此前 createSession 完全没有 body,新建的会话一律是默认角色,后端 v0.42 起的多角色能力用不上。
- 会话角色**创建后不可变**(`PATCH /sessions/{id}` 一旦收到 personaId 就 400),所以选角这一步
  只在建会话时问一次,也必须在建会话时问。

测试 353 → 385(+32),assembleDebug 与 testDebugUnitTest 均通过。
变异验证:把仓库改回「null 也发 body」后,SessionRepositoryCreateTest 有 2 条断言 FAIL。
2026-10-04 18:06:11 +08:00
AskaEth ae4e4e53f6 feat(share): 接入系统分享入口 —— 分享文本 / 图片进会话(未登录先暂存,登录后落地)
主人 2026-10-04 提出的「分享到 App」的客户端半边。触发面就是系统分享菜单的
ACTION_SEND / ACTION_SEND_MULTIPLE;发送侧**完全复用既有路径**(短文本 WS、
长文本与图片 REST、大图先 /upload),不另起一套。

- Manifest:MainActivity 加 SEND / SEND_MULTIPLE 的 intent-filter,MIME 只声明
  text/plain 与 image/*。刻意不声明 */*:对话路径上今天没有「文件」这个表示
  (协同 02/F1 三缺),声明 */* 等于给出一个点进去必然失败的入口;收窄之后非图片
  文件干脆不进分享菜单。另加 launchMode=singleTop 与 label(分享菜单里显示 Cyrene,
  图标沿用应用启动图标,不另设一份会漂移的来源)。
- MainActivity:冷启动(onCreate)与热启动(onNewIntent + setIntent)两条路都接。
  冷启动只在 savedInstanceState==null 时接 intent —— Activity 因旋转 / 深色切换重建
  时系统会把同一个 intent 再送一遍,照单全收就会把同一条分享发第二次。
- 新增 domain/share(纯逻辑,可单测):
  - ShareRules.extract:文本 / 单图 / 多图 / 空内容 / 只有 MIME 没有内容 /
    EXTRA_STREAM 缺席时回落到 ClipData;去重与空白条目在**截断之前**处理
    (重复项既不该挤掉真图,也不该被算成「截掉了」)。一次最多接 4 张,超出的
    条数如实计数,不静默丢。正文一个字都不加(来源标记走独立 RoleSystem 消息 +
    SenderName,拼进正文即违反红线,见 02/F4)。
  - ShareRules.cacheFileName:文件名只由本端 token + 序号 + MIME 白名单扩展名构成,
    外部 URI 的路径 / 显示名绝不参与(纯函数里连本端 token 都不信,非法字符折成 _)。
  - PendingShareHolder:待落地内容的队列,take() 是原子出队 —— 一条内容只能被消费
    一次(消费它的地方都会被重建:重组、切会话、Activity 重建),同时不把先后两条
    挤在同一窗口的那次分享丢掉。
- 新增 app/ShareIntake:进程级单例(不塞 AppContainer,避免碰别的并行改动)。
  图片在收到的那一刻就拷进 cacheDir 并换成**本端 file://**(content:// 的读授权活不过
  登录 / 切页,拷不动的那几张如实计入「未发送」);进程启动清一次缓存目录
  (那份副本只对产生它的进程有意义),运行期间不回收 —— 发送失败回滚到输入框之后
  仍要读它。
- ChatViewModel:sendShared 与输入框的 send 共用 emitAndDeliver(同一套乐观气泡 /
  幂等键 / 回滚),差别只有「不动用户草稿」(分享不是用户按的发送键)。一条消息只能
  带一张图 ⇒ N 张图 = N 条消息,第一条带分享的正文;顺序在同一个协程里串起来。
  长文本不截断,由既有 ChatSendRouting 判据改走 REST(WS 帧 4096 字节是断连不是报错)。
- ChatScreen:仅当**当前会话**的组合存在时认领(已访问过的会话 VM 都还活着,UI 层
  认证才不会让旧会话抢走),落地前先等 WS 连接、上限 3 秒(冷启动必然撞上握手;超时
  也照发,让「没网」退化成内容回到输入框这种看得见的结果,而不是停在看不见的地方)。
  截断 / 读不到 / 空内容用 shareNotice 如实告知(8 秒后自动收走),与 error 分开。
- 测试 +23(ShareExtractionTest 18 / PendingShareHolderTest 5):文本、单图、多图、
  空、只有 MIME、ClipData 兜底与去重、截断计数、超大文本不截断且落 REST、
  文件名不可穿越、原子出队(并发 64 取 64 条不重不漏)。assembleDebug 绿,
  testDebugUnitTest 353 → 376 全绿(failures/errors 皆 0)。
2026-10-04 18:05:27 +08:00
AskaEth 664fd8dcef merge: W3 — B1 /upload(大图走上传,历史只留 URL) 2026-10-04 17:44:23 +08:00
AskaEth b44e8d96b9 merge: W2 — A2 interrupt scope(传输层 + 设置项) 2026-10-04 17:44:21 +08:00
AskaEth 51ea890684 merge: W1 — D4 未读(抽屉角标 + 已读游标) 2026-10-04 17:44:21 +08:00
AskaEth f413f8db55 feat(sessions): 接上 D4 未读 —— 抽屉角标 + 已读游标(进入即读、看着就点已读)
后端 v0.37 起 `GET /sessions` 每条会话恒下发 `unreadCount`、`POST /sessions/{id}/read`
可推进**账号级**已读游标(只前进,`MAX(read_at, before)`)、成功后广播 `session_read`,
客户端一直没接。本次补齐:列表能看见未读、看完真的会消、别的端读了本端也消。

- data/api/dto/SessionDto.kt:`SessionSummaryDto.unreadCount`(**默认 0**——
  `SessionPreferences` 把整份列表原样持久化,非默认值会让升级后第一次启动整份快照
  解码失败、列表先空一下;默认 0 只会让角标少显示一会儿);
  新增 `ReadSessionRequest`(`before` 缺席 = 现在,`explicitNulls=false` 保证序列化成
  `{}`,正是服务端接受的形状)与 `ReadSessionResponse`
- data/api/SessionsApi.kt + data/repository/SessionRepository.kt:`markRead(sessionId, before)`
  —— 不传 0(服务端对 0/负数回 400),null 即「现在」
- data/ws/WsEvent.kt:新增 `SessionRead`(`{type:"session_read", sessionId, data:{readAt}}`)。
  会话归属**只在帧壳**上(data 里没有 sessionId);`readAt` 缺席回落 0,而 0 在上层是
  「无从判断」——坏帧因此天然退化成什么都不改
- ui/sessions/SessionsViewModel.kt:`SessionItem` 多出 `unreadCount` 与 `lastMessageAt`
  (后者只用于游标比对);两条调和规则方向相反且都写成纯函数——
  `withReadCursor(readAt)` **有依据**地清(游标只前进,本地最新消息不比游标新才算真已读;
  若有更新的消息则保留旧计数,不能把真实未读藏起来),`markRead(id)` 无条件清(用户就在这一行)
- 已读策略:① `select()`(点开 = 进入)以「现在」推进——聊天页紧接着会加载并显示最新一页,
  比推本地可能陈旧的 `lastMessage.createdAt` 更诚实,且先就地清零,角标不等网络往返;
  ② `message_persisted` 且会话是**当前打开**的那一个时,以该消息 `createdAt` 推进
  (只标它及其之前,同时落库的更晚消息仍算未读),否则「自己正看着的会话」会攒出角标。
  不该标的绝不乱标:别的会话攒未读是正确行为,本端不做「替服务端数未读」(增量会漂移,
  抽屉每次打开都会静默重拉,以服务端为准)。失败**静默**——服务端游标是唯一真源
- `session_read` 帧按帧里的会话 id **逐行**定位(刻意不用 `isForSession`:那道闸是给
  「一个 VM 只关心一个会话」的 ChatViewModel 用的,本 VM 持有整个列表,按 activeSessionId
  过滤会把「别的会话被读了」这一帧丢掉,那正是最该处理的一半)
- 另记一份本地已知游标(只取服务端口径:read 响应的 readAt / 帧的 readAt / 落库消息的
  createdAt),在整表替换时把「本地已知没有更新消息」的行压回 0——否则一次竞态中的
  GET(快照早于本端刚发起的 read)会把角标闪回来;登出时随快照一起清(游标是账号级的)
- ui/sessions/SessionDrawerContent.kt:角标用 Miuix 自带 `Badge`(错误色药丸 + onError 文字),
  且**放在标题行里**而不是 `endActions`:抽屉固定 300dp,卡片与内边距之后只剩约 244dp,
  `endActions` 已有三个 40dp 图标(120dp)且被库内 60% 硬上限卡着,再塞角标会把图标挤到裁剪。
  改成标题行的 `[标题][角标]` 后角标不占额外列宽;标题/摘要同时收紧为 `maxLines = 1` +
  省略号(长标题不再在约 116dp 里折成两三行把卡片撑高);`99+` 收敛、`0` 不画

测试:305 全绿(272 → +33):角标文本(0/负数不画、99 精确、100 与 1234 收敛 `99+`)、
两条调和规则的各种边界(游标等于/新于/旧于最新消息、无消息会话、坏帧 readAt=0、
按会话逐行定位、遮罩只压记过游标的行)、`toItem` 映射、`session_read` 帧解码
(生产者逐字取自后端 `gateway/session_read.go:broadcastSessionRead`)、DTO 真载荷
(`GET /sessions` 抓包里主会话 unreadCount=6、次级=0)与**旧快照无该键仍解得开**、
read 请求体序列化(`{}` / `{"before":…}`)。

未验证(无模拟器,也不许碰 adb,只有构建 + 单测):
- 角标与标题的**实际观感**没截图。宽度是按 Miuix 0.9.3 源码算的(抽屉 300dp − 卡片边距 24
  − 组件内边距 32 = 244dp;endActions 120dp < 60% 上限,标题列约 116dp,角标约 16~32dp)。
  LazyColumn 里角标与标题的具体落位(紧贴标题还是行尾)取决于 Row 的权重分配细节,两种都成立、
  都不遮蔽文字,但真机上可能想微调间距
- 「进入即读」在 UI 上没端到端跑过:POST /sessions/{id}/read 是否 200、`session_read` 帧
  是否真的清掉另一端的角标,只有单测与契约(后端源码)支撑,没有真机往返
- 已知近似:判断「正在看这个会话」只用 `activeSessionId`,而它是**活动页**的会话 id——
  用户切到设置 / 管理页时 ChatScreen 已卸载,此时落库的消息也会被标记已读(他会回来看到,
  但角标已经消了)。信息不足,如实记在这里

架构代价(需主人裁决):`session_read` / `message_persisted` 只在 WS 上,而 `AppContainer`
只提供 `createChatSocket()`(每次新建,**没有进程级共享连接**),`ChatSocket` / `ChatViewModel`
/ `AppContainer` 都在别人的改动范围里 —— 本 VM 因此自持一条连接(登录后连、登出断开、
销毁断开)。这是既有遗留问题「每 VM 一条 socket、旧 socket 不关」之上再加一条;服务端支持
同账号多连接(多端本来如此),收不到帧也只是退回「下次 GET /sessions 收敛」。共享一条
app 级连接是更好的终局,但那要动 AppContainer / ChatSocket。
2026-10-04 17:42:31 +08:00
AskaEth cc2f266564 feat(chat): 大图改走 /upload,历史里只留 URL
服务端把 `chat.image` **原样落库**,于是内联的 base64 会在**每一页历史**与**每一次
WS `history` 回放**里重传一遍(协同 G3)。B1 的 `POST /api/v1/upload` 早就有了,客户端
一直没用——本次把它接进发送路径。

- `data/api/UploadApi.kt` / `data/api/dto/UploadDto.kt` / `data/repository/UploadRepository.kt`:
  upload 三件套。multipart 字段名 `file`(Go 只认这个名,且只把带 `filename` 的部件当文件,
  已对着在线后端实测:字段名不对回 `400 {"code":"bad_request","message":"file is required"}`);
  走 `apiCall`(这台端点的状态码不承载额外语义,`413`/`415` 的区分在 `error.code` 里)。
  仓库另拦两种「服务端帮不了」的情况:`url` 为空**或不是绝对 http(s) 地址**就判解析失败
  ——视觉层只把 `http(s)://` 当 URL,裸路径会被当成 base64,照发等于把这一轮扔进黑洞。
- `app/AppContainer.kt`:按 baseUrl 缓存接线,与 chatApi 同源(上传产物 URL 必须交给同一个后端)。
- `domain/chat/ChatImageDelivery.kt`:内联 vs 上传的**唯一判据**(纯函数)。
  阈值为重编码后的 **64 KiB(含等于内联)**:内联的代价是每次历史取数再多 4/3 × B,
  64 KiB 即 +85 KB/次(20 张这种图的会话每次冷启动/重连回放就多传 ≥1.7 MB),而上传只是
  多一次往返(局域网毫秒级)——从第二次取数起就稳赚;≤64 KiB 的缩略图省下的量与一次往返同量级,
  不值得多一个失败面。阈值必须严格小于本端 1 MiB 预算,否则上传分支是死代码(有测试钉住)。
- `ui/chat/ChatViewModel.kt`:图片发送路径分流(小图内联、大图上传换 URL 再发 REST)。
  **上传失败不回退内联**:回退会把「省历史重量」当场作废、还是在用户看不见时换了一条语义不同的路;
  这里按上一步骤给可重试文案,并把图与文本还给输入框(`uploadFailureMessage`,纯函数可单测)。
- `data/image/ChatImagePreparer.kt`:产物改为同时给出 `data:` URI 与**同一份**字节,
  上传因此不必把刚编出来的东西再 base64 解一遍。

阈值依据、以及「URL 形态到底受不受 5 MiB 上限约束」的核查结论写在 `ChatImageDelivery`
与 `UploadRepository` 的注释里(结论:URL 形态**不受**解码后 5 MiB 约束——网关
`checkChatImageSize` 量的是 `image` 字符串长度,URL 才几十字符;URL 到视觉层才被取回,
上限是 `vision.maxFetchedBody` = 50 MiB。所以走上传**不是**为了绕开上限,本端 1 MiB 预算离
两个 5 MiB 上限都还差两个数量级)。

测试:309 全绿(272 → +37):上传响应/错误体的真实形状(错误体是在线抓包原文)、
multipart 线级形状(部件名、filename、字节原样)、内联阈值与两条不变量、上传失败文案。
`assembleDebug` 通过。**未实测**(本端无模拟器、且不给在线后端留测试文件):
真机上的上传成功链路与 Coil 渲染远程 URL——已静态确认 `coil-network-okhttp` 带
`META-INF/services/coil3.util.FetcherServiceLoaderTarget`(默认 ImageLoader 会自动注册网络
fetcher)、`network_security_config` 放行明文(同源 http URL 可取),但没跑过真机。
2026-10-04 17:40:12 +08:00
AskaEth b7806951eb feat(ws): interrupt 支持 scope,并按偏好选「追加队列 / 打断」(协同 A2)
契约 v0.56/A2(api-client §3.1):interrupt{sessionId, scope?},取值域 {"", "all", "current"}——省略 / 空串 / "all" 同义(取消在跑回合 + 清空待处理队列,逐字不变的旧行为),"current" = 只取消在跑回合、保留待处理队列(队列消息按编排器续跑规则继续执行,一条不丢);其余取值服务端定向回 error{bad_request}、不打断任何会话。

- WsOutbound.Interrupt 增可选 scope:null = 整键省略(服务端视同 all),默认帧因此与今天逐字节相同;传输层不校验取值、不补默认、不改写别名(开放取值域由服务端裁决,客户端替它纠正只会换来一次没解释的打断)。
- ChatSocket.sendInterrupt(sessionId, scope = null):把 scope 交给调用方从偏好取值;今天仍无调用方——「打断」触发点在聊天区(属另一轮),本 API 先把传输面备好,KDoc 如实标注。
- 新偏好 InterruptMode(DataStore cyrene_app/interrupt_mode):AppendQueue(默认,省略 scope = all)与 Interrupt(scope:"current");未知/缺省值一律解回默认档,绝不悄悄换成更激进的一档。偏好经 AppPrefs → LocalAppPrefs 下发,聊天区按既有方式读取。
- 设置页新增「她忙时发消息」分组:沿用主题那套 Miuix PreferenceCard + BasicComponent + ✓;两行文案说清唯一差别——忙时那条排队消息还在不在(看不懂效果的偏好比没有更糟)。
- 测试新增 11 条(WsOutboundInterruptTest 6 / InterruptModeTest 5):钉死默认档与旧帧逐字节相同、current 原样透传、"all"/""/未知取值都原样发出、两档不在线上打成同一种形状。工作树 assembleDebug 绿,testDebugUnitTest 283 条 0 失败(基线 272 + 新增 11)。
2026-10-04 17:36:22 +08:00
AskaEth feeb49ebeb fix(sessions): 刷新后校验当前会话是否还在,别一直指着已删会话
会话在**别处**被删掉(Web 面板 / 别的设备 / 直接调 API)时客户端无法感知:`refresh()`
成功后会整体替换 `sessions`,却**不校验 `activeSessionId` 是否还在新列表里**,
于是当前会话会一直指着一个已删会话——聊天页对着空会话,而且这个陈旧 id 会被
`prefs.setActive` 持久化下去,重启后依然是它。

本端自己的删除已经处理过(`deleteSession` 会主动切回主会话),漏的正是「别处删除」这一路。

- ui/sessions/SessionsViewModel.kt:新增纯函数 `resolveActiveSession(activeId, fetchedIds)`
  —— 不在列表里就回落主会话;**列表为空时不回落**(那是异常响应,不该拿它改用户的选择)。
  `refresh()` 成功分支调用它,值变化时同步持久化

测试:272 全绿(269 → +3):已删会话回落主会话 / 仍在列表则保留 / 空列表不覆盖用户选择。

实测:会话在别处删除后 force-stop 重开(重走 init 与刷新),
`cyrene_sessions.preferences_pb` 里 **`web:<sub>` 残留彻底消失**、`active_session_id` 回落 `web`;
界面回到主会话对话,不再出现已删会话的幽灵行。
2026-10-04 17:10:26 +08:00
AskaEth e642b0c18c feat(chat): 加 REST 发送路径 —— 长文与图片(含 clientMsgId 幂等与归一)
客户端此前**只有 WS** 发送,而 WS 帧硬上限 4096 字节、**超限是直接断连**(不是报个错),
所以长文发不出去、图完全发不了。v0.56 给 REST 流式补齐了明确响应面与 clientMsgId,
本次照它接入。

- data/api:ChatApi + ChatDto(窄请求体,不回环整条会话)+ ChatRepository
  —— **刻意不走 `apiCall`**:它把 4xx/5xx 一律折进 `Result.failure` 并丢掉状态码,
  那样 `202`(已排队)与 `409`(队满)的差别会在那一刻消失;改为映射成 ChatAck 密封结果
  (Accepted / Queued / QueueFull / Duplicate / Unavailable / Rejected)
- domain/chat/ChatSendRouting:纯函数路由 —— **UTF-8 字节数 ≤ 3584 且无图 → WS,否则 REST**。
  阈值按**字节**不按字符(中文 1 字 3 字节);预留 512 字节覆盖帧壳。短文本仍走 WS 是刻意的:
  那条路**绕开有损总线**,而超 WS 上限的代价是**断连**而非报错,两边风险不对称
- domain/chat/ChatImageBudget + data/image/ChatImagePreparer:解码 → 等比缩放 → 重编码为
  JPEG/PNG data URI;超预算自动降尺寸重来;**失败返回 null,绝不退回原图 base64**
- clientMsgId:每次发送 `"local-" + UUID`(**随机不自增**——服务端判重窗口是**整个会话历史
  且无过期清理**,进程重启后复用旧值会让那条消息永远发不出去);REST 与 WS 两路都带
- 归一:带 clientMsgId 的消息**只按 id 匹配**(文本不同也命中;匹配不到就什么都不吃,
  **绝不回落到文本**——那会吃掉一条无关气泡);不带键的老行才回落「第一条同文本」
- ack 分支:200 已受理(等 message_persisted)/ 202 已排队(出「已排队 N 条」)/
  **409 既未受理也未落库 → 回滚气泡并把文本与图片还回输入框**(不能当「已排队」,
  那会让气泡永远挂着「发送中…」)/ duplicate 不是错误(收气泡;本地没有该 messageId 就补拉一页)/
  500、503 回滚 / 400 image_too_large 换短文案
- 接下行帧 `chat_duplicate`;图片一律走 REST(`WsOutbound.Chat.image` 仍无生产者)

测试:269 全绿(218 → +51)。含真实抓包的四段响应(200 / 202 / duplicate / 400 image_too_large,
且服务端文案里的 6990572 与本端 SERVER_MAX_CHARS 做成对断言)、路由阈值边界
(1194 汉字 = 3582 字节走 WS、1195 = 3585 走 REST,并直接断言阈值处真实
`WsOutbound.Chat(...)` 序列化后 ≤ 4096 字节)、归一规则、六种 ack 映射。

真机(真后端 127.0.0.1:10942):**图片全链路通过** —— 选图 → 重编码(源 7.8 KB PNG →
1 KB JPEG,magic ffd8ffe0)→ `POST /api/v1/chat`(1558 字节体)→
`200 {"data":{"stream":true}}` → 气泡由「发送中…」归一为「来自 安卓」→ 昔涟回复**准确描述了那张图**。
另 WS 短文本路径未回归(无 POST,且 clientMsgId 被 v0.56 原样回填)。

未验证:`409 chat_busy` 与 `500` 非真实抓包(要填满主人后端的待处理队列、等于真跑 5 个回合,
判断不值得;形状与文案逐字取自后端源码,测试注释里已写明);UI 上的 202/409 未端到端跑过;
降尺寸「减半重来」那步未在设备上跑过(测试图 64×64 只走到重编码)。
2026-10-04 16:57:22 +08:00
AskaEth 4c14028c70 fix(tools): 接上 tool_execution 三事件,并改用 id 关联工具卡
客户端此前**只映射了 `tool_execution.update`**——恰好是后端**没有生产者**的那一帧
(后端回执亦确认:`.start`/`.end` 有真实生产者,`.update` 运行期可能永不来)。
真正会发出的 `.start` / `.end` 落进 `else -> null` 被**静默丢弃**,所以
**工具执行状态从未到过界面**:工具卡能显示调用与参数,却永远不显示「执行中 / 完成 / 失败」。

后端 v0.50 已把 `tool_execution` 拆成三帧、载荷为 `{tool, phase, id?}`——刻意**不带
`index` / `streamId`**(WS bridge 注释明写「tool_execution.* 不属于某一次模型流」),
关联键是 **`id`**(与同轮 `tool_call.*` 同一个 provider 调用 id)。所以旧实现按 `index`
归属本身就是错的。

- data/ws/WsEvent.kt:三个事件类型承载 `tool` / `phase` / 可空 `id` / `delta`(仅 update);
  新增私有 `ToolExecutionData`(**无 `index`**);三个帧名都进 `dispatchStreaming`
- ui/chat/ChatViewModel.kt:`ToolPhase { Running, Done, Error }` + `toolPhaseOf`;
  `ToolCallState` 扩 `tool` 与可空 `phase`;新增两个纯函数
  `toolExecutionTargetIndex` / `applyToolExecution`;三帧经既有 `isForSession` 会话闸
- ui/chat/StreamingBlocks.kt:工具卡标题与配色由 `phase` 驱动(`执行 <tool>…` /
  `工具调用 · <tool>` / `工具调用失败 · <tool>`,失败用 error 色)

关联策略是「**不确定就不挂**」,绝不默认 0:`id` 非空 → 精确匹配,找不到返回 -1
(宁可不挂也不挂错);`id` 缺失 → 仅当「恰好一张卡 id 为空」或「整个列表只有一张卡」
才挂,否则 -1。`applyToolExecution` 不建卡、不动别的卡;`tool` 空白保留已有名字、
`phase` 无法识别保留已有阶段、`delta` 为空不改输出。`.update` 那条路也实现并测了。

测试:218 全绿(194 → +24)。新增 10 条三帧解码(按真实 `toolExecutionData` 形状:
id 有/无、phase running/done/error、update 带/不带 delta、多余 `streamId` 被忽略、
旧单帧形状可容忍、会话闸)+ 14 条关联规则与 phase/name/delta 应用。

实机:只读 WS 探针抓到真实帧
`tool_execution.start {"id":"call_00_o9Q…","phase":"running","tool":"calculator"}` 与
`.end {…,"phase":"done","tool":"calculator"}`,`tool_call.*` 共享同一 id、执行帧确实
没有 `index`/`streamId`;屏幕录制抓到渲染出的「工具调用 · calculator」——工具名只可能
来自 `tool_execution.*`,故端到端成立。全程只见 `.start`/`.end`,印证 `.update` 无生产者。

未覆盖:`phase:"running"`(内置工具太快)与 `phase:"error"`(无法诱发)的瞬时态,
仅单测覆盖。
2026-10-04 16:28:22 +08:00
AskaEth d12a920096 fix(ws): 流式帧按 sessionId 收敛,修跨会话串台并退役 A1/D14
服务端从 v0.36 起就在结构化流帧(message.* / thinking.* / tool_call.* /
tool_execution.*)上带了 sessionId + turnId,而本端 WsEvent.parse 把这两个字段
**丢掉了**——所以「同一时刻只有一个会话在流式」这条长期约束(A1/D14)其实是
客户端自己造成的,不是后端限制。

而广播是发给**每条已认证连接**、不按会话订阅;每个 ChatScreen 的 VM 又各建一条
ChatSocket,切会话后旧 socket 与旧 VM 都还活着——于是旧 VM 会拿新会话的 delta
跑自己的流式状态机,思维链/工具卡/正文全线串台。

- data/ws/WsEvent.kt:基类开 open val sessionId(子类按帧壳覆盖),透传到 10 个
  结构化事件类型;新增纯函数 isForSession()——**只排除「明确属于别的会话」的帧**,
  空值视为「无法判定」而放行(与 historyForSession 对空值的处理一致)
- ui/chat/ChatViewModel.kt:onEvent 顶部加一道会话闸;history 帧**刻意豁免**,
  它的归属由帧内每条消息的 sessionId 判定(比帧壳更细,即 2a49c25 的语义)
- data/ws/ChatSocket.kt:改掉那段描述「同一时刻只允许一个会话在流式」的过时 KDoc

顺带核实:**没有任何代码实现过 D14**(切会话时不拦截,sendInterrupt 更是零调用点),
它只是文档假设 + 解析器丢字段的副作用 → 因此 D14 退役、A1 闭环。

测试:194 全绿(178 → +16)。新增 WsEventSessionScopeTest:12 种帧类型的解码
(按真实帧壳形状)、向后兼容(缺字段 / 显式 null)、实抓的 history 帧、isForSession 规则。

未验证项:跨会话修复只由单测 + 后端自带的 ws_hub_stream_scope_test 覆盖,
没有实跑「两个会话同时流式」。
2026-10-04 14:19:24 +08:00
AskaEth 2a49c25e1b fix(chat): WS history 帧按会话过滤,修次级会话串进主会话消息
WS 首帧 history 是**主会话**最近 50 条的回放,但连接是全局的、帧会被投给任意
ChatViewModel;onEvent 此前把它**原样**并进当前消息列表,导致进次级会话会看到
主会话的消息混在一起——是数据串台,不是显示问题。

- data/repository/PagedMessages.kt:新增纯函数 historyForSession(messages, sessionId),
  按**消息自身的 sessionId** 过滤;sessionId 为空的帧视为「无法判定」放行,
  不因缺一个字段就整段丢掉历史(保持既有行为)
- ui/chat/ChatViewModel.kt:history 分支先收敛到本会话再合并

为什么按 sessionId 而不是「只有主会话才收 history」:前者更耐将来——
服务端哪天开始给次级会话回放,这里无需改动。

测试:178 全绿(175 → +3),新增三条:主会话回放不得泄漏进次级会话、
缺 sessionId 的帧放行、过滤不修改入参。
2026-10-04 14:05:38 +08:00
AskaEth c09a21136c feat(sessions): 会话列表用服务端标题 + 置顶 + 最后消息预览(A4/D5/B3)
列表此前显示的是本地硬生成的「会话 N」,因为后端 title 恒空。后端后来做了
「首条用户消息自动命名」+ PATCH 改名/置顶 + lastMessage 预览,本轮接入,
并把本地那套序号机器整块删掉。

- data:SessionSummaryDto 补 pinned / lastMessage(均带默认值,老快照仍能解);
  新增 UpdateSessionRequest / Response 与 @PATCH api/v1/sessions/{id}
- core/prefs:**整块退役**本地序号机器(order / KEY_ORDER / OrderSerializer /
  decodeOrder / ensureOrder / 「会话 ?」)——有了服务端标题,本地序号永远不会一致,
  且 id 没有序号时会渲染出字面的「会话 ?」
- 标题规则:服务端标题非空白即原样显示,否则兜底(主会话「与昔涟」/ 次级「新会话」)。
  主会话刻意保留「与昔涟」而不采用服务端自己的「主会话」
- 顺序**完全信任服务端**(pinned DESC, main DESC, created_at ASC),客户端不重排;
  因此置顶的次级会排在主会话之前——顺带修正 SessionDto 里「主会话恒首位」的过时注释
- 置顶:主会话与次级都可置顶(契约允许);先就地翻转拿即时反馈 → PATCH →
  成功后静默重拉(服务端才是顺序权威,快照顺带修正)→ 失败回滚并报错
- 改名:长按行打开对话框(预填当前标题 + n/80 计数),不做乐观更新(服务端会 trim 后
  回写,等静默重拉取权威值);**不提供「恢复自动命名」**——服务端无法表达(空白即 400)
- **刷新触发(前置条件,不是锦上添花)**:列表此前只在 VM 初始化 / 新建 / 删除时拉取,
  所以自动标题与预览要重启 App 才可见。现改为**打开抽屉时静默刷新**,
  且不闪「同步中…」横幅
- 顺带修同文件里两个既有缺陷:① **快照的指纹闸门其实没实现**(fingerprint 写了却从不
  比较、clear() 是死代码)→ 现按 baseUrl+username 指纹过滤,并把 clear() 接到登出
  (否则换账号后第一帧会闪上个账号的会话列表);已实测确认指纹与写入时逐字节一致,
  不会误丢老快照。② 预览剥标签时旁白与正文会粘连(看截图发现的排版瑕疵)→
  标签改替成空格再折叠空白

测试:175 全绿(153 → +22)。含真实抓取响应体的 DTO 契约测试、标题兜底与预览规则的
纯函数测试、指纹闸门测试。
2026-10-04 13:55:52 +08:00
AskaEth f5576540b4 feat(auth): 接入 /auth/refresh,不再把登录口令落盘
客户端此前把明文口令存进 EncryptedSharedPreferences,用于过期后的静默重登
(当时后端没有 refresh 端点)。后端现在有 /auth/refresh + /auth/logout 了,
本轮改为存 refresh token,口令在首次成功认证后即被抹掉。

关键约束:服务端的 refresh 是**单次使用 + 原子旋转 + 无宽限期**——同一个 token
第二次使用立刻 invalid_grant;而本端并发 401 是常态(管理概览页两个请求并发、
聊天页 REST 与 WS 同时起步)。因此:

- core/auth/TokenRefresher.kt:单飞刷新 + 代际守卫(等锁期间别人已换好令牌就直接
  复用,绝不用同一个 refresh token 打第二次)+ 终态记忆(被 invalid_grant 拒过的
  token 记下来,一次失效里只打一次注定失败的请求;Transient 不记忆以保留重试)
- 用应用层 Interceptor 而非 OkHttp Authenticator:后者对**任何** 401 都会被调用
  (含不带令牌的登录请求),且由 RetryAndFollowUp 内部循环驱动(上限 20 次跟进),
  持续 401 的令牌会打出刷新风暴。应用层拦截器只在「带了令牌仍 401」时介入,且严格
  重试一次
- 可恢复的 401 不再登出:Transient(网络/5xx)原样交回 401 并保留会话;只有
  Unrecoverable,或「换上刚签发的令牌仍 401」,才判终态
- WS 握手 401:先尝试一次续期再重连,终态才登出
- 登出补调 POST /auth/logout(此前从不调用,服务端 grant 会白活 30 天)
- 主动 skew(60s)刷新 + 兜底重试;刷新请求走不带令牌的客户端,不会递归回本路径

迁移对用户无感、绝不强制重登:启动时若「无 refresh token 但有旧口令」,用旧路径
静默登录一次换到 refresh token,随即 commit() 抹掉口令;离线失败就下次启动再试,
且不清会话。另外鉴权过期时也走一次续期——否则去掉口令会把会话砍到 24h,比改动前
更差。

HttpLoggingInterceptor 保持 BASIC 不动:/auth/refresh 与 /auth/logout 把 refresh
token 放在**请求体**里,再高一级就会把凭据打进 logcat。

测试:153 全绿(115 → +38)。含真实抓取响应体的 DTO 契约测试、TokenRefresher 的
并发/代际/终态单测、bootstrap 迁移单测,以及走真实 OkHttp + Retrofit + 回环
HttpServer 的端到端测试(证明只重试一次)。未新增任何依赖。

未验证项:主动/被动续期路径本身没能真机触发(唯一输入是加密存储里的 expires_at,
shell 写不进去;而 tokenTTL 是 24h 常量),仅由回环端到端测试覆盖。
2026-10-04 13:34:53 +08:00
AskaEth 09a3222b7b feat(enums): 接入 /meta/enums 权威枚举表,删掉客户端硬编码映射
客户端此前自带 platform / mood / mode 三张硬编码标签表,会与服务端漂移——
实测已经漂了:客户端把 `web` 显示成「Web」,权威表里是「网页」。

- data:MetaApi(GET /api/v1/meta/enums)+ MetaDto + MetaRepository,
  经 ApiClientFactory 创建以复用既有 token / 401 处理
- domain/enums/EnumCatalog.kt:标签查找器,**不含任何硬编码兜底表**;
  不在表内的取值一律原样返回(api-client §1.1 的未知值回落红线);
  platformLabelOrNull 供 deviceId 前缀这类「认识/不认识要走不同回落」的调用方用;
  空 id 或空 label 一律不入表(否则角标会显示空白,或查空串凭空借到别人的标签)
- core/prefs/EnumPreferences.kt:last-good 快照,baseUrl+username 指纹闸门,
  换服务器/账号即作废,登出清空
- EnumCatalogViewModel + LocalEnumCatalog:在 CyreneRoot 注入(与 LocalAppPrefs 同套路),
  聊天角标与管理页共读同一份
- 删除 platformLabels / personaModeLabels / moodLabels;MessageSource 与 AdminFormat
  改为查表,并保留各自原有的原样回落

为什么不留硬编码兜底表:没有登录就看不到任何消息,而权威表在登录前后即会拉取,
所以 last-good 快照几乎总是存在;留着硬编码表等于把「漂移」重新引进来,而且是无声的——
旧标签和新鲜标签长得一模一样。

不转换的:trace kind/status、memory scope、thinking.mode、WS session_busy 的 mode 集合——
它们都不在 /meta/enums 内,且原本就已按原样回落。

测试:115 全绿(88 → +27:MetaDtoRealPayload 8 / EnumCatalog 7 / EnumPrefs 3 /
MessageSource 8,AdminFormatTest 改写并 +1),其中 /meta/enums 与登录响应一样
用真实抓取的响应体做夹具。
2026-10-04 13:19:34 +08:00
AskaEth 9f054aad28 fix(admin): 后台思考 conclusion 的 <p>/<action> 标记被原样显示
现象:后台思考页把服务端 conclusion 里的 Web 端标记当纯文本打印,真机截图里
能看到 `<p></p><p></p><p>亲爱的…</p>` 和 `<action>轻轻戳了戳你</action>` 这样的
原始标记,部分记录开头还有一串空 `<p></p>`。

为什么:backthink 的 conclusion 与聊天消息是同一套标记,而 AdminBackthinkScreen
只是把它拼进 body 字符串,从未解析。

改法:复用项目已有的 domain/parse/ChatContentParser.parse()(不新写正则),
在 domain/admin/AdminFormat.kt 加纯函数 conclusionParagraphs()——按
head → 正文 → tail 产出段落、丢弃无有效片段的空段落(这才是消掉空 <p></p> 的关键);
UI 侧新增 AdminRichTextCard,Text 正常渲染,Action/Narration 走聊天页同一套
灰斜体旁白样式(对齐 MessageBubble.AnnotationLine 的既有约定)。
AdminTextCard 行为不变(表头抽成共享的 AdminCardHeader),已回访记忆页确认无回归。

测试:新增 7 例(AdminFormatTest 13→20,用真实抓取的 conclusion 做夹具,并断言
渲染模型里不残留 <p>/</p>/<action>/< 或 > 任何一项),全套 88 例全绿。
2026-10-04 13:02:09 +08:00
AskaEth acff289887 test(admin): 用真实后端响应锁定管理端 DTO 契约
原先 AdminDtoDecodingTest 用的是手写的合成 JSON,等于拿「我以为的形状」自证,
证明不了真实契约。新增 AdminDtoRealPayloadTest,把 5 个管理端点的真实响应体
(登录后从本机 10942 实抓)过一遍生产用的 Json 实例。

顺带把真实数据里几个容易翻车的形状钉死:
- trace.durationNs 必须是 Long(真实 ~1.17e9 ns,超过 Int 上限就炸)——
  真实值恰好塞得进 Int,所以额外用一例 3e9 的边界值才真正证明它是 Long
- 根 span 没有 parentId 键 → null
- 未配置人格的 thinking 键完全不出现 → null(不得变成全零对象)
- persona 的 mode / memoryNamespace 是空串,而非缺键
- backthink.id 是 JSON 字符串("487"),at 是数字(Unix 毫秒)
- props / events 同一个 map 里混着数字与字符串

为什么:C-2 的 DTO 此前只经「编译通过」这一层,从未被真实响应验证过。
真实响应全部一次解出,无需改动生产代码。

测试:新增 7 例(AdminDtoRealPayloadTest),全套 81 例全绿。
2026-10-04 13:02:08 +08:00
AskaEth a7b3452e6d feat(admin): 新增 C-2 管理员只读监控(概览/人格/记忆/后台思考)
侧边栏「管理员监控」进入的独立管理区 + 独立 Scaffold,四页全部只读,
不调用任何 /admin/* 写端点。

- data:AdminApi(5 条只读 GET)+ AdminDto + AdminRepository,
  经 ApiClientFactory 创建以复用既有 token / 401 处理
- domain:AdminAccess.kt 为权限真源(纯函数,便于单测);
  AdminFormat.kt 把 RFC3339 与 Unix 毫秒两种时间形状分开解析
- ui:AdminHomeScreen 做成可加页的壳(加页 = AdminPage 加一条枚举),
  四个子页 + 统一失败态;列表项一律 PreferenceCard + BasicComponent
- RBAC:后端契约要求客户端自行降级(api-client §2.0),按矩阵分档——
  viewer 只能进概览/人格;入口禁用 + ViewModel 短路(不发请求)+
  服务端 403 也渲染「无权限」态,三层 fail-closed;
  任何失败都不渲染成「暂无数据」

为什么:M0–M5 + 设置页 C-1 已完成,C-2 是路线上的下一项;后端 5 个读端点
均已实现(非 501),且手机端后续要走「全功能管理」(后端工作小结 主线②,
凭据前置已完成),故管理区做成可扩展的壳而非只读死胡同。

测试:74 个单测全绿,其中新增 29 个
(AdminAccessTest 9 / AdminFormatTest 13 / AdminDtoDecodingTest 7)
2026-10-03 21:31:09 +08:00
AskaEth 9bc45dc315 fix(ui): PreferenceCard 补 padding import 2026-09-08 23:17:06 +08:00
AskaEth 664fb2d9ed style(ui): PreferenceCard 补左右外边距 12dp 2026-09-08 23:16:23 +08:00
AskaEth cf291b58ab style(ui): 列表项加卡片容器;登出移到设置页底部;按钮改矢量图标
新增 PreferenceCard(BasicComponent 本身无背景/圆角);抽屉去掉登出、新建与删除改图标按钮;设置页返回改 ChevronBackward、底部新增登出图标。
2026-09-08 23:13:00 +08:00
AskaEth df2a26701c style(ui): 列表项改用 Miuix BasicComponent(MD3 圆角卡片)
抽屉的会话行/入口行、设置页的主题选项统一换成 BasicComponent,间距 8dp,选中态用 ✓ 标记。
2026-09-08 22:55:29 +08:00
AskaEth 00856eeaf7 fix(ui): 浅色模式系统栏图标改深色;返回键逐层回退
CyreneRoot 用 WindowInsetsControllerCompat 跟随主题切换系统栏明暗;ChatHomeScreen 为设置页与抽屉各加 BackHandler,避免直接回桌面。
2026-09-08 22:45:20 +08:00
AskaEth 61214a18fa feat(settings): C 起步——App 设置页(主题/旁白/思维链默认展开/关于)
新增 AppSettings(DataStore)与 LocalAppPrefs;CyreneRoot 按偏好决定 Miuix 主题模式并下发偏好;设置页从侧边栏进入,返回回聊天。旁白开关与思维链默认展开已接入渲染层。
2026-09-08 22:41:37 +08:00
AskaEth 91117986e4 feat(chat): 改为文本选择模式,全选仅作用于当前气泡
用 SelectionContainer 包住气泡/旁白/思维链/工具卡的正文,长按可拖选;移除长按整段复制的实现与其工具文件。
2026-09-08 22:38:28 +08:00
AskaEth 33770d87a6 feat(chat): 回车发送 + 长按气泡复制
输入框 imeAction=Send(回车即发送);长按气泡体复制原文到剪贴板并 Toast 提示。
2026-09-08 22:08:52 +08:00
AskaEth 0ea41f5815 fix(chat): 补 LaunchedEffect import 2026-09-08 21:57:43 +08:00
AskaEth 5a5b02f6fb feat(chat): 思维链保留 10 分钟并自动折叠;输入栏下方留白承载忙碌提示
流式结束后把思维链挂到落库消息上,10 分钟后自动清除;ThinkingCard 在 active 变 false 时自动折叠,历史思维链默认折叠。输入栏底部预留 22dp 避开屏幕圆角,顶栏副标题只留连接状态,忙碌提示移到输入栏下方(带左缩进)。
2026-09-08 21:56:55 +08:00
AskaEth 5975827aea fix(chat): Column 同时传 content 与尾随 lambda 导致编译失败 2026-09-08 21:50:54 +08:00
AskaEth 7faa604fd0 feat(chat): M4 思维链卡片 + 工具卡 + 图片
thinking.*/tool_call.*/tool_execution.update 累积进流式状态并渲染为可折叠卡片(思维链默认展开);历史消息的 thinking(来自 REST /messages)同样展示;image 用 Coil 3 加载。
2026-09-08 21:48:40 +08:00
AskaEth 5ac0ea3c3b fix(chat): 输入法弹出时输入框被顶到顶部
窗口默认 adjustUnspecified 会先做 pan,叠加 imePadding 后高度被算两次;改为 adjustResize。
2026-09-08 21:42:36 +08:00
AskaEth 5d71c6a2ab fix(ui): 移除聊天页顶栏登出按钮;抽屉内容避开系统栏
登出已收进侧边栏;抽屉面板保留全高背景,内容用 systemBars inset 避开状态栏/导航栏。
2026-09-08 21:34:10 +08:00
AskaEth e72621db94 feat(sessions): M5 侧边栏抽屉 + 会话列表(切换/新建/删除)
自研 MiuixDrawer(draggable+Animatable,关闭态不拦截触摸);DataStore 持久化会话快照/序号映射/当前会话;SessionsViewModel 先快照后刷新;次级会话删除二次确认,主会话禁删。
2026-09-08 21:29:24 +08:00
AskaEth e04995a84d fix(chat): 新消息自动跟随底部(微信/QQ 式)
reverseLayout 会锚定原可见项,导致新消息插到视口外。改为:滚动结束时记录是否贴底(容差 64px),最新一项变化且处于跟随状态时 animateScrollToItem(0);用户上滑查看历史时不再强制滚动。
2026-09-08 21:16:49 +08:00
AskaEth 67ed0479e4 feat(chat): M3 发送与流式渲染
输入框 + 发送(WS chat 上行,deviceId=voiceassist:cyrene:v1);乐观消息先上屏,message_persisted 到达后按文本归一;message.start/delta/end + stream_end 走流式增量解析,末尾带光标;发送失败回滚文本到输入框。
2026-09-08 21:09:37 +08:00
AskaEth a8a422c4e1 fix(chat): 来源角标改挂到消息组末尾
角标应紧随它标注的那条消息(主人右下、昔涟左下),放在上方会显得在标注下一条。
2026-09-08 21:06:42 +08:00
AskaEth 6323f89b5b fix(chat): 气泡间距 6dp;来源文案按角色区分「来自 / 发送至」
主人发的显示「来自 X」,昔涟发的显示「发送至 X」。
2026-09-08 21:04:32 +08:00
AskaEth 01b5f980a5 fix(chat): 来源角标移到消息组顶部;IM deviceId 显示友好平台名
角标挂底部会视觉贴到下一条消息上;微信等 IM 的 deviceId 形如 <平台名>:<配置id>:<uid>,只取平台名显示「来自微信」。
2026-09-08 21:03:33 +08:00
AskaEth 63fe9a12a6 feat(chat): 接入 <p> 多气泡与 <action>/<narration> 旁白渲染
chat 模式消息按 <p> 拆成多个气泡;<action>/<narration> 解析为灰斜体小字依附气泡上/下;来源角标只在最后一条气泡显示一次。非 chat 模式原样渲染。
2026-09-08 21:01:48 +08:00
AskaEth 171f6ff59a fix(net): 放行明文 HTTP,修复自部署地址报「网络不可用」
Android 9+ 默认禁止 cleartext,OkHttp 抛 UnknownServiceException 被映射为无网。新增 network_security_config.xml(放行 cleartext + 信任用户 CA),并给该异常单独的提示文案。
2026-09-08 20:56:06 +08:00
AskaEth 0587f5a45f chore: devdocs 移出版本控制
设计/协作文档只保留在本地,不再随仓库分发。
2026-09-08 20:50:40 +08:00
AskaEth 253913ad82 chore: 机器特定的构建配置移出仓库
aapt2FromMavenOverride(ARM64 专用绝对路径)与 sdkDownload 移到本机 ~/.gradle/gradle.properties,仓库配置保持跨平台可用。
2026-09-08 20:49:00 +08:00
AskaEth 84de51c7b7 feat: 初始化 Android 客户端(M0 骨架 + M1 登录 + M2 基础模块)
M0:Gradle 9.7.1 / AGP 9.2.1 / Kotlin 2.4 / Miuix 0.9.3,minSdk 33,compileSdk 37。
M1:登录页(服务器地址可配、服务端/本地错误分流、EncryptedSharedPreferences、自动登录与静默重登、401 登出)。
M2 基础模块:消息内容解析器(<p>/<action>/<narration> + 流式增量)、WebSocket 客户端、REST 数据层与分页去重。
45 个单元测试全绿,真机验证通过(登录页 + 启动图标)。
devdocs/ 收录设计文档、前后端协同事项与细化方案。
2026-09-08 20:40:58 +08:00
264 changed files with 48724 additions and 105 deletions

No files matched your search

+8 -29
View File
@@ -1,35 +1,14 @@
# ---> Android
# Gradle files
*.iml
.gradle/
build/
# Local configuration file (sdk path, etc)
.kotlin/
# 本机开发凭据(主人授权落地的测试用 key 等)——只在本机,绝不进仓库
dev-secrets.local.md
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
.idea/
.DS_Store
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof
# 主人从手机丢进仓库根当素材用的图(例如换图标用的原图)——只用不存,绝不进仓库
/IMG_*.png
-73
View File
@@ -1,73 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2026 AskaEth
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-3
View File
@@ -1,3 +0,0 @@
# Cyrene-For-Android
昔涟在安卓设备上的载体。
+78
View File
@@ -0,0 +1,78 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "top.yeij.cyrene"
compileSdk = 37
buildToolsVersion = "37.0.0"
defaultConfig {
applicationId = "top.yeij.cyrene"
minSdk = 33
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.foundation)
implementation(libs.miuix.ui)
implementation(libs.miuix.icons)
implementation(libs.miuix.preference)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.datastore.preferences)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit)
}
+10
View File
@@ -0,0 +1,10 @@
# kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers class **$$serializer {
*** descriptor;
}
-keepclasseswithmembers class ** {
kotlinx.serialization.KSerializer serializer(...);
}
+70
View File
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".app.CyreneApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Cyrene">
<!--
launchMode="singleTop":App 已经开着时从别的 App 分享过来,系统不再新建一个实例,
改成回调 onNewIntent(见 MainActivity)。不写它就会起第二个 Activity 实例、
两个实例各持一套 ViewModel / socket。
-->
<activity
android:name=".app.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!--
分享入口:只声明本端真能落地的两类 MIME(纯文本 / 图片)。
刻意不声明 */*——对话路径上今天没有「文件」这个表示(协同文档 02/F1 的三缺),
声明了就等于给出一个点进去必然失败的入口。标签沿用应用名(活动不设自己的
label / icon 时,分享菜单取的就是 Cyrene 的名字与启动图标)。
-->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- 多图分享只收图片:多条纯文本的 EXTRA_TEXT(SEND_MULTIPLE + text/plain)本端不接。 -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<!--
附件预览的「用其它应用打开 / 保存」出口:`cacheDir/documents/` 下的文件要交给
系统里别的 App 读,就必须是 content:// 而不是 file://——API 24 起把 file:// 交出去
会直接抛 FileUriExposedException(且 StrictMode 下同样致命)。
路径范围见 res/xml/file_paths.xml:只暴露 cacheDir/documents/ 这一层,
exported="false" + 按次授权(grantUriPermissions)保证这份缓存不对外常开。
-->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,247 @@
package top.yeij.cyrene.app
import android.content.Context
import top.yeij.cyrene.core.auth.AuthManager
import top.yeij.cyrene.core.auth.SessionStore
import top.yeij.cyrene.core.prefs.AppSettings
import top.yeij.cyrene.core.prefs.EnumPreferences
import top.yeij.cyrene.core.prefs.SessionPreferences
import top.yeij.cyrene.core.session.GoneSessions
import top.yeij.cyrene.data.api.AdminApi
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ChatApi
import top.yeij.cyrene.data.api.DocumentsApi
import top.yeij.cyrene.data.api.MetaApi
import top.yeij.cyrene.data.api.PersonaApi
import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.api.UploadApi
import top.yeij.cyrene.data.repository.AdminRepository
import top.yeij.cyrene.data.repository.ChatRepository
import top.yeij.cyrene.data.repository.DocumentRepository
import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.MetaRepository
import top.yeij.cyrene.data.repository.PersonaRepository
import top.yeij.cyrene.data.repository.SessionRepository
import top.yeij.cyrene.data.repository.UploadRepository
import top.yeij.cyrene.data.ws.ChatSocket
/** 手写 DI 容器(D:不引 Hilt)。 */
class AppContainer(context: Context) {
/** 应用上下文:给需要在 ViewModel 里读本地资源(图片解码等)的组件用。 */
val appContext: Context = context.applicationContext
val sessionStore: SessionStore = SessionStore(context.applicationContext)
val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext)
val enumPreferences: EnumPreferences = EnumPreferences(context.applicationContext)
val appSettings: AppSettings = AppSettings(context.applicationContext)
val authManager: AuthManager = AuthManager(sessionStore)
/**
* 本端已知「服务端没有这个会话」的那几个 id(**进程级**,与 [chatSocket] 同一层)。
*
* 跨两个 VM 的唯一共享面:写它的是 `SessionsViewModel`(本端唯一的删除入口),读它的是
* **每一个** `ChatViewModel`(它们各留一个常驻 VM,删会话不回收)。它拦下的是那一轮注定
* `404 session_not_found` 的补拉 —— 理由与「为什么不持久化」见 [GoneSessions] 的类注释。
*/
val goneSessions: GoneSessions = GoneSessions()
private var cachedBaseUrl: String? = null
private var cachedSessionsApi: SessionsApi? = null
/** 会话/消息 REST 客户端;baseUrl 变化时自动重建。 */
val sessionsApi: SessionsApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedSessionsApi
if (cached != null && cachedBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = SessionsApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedBaseUrl = baseUrl
cachedSessionsApi = created
return created
}
val sessionRepository: SessionRepository get() = SessionRepository(sessionsApi)
val messageRepository: MessageRepository get() = MessageRepository(sessionsApi)
private var cachedChatApi: ChatApi? = null
private var cachedChatBaseUrl: String? = null
/** 发送端点(`POST /api/v1/chat`);与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
val chatApi: ChatApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedChatApi
if (cached != null && cachedChatBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = ChatApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedChatBaseUrl = baseUrl
cachedChatApi = created
return created
}
val chatRepository: ChatRepository get() = ChatRepository(chatApi)
private var cachedUploadApi: UploadApi? = null
private var cachedUploadBaseUrl: String? = null
/**
* 图片上传端点(`POST /api/v1/upload`);与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理)——
* 上传的产物 URL 就是要交给 `POST /api/v1/chat` 的 `image` 字段的,两者必须在同一个后端上。
*/
val uploadApi: UploadApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedUploadApi
if (cached != null && cachedUploadBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = UploadApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedUploadBaseUrl = baseUrl
cachedUploadApi = created
return created
}
val uploadRepository: UploadRepository get() = UploadRepository(uploadApi)
private var cachedDocumentsApi: DocumentsApi? = null
private var cachedDocumentsBaseUrl: String? = null
/**
* 文档存储端点(`POST /api/v1/documents` 上传、`GET /api/v1/documents/{name}` 取字节);
* 与 [chatApi] **必须同源**(同一 baseUrl、同一 token/401 处理)——上传拿到的存储名就是要交给
* 同一个后端的 `POST /api/v1/chat` 的 `documents[].name`,两条链路不在同一台机器上就必然 404。
*
* Bearer 与 401 续期不用在这里做额外事:[ApiClientFactory.create] 按 **OkHttpClient**
* 挂 `AuthInterceptor`,文档读侧那点「与图片读侧刻意相反」的鉴权要求因此天然满足。
*/
val documentsApi: DocumentsApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedDocumentsApi
if (cached != null && cachedDocumentsBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = DocumentsApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedDocumentsBaseUrl = baseUrl
cachedDocumentsApi = created
return created
}
val documentRepository: DocumentRepository get() = DocumentRepository(documentsApi)
private var cachedAdminApi: AdminApi? = null
private var cachedAdminBaseUrl: String? = null
/** 管理只读监控客户端;与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
val adminApi: AdminApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedAdminApi
if (cached != null && cachedAdminBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = AdminApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedAdminBaseUrl = baseUrl
cachedAdminApi = created
return created
}
val adminRepository: AdminRepository get() = AdminRepository(adminApi)
private var cachedMetaApi: MetaApi? = null
private var cachedMetaBaseUrl: String? = null
/** 枚举权威表客户端;同样与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
val metaApi: MetaApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedMetaApi
if (cached != null && cachedMetaBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = MetaApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedMetaBaseUrl = baseUrl
cachedMetaApi = created
return created
}
val metaRepository: MetaRepository get() = MetaRepository(metaApi)
private var cachedPersonaApi: PersonaApi? = null
private var cachedPersonaBaseUrl: String? = null
/** 用户侧角色目录(`GET /api/v1/personas`);与 [metaApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
val personaApi: PersonaApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedPersonaApi
if (cached != null && cachedPersonaBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = PersonaApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedPersonaBaseUrl = baseUrl
cachedPersonaApi = created
return created
}
val personaRepository: PersonaRepository get() = PersonaRepository(personaApi)
/**
* 进程内**唯一**的实时通道连接(`GET /ws/chat`):所有需要收下行帧的 ViewModel 共用这一条。
*
* ## 为什么可以共享(不是「省点资源」,是**逐帧等价**)
* 服务端是**广播**:下行帧发给**每一条**已认证连接、**不按会话订阅**(见 [ChatSocket] 类注释
* 「会话归属」)。于是改前「N 条连接,每条都收到全部帧 ⇒ 每个 VM 都收到全部帧 ⇒ 各 VM 靠
* [top.yeij.cyrene.data.ws.WsEvent.isForSession] 收敛到自己的会话」与改后「一条连接收到全部帧
* ⇒ **每个 VM 仍然收到全部帧**」是**同一个语义**:帧集合逐帧相同,过滤与事件分支一个字都不用改。
* 这次变的只是「连接有几条」,不是「帧怎么走」。
*
* ## 为什么必须唯一
* 每个 `ChatSocket` 都自带一个 `OkHttpClient`(各自的连接池 / ping / 重连循环)。改前
* `ChatViewModel`(**每个会话一个 VM**)、[top.yeij.cyrene.ui.sessions.SessionsViewModel]、
* [top.yeij.cyrene.ui.admin.AdminViewModel] 各自建了一条(`ChatViewModel.start()`、
* 两个 VM 的 init)⇒ 访问 N 个会话 = N+2 条连接、N+2 个 `OkHttpClient`;而 VM 活在 Activity 级
* `ViewModelStore` 里,**切走 / 删会话都不回收**。现在这里就是唯一的持有者(`by lazy`,只此一份),
* 调用方只订阅 [ChatSocket.events] / [ChatSocket.state]。
*
* ## 开与关只有一个主人
* [top.yeij.cyrene.ui.sessions.SessionsViewModel] 的登录态订阅:`LoggedIn → connect()`、
* `LoggedOut → close()`(为什么是它,见那个类的 KDoc)。`connect()` 幂等,baseUrl / token 都是
* 每次重连现读 ⇒ 登出重登、换账号**不需要**重建本实例。**任何 ViewModel 都不许关它**——
* 细节与理由写在 [ChatSocket.close]。
*/
val chatSocket: ChatSocket by lazy {
ChatSocket(
baseUrlProvider = { authManager.currentBaseUrl },
tokenProvider = authManager::currentToken,
onAuthRecover = authManager::recoverSocketAuth,
onAuthFailed = authManager::onSessionExpired,
)
}
}
@@ -0,0 +1,32 @@
package top.yeij.cyrene.app
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.ui.chat.ChatHomeScreen
import top.yeij.cyrene.ui.common.LoadingScreen
import top.yeij.cyrene.ui.login.LoginScreen
@Composable
fun CyreneApp(container: AppContainer) {
val state by container.authManager.state.collectAsState()
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
container.authManager.bootstrap()
}
when (state) {
SessionState.Unknown -> LoadingScreen()
SessionState.LoggedOut -> LoginScreen(container)
is SessionState.LoggedIn -> ChatHomeScreen(
container = container,
// 登出要先把服务端的 refresh 授权吊销掉,所以是挂起的;UI 不因为它在主线程等待。
onLogout = { scope.launch { container.authManager.logout() } },
)
}
}
@@ -0,0 +1,14 @@
package top.yeij.cyrene.app
import android.app.Application
class CyreneApplication : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
}
@@ -0,0 +1,72 @@
package top.yeij.cyrene.app
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import top.yeij.cyrene.core.prefs.AppPrefs
import top.yeij.cyrene.core.prefs.LocalAppPrefs
import top.yeij.cyrene.core.prefs.ThemeMode
import top.yeij.cyrene.core.theme.CyreneTheme
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
/**
* 应用根:读 App 偏好 → 决定 Miuix 主题模式 → 通过 [LocalAppPrefs] / [LocalEnumCatalog] 下发给渲染层,
* 同时把系统栏图标明暗跟着主题走(否则浅色模式下白字白底看不见)。
*/
@Composable
fun CyreneRoot(container: AppContainer) {
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
val enumCatalogViewModel: EnumCatalogViewModel = viewModel(
factory = viewModelFactory { initializer { EnumCatalogViewModel(container) } },
)
val catalog by enumCatalogViewModel.catalog.collectAsState()
val darkTheme = when (prefs.themeMode) {
ThemeMode.System -> isSystemInDarkTheme()
ThemeMode.Light -> false
ThemeMode.Dark -> true
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = view.context.findActivity()?.window ?: return@SideEffect
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
}
}
CompositionLocalProvider(
LocalAppPrefs provides prefs,
LocalEnumCatalog provides catalog,
) {
CyreneTheme(mode = prefs.themeMode.toColorSchemeMode()) {
CyreneApp(container)
}
}
}
private fun ThemeMode.toColorSchemeMode(): ColorSchemeMode = when (this) {
ThemeMode.System -> ColorSchemeMode.System
ThemeMode.Light -> ColorSchemeMode.Light
ThemeMode.Dark -> ColorSchemeMode.Dark
}
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
@@ -0,0 +1,53 @@
package top.yeij.cyrene.app
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.domain.enums.EnumCatalog
/**
* 枚举权威表(api-client §1.1)的应用级持有者:先用本地快照渲染,再拉一次权威表。
*
* - 登录 / 静默重登后拉取;**拉取失败保持现状**(快照或原样 id),绝不因此清空角标;
* - 快照按 `baseUrl + username` 指纹认领:换服务器/账号即作废;
* - 登出时落盘快照与内存标签一并清掉(此时界面只剩登录页,不会闪空角标)。
*/
class EnumCatalogViewModel(private val container: AppContainer) : ViewModel() {
private val _catalog = MutableStateFlow(EnumCatalog.Empty)
val catalog: StateFlow<EnumCatalog> = _catalog.asStateFlow()
init {
viewModelScope.launch {
container.authManager.state.collect { state ->
if (state == SessionState.LoggedOut) {
container.enumPreferences.clear()
_catalog.value = EnumCatalog.Empty
return@collect
}
val fingerprint = fingerprint()
// 先用快照渲染(没有就原样显示 id),拉取不阻塞首帧
val snapshot = container.enumPreferences.data.first()
_catalog.value = EnumCatalog.of(snapshot.tableFor(fingerprint))
if (state is SessionState.LoggedIn) refresh(fingerprint)
}
}
}
private fun refresh(fingerprint: String) {
viewModelScope.launch {
container.metaRepository.enums().onSuccess { table ->
container.enumPreferences.save(table, fingerprint)
_catalog.value = EnumCatalog.of(table)
}
}
}
private fun fingerprint(): String =
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
}
@@ -0,0 +1,38 @@
package top.yeij.cyrene.app
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
// 冷启动的两步:先清掉上一进程遗留的分享缓存(它已经没有主了),再接这次带来的分享。
// 顺序不能反——反过来会把刚拷进来的图一起删掉。
ShareIntake.reset(this)
// 只在**真正的冷启动**接 intent:Activity 因旋转 / 深色切换而重建时,系统会把原来那个
// intent 再送一遍,照单全收就会把同一条分享发第二次。重建带 savedInstanceState,
// 冷启动不带——这就是判据。
if (savedInstanceState == null) {
intent?.let { ShareIntake.accept(this, it) }
}
val container = (application as CyreneApplication).container
setContent {
CyreneRoot(container)
}
}
/**
* 热启动(`launchMode="singleTop"`):App 已经开着时系统不再新建实例,只回调这里。
* 不接这一路的话,「从浏览器分享 → 回到已经开着的 Cyrene」会一点反应都没有。
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// 让 getIntent() 与新到的 intent 一致(系统不会自己更新它),否则后续读到的还是旧的那个。
setIntent(intent)
ShareIntake.accept(this, intent)
}
}
@@ -0,0 +1,346 @@
package top.yeij.cyrene.app
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.StateFlow
import top.yeij.cyrene.domain.chat.DocumentAttachments
import top.yeij.cyrene.domain.chat.DocumentDelivery
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.share.PendingShareHolder
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent
import top.yeij.cyrene.domain.share.SharedDocument
import java.io.File
import java.util.UUID
/**
* 「分享到 App」的接收入口(`ACTION_SEND` / `ACTION_SEND_MULTIPLE`)。
*
* ## 它为什么是一个进程级单例
* 分享进来时可能还没登录、ChatScreen 甚至还没被组合出来,而登录之后消费它的又是**另一个**
* Compose 实例。内容因此必须待在一个不属于任何 UI / ViewModel 的地方——见
* [PendingShareHolder]。不塞进 `AppContainer` 是因为那个文件同时被别的并行改动占用,
* 这里没有任何依赖需要它:本对象只需要一个 `Context`(读 URI、写 cache)。
*
* ## 图片为什么在收到的那一刻就落盘
* `content://` 的读授权只给到**接收它的那个 Activity / 任务**,登录(可能十几秒、还可能切页)
* 之后再去读,拿到的多半是 `SecurityException` / 打不开。所以字节在这里立刻拷进 `cacheDir`,
* 之后一律用本端自己的 `file://` 路径——**外部 Uri 只在这个方法里出现一次**。文件(非图片)同理。
*
* ## 分流:图片还是文件
* 逐条 URI 看 `ContentResolver.getType`(判定见 [top.yeij.cyrene.domain.share.classifySharedStream]):
* MIME 以 `image/` 开头走既有图片路,**其余一律走文档路**。此前一律当图片拷、当图片解码,
* 一份 `.md` 因此只有两种结局——「图片处理失败」或者被当成一张读不出来的图。
*
* ⚠️ **Manifest 刻意不声明全通配或文本通配 MIME**:后端**明令拒绝** `text/html` / SVG / XML /
* 压缩包 / 脚本,放宽声明会造出一堆「点进去必然失败」的入口。所以这里能接到的**文件**只有
* 系统按 `text/plain` 投过来那些(`.txt` / `.md` / `.log` 这类文件管理器确实会报 `text/plain`);
* 报成 `text/markdown` 之类的分享**根本不会**出现在 Cyrene 的分享菜单里。
*
* ⚠️ **provider 不报 MIME(`getType` 回 null)时按文档路走**,这是刻意的:本端不猜类型,
* 猜错等于替用户把一个合规文件变成一次失败;代价是「图片但 provider 没报 MIME」会落到文档路、
* 被服务端按 `415` 拒(有如实文案)。画廊 / 相机 / MediaStore 都会报图片那一类,风险面很小。
*
* ## 清理
* 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以
* 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
* 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。
* 目录因此以「一次进程生命周期内的分享份数」为界(图片 4 + 文件 5,另加 [stageTextAsDocument]
* 为超长正文落下的那一份 `.txt`),另有系统的 cache 回收兜底。
*/
object ShareIntake {
private const val DIR_NAME = "share-intake"
private val holder = PendingShareHolder<SharedContent>()
/** 待落地的分享内容;消费侧(ChatScreen)订阅它。 */
val pending: StateFlow<List<SharedContent>> = holder.pending
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** 进程启动时调用一次:把上一进程遗留的分享缓存清干净。 */
fun reset(context: Context) {
if (cleanedThisProcess) return
cleanedThisProcess = true
runCatching { shareDir(context).deleteRecursively() }
}
/**
* 「本进程已经清过一次」的标记。
*
* Activity 会因为旋转 / 分屏 / 深色切换而**重建**,重建就会再走一次 `onCreate`——
* 那时清缓存会把用户刚分享进来、还摆在输入框里的那张图删掉(预览变空图、发送也没有字节)。
* 缓存副本只对「产生它的进程」有意义,所以判据是**进程**而不是 Activity 实例。
*/
private var cleanedThisProcess = false
/**
* 接收一个分享 Intent(冷启动在 `onCreate`、热启动在 `onNewIntent`)。
*
* 提取是纯函数([ShareRules.extract]),拷字节是重活,所以在 IO 线程做:`content://` 的读
* 授权绑的是 Activity 而不是某一次调用栈,异步读一样有效。
*/
fun accept(context: Context, intent: Intent) {
val appContext = context.applicationContext
// EXTRA_STREAM 一次读原始值、按**形态**分派(规范形态 + 字符串兼容形态),见 [shareStreamExtrasOf]:
// 挨个 getParcelableExtra / getParcelableArrayListExtra 去问的话,遇到字符串形态只会拿到两行
// `ClassCastException` 加一个 `null`——那正是「分享进来什么都没发生」的来处。
val rawStream = intent.streamExtraValue()
val streamExtras = shareStreamExtrasOf(rawStream)
val extracted = ShareRules.extract(
action = intent.action,
text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT),
// 单值 / 列表两个字段都喂进去(本端不再按 action 猜该读哪个,见 [ShareRules.extract])。
stream = streamExtras.uriForms.firstOrNull(),
streams = streamExtras.uriForms,
streamStrings = streamExtras.stringForms,
// 「里面躺着值」这件事只有这里知道(纯函数看不到原始值):形态认不出的那种也算带着内容,
// 否则「分享点下去什么都没发生」会从那条缝里回来。
streamPresent = rawStream != null,
clipUris = intent.clipUris(),
// 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。
mimeOf = { uri ->
runCatching { context.contentResolver.getType(Uri.parse(uri)) }.getOrNull()
},
)
// 没什么值得往下走的就到此为止(判据是 [SharedContent.worthDelivering]:有可落地的内容,
// 或者有丢件 / 读不进来要对用户说清的事)。整条分享里什么都没有时静默是对的;而
// 「带着内容迹象却一条都没读进来」必须继续往下走——那条告知在下游 `shareNoticeFor` 里等它。
if (!extracted.worthDelivering) return
scope.launch {
val dir = shareDir(appContext).apply { mkdirs() }
val copied = extracted.images.mapIndexedNotNull { index, uri ->
copyToCache(appContext, dir, uri, index)
}
val documents = extracted.documents.mapIndexedNotNull { index, document ->
copyDocumentToCache(appContext, dir, document, index)
}
holder.put(
extracted.copy(
images = copied,
documents = documents,
// 拷不动的那些如实计入「没发送」:宁可界面上说一句,也不要静默少一张。
failedImages = extracted.failedImages + (extracted.images.size - copied.size),
failedDocuments = extracted.failedDocuments + (extracted.documents.size - documents.size),
),
)
}
}
/**
* 取走一条待落地内容(原子出队,只有取到它的那个调用方会去发);队列为空回 `null`。
*
* 出队是**唯一的认领方式**:消费它的地方会被重建(重组 / 切会话 / Activity 重建),
* 所以「已经发过了」这件事不能记在消费方身上(见 [PendingShareHolder])。
*/
fun take(): SharedContent? = holder.take()
private fun shareDir(context: Context): File = File(context.cacheDir, DIR_NAME)
/**
* 把一段**分享进来的超长正文**落成 cache 里的 `.txt`,返回可当附件用的待发文档;写不进去回 `null`。
*
* ## 为什么走本对象
* 落成附件的那份字节必须有个**本端的本地地址**才能进既有发送链(芯片要显示它、上传要按 URI
* 现读字节、发送失败回滚后用户还可能要点开看):`DraftDocument` 只带引用不带字节。而写进
* 「分享缓存」这个目录、跟着同一条清理规则(进程启动整目录删一次)是最省事也最一致的做法——
* 与外部分享进来的文件**同一处落地、同一条生命周期**,不另造一个没人回收的目录。
* 本函数只负责「落盘 + 报一个可用的引用」,**不判断**要不要转(那是
* [DocumentDelivery.planSharedText] 的事)。
*
* 文件名用 [ShareRules.documentCacheFileName] 生成(**只由本端 token 与序号拼路径**,
* 外部字符串一个字符都进不了文件名),但对**服务端**报的名字是
* [DocumentDelivery.TEXT_DOCUMENT_FILE_NAME] ——服务端只取扩展名(白名单里的 `.txt`),
* 其余部分会被内容寻址丢弃。
*
* @return 待发文档(含 `file://` 地址、显示名与**真实字节数**);任何一步失败都回 `null`,
* 由调用方按「内容一字不丢」的原则退回内联正文(见 `ChatViewModel.sendShared`)。
*/
suspend fun stageTextAsDocument(context: Context, text: String): DraftDocument? = withContext(Dispatchers.IO) {
val staged = runCatching { DocumentDelivery.textAsFile(text) }.getOrNull() ?: return@withContext null
val dir = shareDir(context.applicationContext).apply { mkdirs() }
val target = File(
dir,
ShareRules.documentCacheFileName(UUID.randomUUID().toString(), 0, staged.fileName),
)
try {
target.writeBytes(staged.bytes)
val size = target.length()
if (size > 0L) {
DraftDocument(
uri = Uri.fromFile(target).toString(),
displayName = staged.fileName,
sizeBytes = size,
)
} else {
target.delete()
null
}
} catch (_: Exception) {
// 半截文件不能留下:它会被当成一份真附件去读,报出的错会指向错的地方。
target.delete()
null
}
}
/**
* 把一份非图片分享文件的字节拷进 cache,返回**本端自己的** `file://` 地址 + 显示名 + 真实字节数;
* 失败回 `null`。
*
* 显示名必须先取(拷完之后外部 URI 就只在这个调用栈里了),取不到就回落 URI 最后一段——
* 它是**上传部件的名字**,服务端只从中取扩展名(判定白名单),空名字则由仓库层兜
* `document.txt`。
*/
private fun copyDocumentToCache(
context: Context,
dir: File,
document: SharedDocument,
index: Int,
): SharedDocument? {
val parsed = runCatching { Uri.parse(document.uri) }.getOrNull() ?: return null
if (parsed.scheme.isNullOrBlank()) return null
val name = displayNameOf(context, parsed)
val target = File(dir, ShareRules.documentCacheFileName(UUID.randomUUID().toString(), index, name))
return try {
val source = context.contentResolver.openInputStream(parsed) ?: return null
source.use { input -> target.outputStream().use { output -> input.copyTo(output) } }
val size = target.length()
if (size > 0L) {
document.copy(uri = Uri.fromFile(target).toString(), displayName = name, sizeBytes = size)
} else {
target.delete()
null
}
} catch (_: Exception) {
// 半截文件不能留下:下一次发送会把它当一份真文件去读,报出的错会指向错的地方。
target.delete()
null
}
}
/** provider 报的显示名优先;拿不到就回落到 URI 最后一段(都不给就留空,不猜扩展名)。 */
private fun displayNameOf(context: Context, uri: Uri): String {
val fromProvider = runCatching {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (index < 0 || cursor.isNull(index)) null else cursor.getString(index)
}
}.getOrNull()
return fromProvider?.takeIf { it.isNotBlank() }
?: DocumentAttachments.fallbackDocumentName(uri.toString())
}
/**
* 把一个外部 URI 的字节拷进 cache,返回本端自己的 `file://` URI;失败回 `null`。
*
* 必须是 `file://` 而不是裸路径:`ContentResolver.openInputStream` 认 `file` scheme,
* 但裸路径没有 scheme,会走到「找 content provider」那条路并抛 `FileNotFoundException`。
*/
private fun copyToCache(context: Context, dir: File, uri: String, index: Int): String? {
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return null
if (parsed.scheme.isNullOrBlank()) return null
val target = File(
dir,
ShareRules.cacheFileName(
token = UUID.randomUUID().toString(),
index = index,
mime = runCatching { context.contentResolver.getType(parsed) }.getOrNull(),
),
)
return try {
val source = context.contentResolver.openInputStream(parsed) ?: return null
source.use { input -> target.outputStream().use { output -> input.copyTo(output) } }
if (target.length() > 0L) {
Uri.fromFile(target).toString()
} else {
target.delete()
null
}
} catch (_: Exception) {
// 半截文件不能留下:下一次发送会把它当一张真图去解码,报出的错会指向错的地方。
target.delete()
null
}
}
/**
* `EXTRA_STREAM` 的原始值(**故意读原始值**,理由见 [ShareStreamExtras])。
*
* `Bundle.get` 在 API 33 起被打上废弃:它绕过类型安全。这里要的正是「绕过类型安全」——要判的
* 就是「这个值到底是什么形态」。逐类型的 `getXxxExtra` 遇到别的形态只会往 logcat 打一句
* `ClassCastException` 再把值扔掉,那正是本次要修的那个静默。
*/
@Suppress("DEPRECATION")
private fun Intent.streamExtraValue(): Any? = extras?.get(Intent.EXTRA_STREAM)
/**
* `ClipData` 各条目的 URI(部分 App 只填它);纯文本条目的 URI 是 `null`。
*
* 这里刻意**保留**没有 URI 的条目(用 `null` 占位)而不是就地滤掉:条目本身就是「这次分享带着
* 内容」的证据——只有纯文本条目时本端一个字节都读不到(本端不读 `ClipData` 里的文本),那种
* 情形必须能说出口,见 [SharedContent.contentUnreadable]。
*/
private fun Intent.clipUris(): List<String?> {
val clip = clipData ?: return emptyList()
return (0 until clip.itemCount).map { clip.getItemAt(it).uri?.toString() }
}
}
/**
* `EXTRA_STREAM` 一条**原始值** ⇒ 里面能读出来的地址,按形态分成两组。
*
* ## 为什么读原始值而不是挨个 `getXxxExtra` 去问
* `EXTRA_STREAM` 在真实世界里有三种形态:规范推荐的 `Uri`(多选时 `ArrayList<Uri>`)、`String[]`、
* `ArrayList<String>`。后两种(历史遗留 / 跨平台壳应用常见)此前**完全认不出**:`getParcelableExtra`
* 对 `String[]` 只会往 logcat 打一句
* `ClassCastException: Cannot cast [Ljava.lang.String; to android.net.Uri` 然后回 `null`,
* `getParcelableArrayListExtra` 同理——于是整次分享「什么都没发生」,连一句告知都没有。读原始值再按
* 类型分派是一次判完三种形态,顺带也不再往 logcat 里丢那两行噪声(`Bundle.get` 不做类型检查,
* 正是这里需要的;`getXxxExtra` 那一套是查完再 cast,查不中就把值扔了)。
*
* ## 形态决定信任级别
* - [uriForms]:`Uri`,以及数组 / 列表里的 `Uri`。对方是把 URI **对象**写进去的,这是规范路径;
* 本端维持既有口径不动(不看 scheme)。
* - [stringForms]:`String` / `CharSequence` 及其数组 / 列表。这是本端新开的一条读入口,所以开得窄
* ——能不能用由 [ShareRules.stringStreamUri] 逐条判(只认 `content://`)。
*
* 认不出形态的原始值(别的 `Parcelable` / 数字 / 映射……)**不猜**:两边都不收。
*/
internal data class ShareStreamExtras(
val uriForms: List<String>,
val stringForms: List<String>,
)
/** 见 [ShareStreamExtras]:把 `EXTRA_STREAM` 的原始值按形态摊平成两组地址(纯逻辑,可单测)。 */
internal fun shareStreamExtrasOf(raw: Any?): ShareStreamExtras {
val uriForms = mutableListOf<String>()
val stringForms = mutableListOf<String>()
fun collect(value: Any?) {
when (value) {
null -> Unit
is Uri -> uriForms += value.toString()
// 含 String。`Spanned` 之类也走这一条:它照样要过 scheme 白名单,不是「因为它是
// CharSequence 就信」。
is CharSequence -> stringForms += value.toString()
is Array<*> -> value.forEach { collect(it) }
is Iterable<*> -> value.forEach { collect(it) }
else -> Unit
}
}
collect(raw)
return ShareStreamExtras(uriForms = uriForms, stringForms = stringForms)
}
@@ -0,0 +1,254 @@
package top.yeij.cyrene.core.auth
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiErrorEnvelope
import top.yeij.cyrene.data.api.CyreneApi
import top.yeij.cyrene.data.api.dto.LoginRequest
import top.yeij.cyrene.data.api.dto.LogoutRequest
import top.yeij.cyrene.data.api.dto.RefreshRequest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
sealed interface SessionState {
data object Unknown : SessionState
data object LoggedOut : SessionState
data class LoggedIn(val baseUrl: String, val role: String) : SessionState
}
/**
* 会话生命周期:登录、静默恢复、续期、登出。
*
* B2 起密码**不再落盘**:长期凭据是 refresh token,访问令牌过期时用它续期,
* 续期由 [TokenRefresher] 单飞协调(并发 401 只会真的刷一次——服务端轮换无宽限期)。
*
* @param apiFactory 认证接口的构造方式。默认走真实 Retrofit;单测注入假实现即可覆盖
* 登录 / 迁移 / 登出路径,既不需要网络也不需要新增依赖。
*/
class AuthManager(
private val store: SessionStorage,
private val apiFactory: (String) -> CyreneApi = { ApiClientFactory.create(baseUrl = it) },
) {
private val _state = MutableStateFlow<SessionState>(SessionState.Unknown)
val state: StateFlow<SessionState> = _state.asStateFlow()
@Volatile
var currentBaseUrl: String = ""
private set
private val refresher = TokenRefresher(store, ::refreshCall)
/** 不带令牌的认证客户端(登录 / 刷新 / 登出),按 baseUrl 缓存。可被 OkHttp 线程读到。 */
@Volatile
private var authApi: CyreneApi? = null
@Volatile
private var authApiBaseUrl: String? = null
/**
* 401 处置:能续期就用新令牌重试一次,明确被拒才判终态。
* 暂时性失败(网络 / 5xx)**绝不清会话**——见 B2 §6。
*/
val unauthorizedPolicy: UnauthorizedPolicy = object : UnauthorizedPolicy {
override fun renew(usedToken: String): AuthRecovery = refresher.renew(usedToken)
override fun onSessionEnded() = onSessionExpired()
}
/** tokenProvider 与 WS 握手用:临近过期时阻塞地先换令牌。 */
fun currentToken(): String? = refresher.ensureToken()
/**
* WS 握手 401 的一次性恢复:换一次令牌让上层重连。
* 与 401 拦截器共用同一条单飞路径,因此 REST 与 WS 同时失效也只会打一次刷新。
*/
fun recoverSocketAuth(): AuthRecovery = refresher.renew(store.load().token)
/**
* 启动时恢复会话(D12 + B2 迁移)。顺序即优先级:
*
* 1. **迁移**:没有 refresh token 却还留着旧版的明文密码 → 用旧凭据静默登录一次换 refresh token,
* 成功后立刻把它抹掉;失败(例如离线)保留原值、**不登出**,下次启动再试。
* 2. 已经有 refresh token → 任何残留的明文密码立刻抹掉。
* 3. 访问令牌过期 → 用 refresh token 续期。密码没了以后,这是唯一的静默恢复途径;
* 没有了它,隔一天再打开就要重新输密码(比旧版还差)。
* 4. 仍然拿不到有效令牌 → 回登录页(username 仍预填)。
*/
suspend fun bootstrap() = withContext(Dispatchers.IO) {
var session = store.load()
currentBaseUrl = session.baseUrl
val legacyUser = session.username?.takeIf(String::isNotBlank)
val legacyPassword = session.password?.takeIf(String::isNotBlank)
if (session.refreshToken.isNullOrBlank() && legacyUser != null && legacyPassword != null) {
doLogin(session.baseUrl, legacyUser, legacyPassword)
session = store.load()
} else if (!session.password.isNullOrBlank()) {
store.purgePassword()
session = store.load()
}
if (!session.hasValidToken() && !session.refreshToken.isNullOrBlank()) {
refresher.renew(session.token)
session = store.load()
}
_state.value = if (session.hasValidToken()) {
SessionState.LoggedIn(session.baseUrl, session.role.orEmpty())
} else {
SessionState.LoggedOut
}
}
suspend fun login(rawBaseUrl: String, username: String, password: String): CyreneError? =
withContext(Dispatchers.IO) {
doLogin(ApiClientFactory.normalizeBaseUrl(rawBaseUrl), username, password)
}
/**
* 主动登出:先尽力吊销服务端的 refresh 授权(否则本地「登出」之后那条授权还能活 30 天),
* 再抹掉本地一切。吊销失败、超时、离线都不阻断本地清理,所以离线登出依然干净。
*/
suspend fun logout() = withContext(Dispatchers.IO) {
val refreshToken = store.load().refreshToken?.takeIf(String::isNotBlank)
if (refreshToken != null) {
// 吊销请求不能拖住登出:给一个短上限,超时就当离线处理。
withTimeoutOrNull(REVOKE_TIMEOUT_MS) {
try {
authApi(sessionBaseUrl()).logout(LogoutRequest(refreshToken))
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// 尽力而为:离线 / 服务端报错也必须把本地清干净。
}
}
}
store.clearAll()
authApi = null
authApiBaseUrl = null
_state.value = SessionState.LoggedOut
}
/** 会话终结(refresh 不可能或明确被拒):清令牌回登录页;username 保留以便预填。 */
fun onSessionExpired() {
store.clearSession()
_state.value = SessionState.LoggedOut
}
private suspend fun doLogin(url: String, username: String, password: String): CyreneError? {
return try {
val response = authApi(url).login(LoginRequest(username, password))
val data = response.body()?.data
when {
response.isSuccessful && data != null -> {
store.saveLogin(url, data.token, data.expiresAt, data.role, username, data.refreshToken)
// B2 红线:不再写明文密码。拿到长期凭据后,把历史遗留的那一份也一并抹掉。
if (!data.refreshToken.isNullOrBlank()) store.purgePassword()
currentBaseUrl = url
_state.value = SessionState.LoggedIn(url, data.role)
null
}
response.isSuccessful -> CyreneError.Local(CyreneError.LocalKind.Parse)
else -> {
val parsed = parseError(response.errorBody()?.string())
if (response.code() == HTTP_UNAUTHORIZED) {
CyreneError.Unauthorized(parsed?.code.orEmpty(), parsed?.message.orEmpty())
} else {
CyreneError.Server(
code = parsed?.code.orEmpty(),
message = parsed?.message.orEmpty(),
httpStatus = response.code(),
)
}
}
}
} catch (e: Exception) {
mapException(e)
}
}
/**
* [TokenRefresher] 注入的阻塞刷新调用。
*
* 它跑在 OkHttp 的调用线程上(拦截器 / tokenProvider),所以这里用 `runBlocking` 就地驱动
* Retrofit 的挂起接口——那正是此刻需要的语义:同步等结果。
*
* 客户端**不带令牌**,镜像 [doLogin]:既不会让过期令牌搭车,也不会把刷新的 401 送回
* 本类的过期处理路径(那两个客户端用的是 [NoUnauthorizedPolicy])。
*/
private fun refreshCall(refreshToken: String): RefreshCall {
val baseUrl = sessionBaseUrl()
if (baseUrl.isBlank()) return RefreshCall.Failed
return try {
val response = runBlocking { authApi(baseUrl).refresh(RefreshRequest(refreshToken)) }
val data = response.body()?.data
when {
response.isSuccessful && data != null ->
RefreshCall.Ok(data.token, data.expiresAt, data.refreshToken)
// 200 但解不动:不拿会话去赌,当暂时性失败。
response.isSuccessful -> RefreshCall.Failed
// 该接口唯一的 401 就是 invalid_grant(未知 / 已吊销 / 已过期 / 已被用掉)。
response.code() == HTTP_UNAUTHORIZED -> RefreshCall.Rejected
else -> RefreshCall.Failed
}
} catch (_: Exception) {
RefreshCall.Failed
}
}
private fun authApi(baseUrl: String): CyreneApi {
val cached = authApi
if (cached != null && authApiBaseUrl == baseUrl) return cached
val created = apiFactory(baseUrl)
authApi = created
authApiBaseUrl = baseUrl
return created
}
private fun sessionBaseUrl(): String = currentBaseUrl.ifBlank { store.load().baseUrl }
private fun StoredSession.hasValidToken(): Boolean =
!token.isNullOrBlank() && expiresAt * 1000 > System.currentTimeMillis()
private fun parseError(body: String?) = body
?.takeIf { it.isNotBlank() }
?.let {
runCatching {
ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
}.getOrNull()
}
private fun mapException(e: Throwable): CyreneError = when (e) {
is UnknownHostException -> CyreneError.Local(CyreneError.LocalKind.Dns)
is SSLException -> CyreneError.Local(CyreneError.LocalKind.Tls)
is SocketTimeoutException -> CyreneError.Local(CyreneError.LocalKind.Timeout)
is ConnectException -> CyreneError.Local(CyreneError.LocalKind.ConnectFailed)
is java.net.UnknownServiceException -> CyreneError.Local(CyreneError.LocalKind.CleartextBlocked)
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
}
private companion object {
const val HTTP_UNAUTHORIZED = 401
/** 吊销授权的最长等待:到点就当离线,本地照样清干净。 */
const val REVOKE_TIMEOUT_MS = 3_000L
}
}
@@ -0,0 +1,137 @@
package top.yeij.cyrene.core.auth
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
data class StoredSession(
val baseUrl: String,
val token: String?,
val expiresAt: Long,
val role: String?,
val username: String?,
/** 长期凭据(B2);B2 之前登录的安装没有这一项,因此可能为 null。 */
val refreshToken: String?,
/**
* **遗留字段**:B2 之前的版本把登录密码落在这里。
* 只读用于一次性迁移,绝不再写入;见 [SessionStorage.purgePassword]。
*/
val password: String?,
)
/**
* 会话存储契约。[SessionStore] 是唯一的生产实现(EncryptedSharedPreferences);
* 单元测试用内存假实现顶替,避免把 Android 的加密存储拖进 JVM 测试。
*/
interface SessionStorage {
fun load(): StoredSession
/** 登录成功:整份会话落盘。**不写密码**(B2 红线)。 */
fun saveLogin(
baseUrl: String,
token: String,
expiresAt: Long,
role: String,
username: String,
refreshToken: String?,
)
/**
* 只替换令牌三元组,保留 baseUrl / username / role。
* [refreshToken] 为 null 表示沿用原值——`AUTH_REFRESH_ROTATE_DISABLED=true` 时服务端不下发新的,
* 此时旧凭据仍然有效,绝不能丢掉。
*/
fun updateTokens(token: String, expiresAt: Long, refreshToken: String?)
/**
* 确定性抹掉历史遗留的明文密码。
* 用 `commit()` 而不是 `apply()`:这是红线清除,必须在下一次读取之前就落到磁盘上。
*/
fun purgePassword()
/** 会话失效:清 token 与 refresh token,保留 username 以便登录页预填。 */
fun clearSession()
/** 主动登出:整份清空。 */
fun clearAll()
}
/** JWT 与登录凭据的加密存储(红线:不落明文密码、不打日志)。 */
class SessionStore(context: Context) : SessionStorage {
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
FILE_NAME,
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
override fun load(): StoredSession = StoredSession(
baseUrl = prefs.getString(KEY_BASE_URL, "").orEmpty(),
token = prefs.getString(KEY_TOKEN, null),
expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L),
role = prefs.getString(KEY_ROLE, null),
username = prefs.getString(KEY_USERNAME, null),
refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null),
// 只读:迁移路径靠它判断「这是升级前的安装」,不再写入。
password = prefs.getString(KEY_PASSWORD, null),
)
override fun saveLogin(
baseUrl: String,
token: String,
expiresAt: Long,
role: String,
username: String,
refreshToken: String?,
) {
val editor = prefs.edit()
.putString(KEY_BASE_URL, baseUrl)
.putString(KEY_TOKEN, token)
.putLong(KEY_EXPIRES_AT, expiresAt)
.putString(KEY_ROLE, role)
.putString(KEY_USERNAME, username)
if (!refreshToken.isNullOrBlank()) editor.putString(KEY_REFRESH_TOKEN, refreshToken)
editor.apply()
}
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
val editor = prefs.edit()
.putString(KEY_TOKEN, token)
.putLong(KEY_EXPIRES_AT, expiresAt)
// 轮换关闭时服务端不下发新凭据,旧的那一份继续有效,必须保留。
if (!refreshToken.isNullOrBlank()) editor.putString(KEY_REFRESH_TOKEN, refreshToken)
editor.apply()
}
override fun purgePassword() {
prefs.edit().remove(KEY_PASSWORD).commit()
}
override fun clearSession() {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_EXPIRES_AT)
.remove(KEY_ROLE)
.remove(KEY_REFRESH_TOKEN)
.apply()
}
override fun clearAll() {
prefs.edit().clear().apply()
}
private companion object {
const val FILE_NAME = "cyrene_session"
const val KEY_BASE_URL = "base_url"
const val KEY_TOKEN = "token"
const val KEY_EXPIRES_AT = "expires_at"
const val KEY_ROLE = "role"
const val KEY_USERNAME = "username"
const val KEY_REFRESH_TOKEN = "refresh_token"
const val KEY_PASSWORD = "password"
}
}
@@ -0,0 +1,132 @@
package top.yeij.cyrene.core.auth
/** 一次令牌续期的结果。 */
sealed interface AuthRecovery {
/** 已拿到可用的访问令牌:可能是刚签发的,也可能是别的线程刚换好的。 */
data class Renewed(val token: String) : AuthRecovery
/**
* 无法续期:本地没有 refresh token,或被服务端以 `invalid_grant` 拒绝
* (未知 / 已吊销 / 已过期 / 已被用掉,服务端把这四种情况折叠成一个码)。
* 这是终态——必须重新登录。
*/
data object Unrecoverable : AuthRecovery
/** 暂时性失败:网络不通、5xx、响应解不动。会话可能仍然有效,稍后再试。 */
data object Transient : AuthRecovery
}
/**
* [TokenRefresher] 注入的刷新调用结果。
* 把 HTTP 细节挡在协调逻辑之外,单飞与代际守卫因此可以纯 JVM 单测。
*/
sealed interface RefreshCall {
/** 200:新的访问令牌;[refreshToken] 为 null 表示服务端关闭了轮换、旧凭据继续有效。 */
data class Ok(val token: String, val expiresAt: Long, val refreshToken: String?) : RefreshCall
/** `401 invalid_grant`。 */
data object Rejected : RefreshCall
/** 网络 / 5xx / 解析失败。 */
data object Failed : RefreshCall
}
/**
* 单飞(single-flight)刷新协调器。
*
* ## 为什么必须单飞
* 服务端 `RefreshStore.Rotate` 是**原子且无宽限期**的:同一个 refresh token 被第二次使用会立刻
* 拿到 `invalid_grant`(防重放)。而本端并发 401 是常态——管理概览页两个请求并发、聊天页 REST 与
* WS 同时起步。所以同一时刻只允许一个线程真正发刷新请求,其余线程在锁上等待后复用结果。
*
* ## 代际守卫
* 拿到锁之后**重新读一次存储**:如果已存的访问令牌既不是自己手里那份([renew] 的 `staleToken`)、
* 又还没到过期点,说明等在锁上的这段时间里别的线程已经换好了令牌——直接复用,绝不刷第二次。
*
* ## 线程模型
* 全部逻辑跑在**调用方线程**上(OkHttp 的调用线程 / WS 重连协程),用 JVM 监视器锁串行化。
* 因此这里没有、也不能用协程 `Mutex`——调用点根本没有 suspend 上下文。
* 真实的刷新请求由调用方用**不带令牌**的客户端发出(见 `AuthManager.refreshCall`),
* 不会递归回本类。
*
* @param store 会话读写;实现必须让写入立刻对下一次 [SessionStorage.load] 可见。
* @param call 真正发刷新请求的阻塞调用。
* @param now 当前毫秒时间戳,可注入以便单测。
*/
class TokenRefresher(
private val store: SessionStorage,
private val call: (refreshToken: String) -> RefreshCall,
private val now: () -> Long = System::currentTimeMillis,
) {
private val lock = Any()
/**
* 已经确定无效的那个 refresh token。`invalid_grant` 对该值而言是**永久**结论,
* 记下来可以让同一次失效里的其它并发 401 直接得到终态,不必各打一次注定失败的请求。
* 值本身随机且每次轮换都变,因此不需要额外失效逻辑。
*/
private var rejectedRefreshToken: String? = null
/** 距过期不足 [SKEW_MS] 就算「该提前换令牌了」。 */
fun isNearExpiry(session: StoredSession): Boolean =
session.expiresAt * 1000 - now() <= SKEW_MS
/**
* 阻塞地给出一个当下可用的访问令牌;临近过期时先走一次单飞刷新。
* 供 OkHttp 的 tokenProvider 与 WS 握手使用(每次重连都会重新读)。
*
* 刷新失败时**仍然返回手里那份令牌**——它可能还没真的过期,401 交给兜底路径处理;
* 在这里返回 null 会让整条请求链断掉,比让服务端来裁决更糟。
*/
fun ensureToken(): String? {
val session = store.load()
val token = session.token?.takeIf(String::isNotBlank) ?: return null
if (!isNearExpiry(session)) return token
return when (val recovery = renew(token)) {
is AuthRecovery.Renewed -> recovery.token
AuthRecovery.Unrecoverable, AuthRecovery.Transient -> token
}
}
/**
* 单飞刷新:同一时刻只有一个线程真正发请求,其余线程在锁上等待并复用结果。
*
* @param staleToken 触发续期的那次请求实际携带的令牌;null 表示只知道「手上没有可用的令牌」。
*/
fun renew(staleToken: String?): AuthRecovery = synchronized(lock) {
val session = store.load()
// 代际守卫:等在锁上的这段时间里,别的线程已经换好了令牌 → 复用,绝不用同一个 refresh token 打第二次。
val current = session.token
if (!current.isNullOrBlank() && current != staleToken && !isNearExpiry(session)) {
return@synchronized AuthRecovery.Renewed(current)
}
val refreshToken = session.refreshToken?.takeIf(String::isNotBlank)
?: return@synchronized AuthRecovery.Unrecoverable
if (refreshToken == rejectedRefreshToken) return@synchronized AuthRecovery.Unrecoverable
when (val result = call(refreshToken)) {
is RefreshCall.Ok -> {
rejectedRefreshToken = null
store.updateTokens(result.token, result.expiresAt, result.refreshToken)
AuthRecovery.Renewed(result.token)
}
RefreshCall.Rejected -> {
rejectedRefreshToken = refreshToken
AuthRecovery.Unrecoverable
}
RefreshCall.Failed -> AuthRecovery.Transient
}
}
private companion object {
/** 提前量:24h 的访问令牌留 60s 余量,避免踩着过期边界发请求。 */
const val SKEW_MS = 60_000L
}
}
@@ -0,0 +1,29 @@
package top.yeij.cyrene.core.auth
/**
* 「带了令牌仍然 401」的处置策略。
*
* 由 `ApiClientFactory` 的拦截器在 **OkHttp 的调用线程上同步调用**,所以实现必须是
* 阻塞且线程安全的——那里没有 suspend 上下文,协程 `Mutex` 帮不上忙。
*/
interface UnauthorizedPolicy {
/**
* 尝试把被服务端拒绝的令牌换成可用的新令牌。
*
* @param usedToken 触发这次 401 的请求实际携带的访问令牌。
* @return [AuthRecovery.Renewed] 时调用方会用新令牌重试一次;另外两种都不重试。
*/
fun renew(usedToken: String): AuthRecovery
/** 终态:续期不可能(没有 refresh token)或明确被拒(`invalid_grant`)——必须重新登录。 */
fun onSessionEnded()
}
/** 不带令牌的客户端(登录 / 刷新 / 登出)用:永不续期,也永不判终态。 */
object NoUnauthorizedPolicy : UnauthorizedPolicy {
override fun renew(usedToken: String): AuthRecovery = AuthRecovery.Unrecoverable
override fun onSessionEnded() = Unit
}
@@ -0,0 +1,53 @@
package top.yeij.cyrene.core.error
/**
* 统一错误模型:区分「服务端有响应」与「本地/网络层失败」。
* 约定(S1.1):已建立连接时用服务端 `error.message`,本地错误由客户端负责文案。
*/
sealed interface CyreneError {
val userMessage: String
data class Server(
val code: String,
val message: String,
val httpStatus: Int,
) : CyreneError {
override val userMessage: String
get() = message.ifBlank { "服务端错误(HTTP $httpStatus)" }
}
data class Unauthorized(
val code: String,
val message: String,
) : CyreneError {
override val userMessage: String
get() = message.ifBlank { "用户名或密码不正确" }
}
data class Local(val kind: LocalKind) : CyreneError {
override val userMessage: String
get() = when (kind) {
LocalKind.InvalidUrl -> "服务器地址格式不对,示例:http://192.168.1.10:8080"
LocalKind.NoNetwork -> "网络不可用,请检查连接"
LocalKind.Dns -> "找不到服务器,请检查服务器地址"
LocalKind.Tls -> "证书校验失败(自签证书暂不支持)"
LocalKind.Timeout -> "连接超时,服务器没有响应"
LocalKind.ConnectFailed -> "无法连接到服务器"
LocalKind.CleartextBlocked -> "系统禁止明文 HTTP 访问"
LocalKind.Parse -> "服务器返回的数据无法识别"
LocalKind.Unknown -> "发生未知错误"
}
}
enum class LocalKind {
InvalidUrl,
NoNetwork,
Dns,
Tls,
Timeout,
ConnectFailed,
CleartextBlocked,
Parse,
Unknown,
}
}
@@ -0,0 +1,134 @@
package top.yeij.cyrene.core.prefs
import android.content.Context
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.appDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_app")
enum class ThemeMode { System, Light, Dark }
/**
* 她忙时发消息的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`;忙时并行规范 §2.2 的
* `chat.parallel`)。
*
* 类型名与落盘键(`interrupt_mode`)都是**历史名字**:第三档「并行」接上来之后它管的不再只是打断,
* 但改类型名 / 键名 / 既有档位的 `name` 都会让已存偏好读不出来并**静默回落**默认档——所以三个名字
* 一个都不动(见 [toInterruptMode] 与 `InterruptModeTest` 的「落盘名稳定」那几条)。
*
* 一档同时表达**两个上行声明**:[wireScope] 与 [wireParallel]。三档各占一种组合——打断占前者、
* 并行占后者、追加队列两个都不占,**没有任何一档同时给两个**。
*
* @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **不发打断帧**。注意它不是
* 「发一条省略 scope 的帧」——那在服务端是 `"all"`(连待处理队列一起清),是完全不同的语义。
* @property wireParallel 这条 `chat` 要不要声明 `parallel:true`;`false` = **一个字节都不带**。
*/
enum class InterruptMode(val wireScope: String?, val wireParallel: Boolean) {
/**
* 追加队列(默认):忙时只把消息排进队列,不打断也不并行。
*
* 该模式**两个声明都不发**(`interruptScopeForSend` 恒为「不发」、`parallelForSend` 恒为
* `false`):默认档的线上请求与本偏好存在之前**逐字节相同**。
*/
AppendQueue(wireScope = null, wireParallel = false),
/**
* 打断:只取消在跑回合、**保留**待处理队列(`scope:"current"`)。
* 队列里的消息按编排器正常续跑规则继续执行,一条都不丢。
*/
Interrupt(wireScope = "current", wireParallel = false),
/**
* 并行:忙时**不打断**在跑的那一路,本条**另起一路**同时处理(`chat.parallel=true`);
* 服务端并发额度满时**不报错**,这一条落回排队。
*
* [wireScope] 为 `null` 不是因为「这一档没什么要发的东西」(它当然要发东西,发的是
* `chat.parallel`),而是因为**并行与打断互斥**:这一档**绝不允许**产生 `interrupt` 帧——
* 一产生就等于先把要并行的那一路砍掉。
*
* 代价:两路各算一遍 ⇒ 峰值算力 / token 近翻倍(主人 2026-10-05 裁决 4:不分摊、优先性能),
* 所以设置页这一栏的 summary 把这句代价明说出来。
*/
Parallel(wireScope = null, wireParallel = true),
}
/**
* 落盘字符串 → 档位(`AppSettings` 的 `interrupt_mode` 键读取口;抽成顶层函数是为了能单测——
* `AppSettings` 本体要 Android + DataStore,单测里起不来)。
*
* 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。
*/
fun String?.toInterruptMode(): InterruptMode = when (this) {
InterruptMode.Interrupt.name -> InterruptMode.Interrupt
InterruptMode.Parallel.name -> InterruptMode.Parallel
else -> InterruptMode.AppendQueue
}
/**
* App 级偏好(非机密)。
*
* @param themeMode 主题深浅
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
* @param thinkingExpanded 思维链卡片默认是否展开
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
*/
data class AppPrefs(
val themeMode: ThemeMode = ThemeMode.System,
val showNarration: Boolean = true,
val thinkingExpanded: Boolean = true,
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
)
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
val LocalAppPrefs = staticCompositionLocalOf { AppPrefs() }
class AppSettings(context: Context) {
private val store: DataStore<Preferences> = context.applicationContext.appDataStore
val data: Flow<AppPrefs> = store.data.map { prefs ->
AppPrefs(
themeMode = prefs[KEY_THEME_MODE].toThemeMode(),
showNarration = prefs[KEY_SHOW_NARRATION] ?: true,
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
interruptMode = prefs[KEY_INTERRUPT_MODE].toInterruptMode(),
)
}
suspend fun setThemeMode(mode: ThemeMode) {
store.edit { it[KEY_THEME_MODE] = mode.name }
}
suspend fun setShowNarration(enabled: Boolean) {
store.edit { it[KEY_SHOW_NARRATION] = enabled }
}
suspend fun setThinkingExpanded(enabled: Boolean) {
store.edit { it[KEY_THINKING_EXPANDED] = enabled }
}
suspend fun setInterruptMode(mode: InterruptMode) {
store.edit { it[KEY_INTERRUPT_MODE] = mode.name }
}
private fun String?.toThemeMode(): ThemeMode = when (this) {
ThemeMode.Light.name -> ThemeMode.Light
ThemeMode.Dark.name -> ThemeMode.Dark
else -> ThemeMode.System
}
private companion object {
val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode")
}
}
@@ -0,0 +1,63 @@
package top.yeij.cyrene.core.prefs
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.dto.EnumTableDto
private val Context.enumDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_enums")
/**
* 枚举权威表的本地回退快照(§1.1)。
*
* @param table 上次成功拉取的权威表;拉取失败时先用它渲染(绝不因此清空角标)
* @param fingerprint `baseUrl + username`:换服务器/账号后快照作废
*/
data class EnumPrefs(
val table: EnumTableDto? = null,
val fingerprint: String = "",
) {
/** 指纹匹配才认这份快照——绝不把别的服务器/账号的标签拿来用。 */
fun tableFor(fingerprint: String): EnumTableDto? = table?.takeIf { this.fingerprint == fingerprint }
}
class EnumPreferences(context: Context) {
private val json = ApiClientFactory.json
private val store: DataStore<Preferences> = context.applicationContext.enumDataStore
val data: Flow<EnumPrefs> = store.data.map { prefs ->
EnumPrefs(
table = prefs[KEY_TABLE].decodeTable(),
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
)
}
suspend fun save(table: EnumTableDto, fingerprint: String) {
store.edit { prefs ->
prefs[KEY_TABLE] = json.encodeToString(EnumTableDto.serializer(), table)
prefs[KEY_FINGERPRINT] = fingerprint
}
}
/** 登出 / 切换服务器时清空。 */
suspend fun clear() {
store.edit { it.clear() }
}
private fun String?.decodeTable(): EnumTableDto? = this
?.let { runCatching { json.decodeFromString(EnumTableDto.serializer(), it) }.getOrNull() }
private companion object {
val KEY_TABLE = stringPreferencesKey("enum_table")
val KEY_FINGERPRINT = stringPreferencesKey("enum_fingerprint")
}
}
@@ -0,0 +1,109 @@
package top.yeij.cyrene.core.prefs
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.ListSerializer
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_sessions")
/**
* 会话列表的本地回退快照与显示态(§3.1)。
*
* @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染
* @param snapshotAt 快照时间(用于「同步于 N 分钟前」)
* @param fingerprint [accountFingerprint]:换服务器/账号后快照作废
* @param activeSessionId 上次停留的会话
*/
data class SessionPrefs(
val snapshot: List<SessionSummaryDto> = emptyList(),
val snapshotAt: Long = 0L,
val fingerprint: String = "",
val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
) {
/** 指纹匹配才认这份快照——绝不把别的服务器/账号的会话列表拿来用。 */
fun snapshotFor(fingerprint: String): List<SessionSummaryDto> =
snapshot.takeIf { this.fingerprint == fingerprint }.orEmpty()
}
/**
* 账号指纹(纯函数):`baseUrl + username`。回答的是「**这份本地状态属于哪个环境**」。
*
* 抽成一个函数的理由是**全仓只能有一份口径**:这类判据一旦各拼一套字符串,差一个分隔符
* 就会在某个闸门上漏过去——而漏过去的后果是把别的服务器/账号的内容摆到屏幕上。
* 现在的两个消费者是 [SessionPrefs.snapshotFor](会话快照作不作废)与聊天页的 VM key
* (`chatViewModelKey`:那个 VM 是不是本环境的)。改口径就是改**两者**,不要就地另拼。
*
* 取用户名而不是角色:角色是同一个人在服务端被授的权限,账号身份由**服务器地址 + 用户名**
* 共同确定;要让指纹对不上,只能是换了服务器或换了账号,那两件事都该让本地状态作废。
*/
internal fun accountFingerprint(baseUrl: String, username: String?): String =
"$baseUrl|${username.orEmpty()}"
class SessionPreferences(context: Context) {
private val json = ApiClientFactory.json
private val store: DataStore<Preferences> = context.applicationContext.sessionDataStore
val data: Flow<SessionPrefs> = store.data.map { prefs ->
SessionPrefs(
snapshot = prefs[KEY_SNAPSHOT].decodeSessions(),
snapshotAt = prefs[KEY_SNAPSHOT_AT] ?: 0L,
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
activeSessionId = prefs[KEY_ACTIVE] ?: SessionSummaryDto.MAIN_SESSION_ID,
)
}
/** 拉取成功:整体替换快照(不做 merge,避免已删除的会话变成幽灵)。 */
suspend fun saveSnapshot(sessions: List<SessionSummaryDto>, fingerprint: String) {
store.edit { prefs ->
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, sessions)
prefs[KEY_SNAPSHOT_AT] = System.currentTimeMillis()
prefs[KEY_FINGERPRINT] = fingerprint
}
}
suspend fun setActive(sessionId: String) {
store.edit { prefs -> prefs[KEY_ACTIVE] = sessionId }
}
/** 删除会话:必要时把 active 退回主会话,并同步从快照里剔除。 */
suspend fun removeSession(sessionId: String) {
store.edit { prefs ->
if (prefs[KEY_ACTIVE] == sessionId) {
prefs[KEY_ACTIVE] = SessionSummaryDto.MAIN_SESSION_ID
}
val remaining = prefs[KEY_SNAPSHOT].decodeSessions().filterNot { it.id == sessionId }
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, remaining)
}
}
/** 登出时清空:快照、指纹、active 一起走,下次登录不会先闪一份旧列表。 */
suspend fun clear() {
store.edit { it.clear() }
}
private fun String?.decodeSessions(): List<SessionSummaryDto> = this
?.let { runCatching { json.decodeFromString(SessionsSerializer, it) }.getOrNull() }
.orEmpty()
private companion object {
val KEY_SNAPSHOT = stringPreferencesKey("sessions_snapshot")
val KEY_SNAPSHOT_AT = longPreferencesKey("sessions_snapshot_at")
val KEY_FINGERPRINT = stringPreferencesKey("sessions_fingerprint")
val KEY_ACTIVE = stringPreferencesKey("active_session_id")
val SessionsSerializer = ListSerializer(SessionSummaryDto.serializer())
}
}
@@ -0,0 +1,47 @@
package top.yeij.cyrene.core.session
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
/**
* 本端**已经知道服务端没有这个会话**的那几个会话 id(进程级、只在内存里、只增不减)。
*
* ## 它回答的是哪一个问题
* 「这个 id 还值不值得我发一次请求」。`ChatViewModel` 每个访问过的会话各留一个常驻 VM
* (切走 / 删会话都不回收),而它们都订阅同一条共享连接 ⇒ **每次重连各自会去补拉一轮**。会话
* 在本端被删掉之后,那个 VM 并不知道(它没有别的渠道知道),于是**每一个**重连都发一次注定
* `404 session_not_found` 的补拉,404 被静默吞掉(S7 登记的那一条)。
*
* 补拉**事前**就能判掉它,靠的就是这里:删除是**本端自己做的**,那一刻就已经知道结果了,
* 没有任何理由等下一条请求回来才「发现」。于是这一位把「已知不存在」从「一次注定失败的往返」
* 提前成「一次本地查询」——**不是**「重连时少拉一点」的省流量优化,而是不让一个注定失败的请求
* 上路(它会占一条连接、写一行日志、并把 `sessionGone` 变成一个只能靠 404 才置得起来的位)。
*
* ## 为什么是进程级、为什么**不**持久化
* - 进程级(挂在 `AppContainer` 上,与那条共享连接同一层):写它的是 `SessionsViewModel`
* (本端唯一的删除入口),读它的是**别的** VM —— 两个 VM 之间没有别的共享面。
* - **不持久化**:它只是「这一趟进程里已经知道的事」。重启之后本端的会话列表会重新从服务端拉
* ([top.yeij.cyrene.core.prefs.SessionPreferences] 那份快照本来就会整体替换),「这个会话还在不在」
* 以那一次服务端答复为准;把 id 落盘只会让一份陈旧记忆挡掉重启后的第一次拉取。
* - **换账号要清**([clear]):id 是服务端铸的,理论上不会跨账号撞车,但这一位说的是「本端
* 已经知道」,而换账号之后本端**什么都不知道** —— 留着上个环境的结论,是那种「差一个分隔符
* 就漏过去」的同一类错误(先例:`accountFingerprint` 的注释)。
*/
class GoneSessions {
private val ids = MutableStateFlow<Set<String>>(emptySet())
/** 记下「服务端已经没有这个会话了」。空串当无效值丢掉(那不是任何会话的 id)。 */
fun mark(sessionId: String) {
if (sessionId.isBlank()) return
ids.update { it + sessionId }
}
/** 本端是否**已经知道**它没了。只回答「知道不知道」,不回答「在不在」——不知道的一律 `false`。 */
fun contains(sessionId: String): Boolean = sessionId in ids.value
/** 换账号 / 登出:本端重新回到「什么都不知道」。 */
fun clear() {
ids.value = emptySet()
}
}
@@ -0,0 +1,16 @@
package top.yeij.cyrene.core.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController
@Composable
fun CyreneTheme(
mode: ColorSchemeMode = ColorSchemeMode.System,
content: @Composable () -> Unit,
) {
val controller = remember(mode) { ThemeController(mode) }
MiuixTheme(controller = controller, content = content)
}
@@ -0,0 +1,54 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
/**
* 管理只读监控端点(api-client §2,全部 `GET`,无写操作)。
*
* RBAC 由服务端兜底(`viewer` 对 ②③ 档一律 403),客户端按 §2.0 权限矩阵**自行降级**,
* 见 [top.yeij.cyrene.domain.admin.AdminAccess];这里只负责取数,不做权限判断。
*/
interface AdminApi {
/** 网关请求指标;`qps` 为近 1 秒窗口计数。 */
@GET("api/v1/admin/observability/metrics")
suspend fun metrics(): Response<ApiEnvelope<AdminMetricsDto>>
/** 最近 trace span,服务端按时间倒序(最新在前);`limit` 缺省 50、上限 500。 */
@GET("api/v1/admin/observability/trace")
suspend fun trace(@Query("limit") limit: Int? = null): Response<ApiEnvelope<List<AdminTraceSpanDto>>>
/**
* 后端日志 tail(api-client §2「可观测性契约」,v0.35):`{"data":[{time,level,source,msg}]}`,
* **最新在前**;`limit` 缺省 200、上限 1000(≤0 或非法回落缺省)。
*
* 不暴露契约里的 `?level=`:它是**最低等级**过滤,只在服务端裁剪一次结果——而界面上那排
* 等级档要能在同一份数据上来回切换(契约 §3.12 也明确推送帧的过滤只能本地做),
* 所以这里一律取全量、由客户端本地过滤(`ui/admin/AdminOverviewMerge.kt`)。
*
* RBAC 是 v0.40 的 `viewerAuth`:**viewer** 也读得到(owner / admin 本来就可以),
* 与 `trace` / `metrics` 同档。
*/
@GET("api/v1/admin/observability/logs")
suspend fun logs(@Query("limit") limit: Int? = null): Response<ApiEnvelope<List<AdminLogEntryDto>>>
/** 人格列表(含 admin 专属 `thinking`)。 */
@GET("api/v1/admin/personas")
suspend fun personas(): Response<ApiEnvelope<List<PersonaSummaryDto>>>
/** 私人记忆正文;`viewer` 会拿到 403。 */
@GET("api/v1/admin/memories")
suspend fun memories(): Response<ApiEnvelope<List<MemorySummaryDto>>>
/** 后台思考记录;`viewer` 会拿到 403。 */
@GET("api/v1/admin/backthink")
suspend fun backthink(): Response<ApiEnvelope<List<AdminBackthinkItemDto>>>
}
@@ -0,0 +1,163 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.BuildConfig
import top.yeij.cyrene.core.auth.AuthRecovery
import top.yeij.cyrene.core.auth.NoUnauthorizedPolicy
import top.yeij.cyrene.core.auth.UnauthorizedPolicy
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
/**
* **连接超时**(毫秒):解析 DNS + 建立 TCP 连接(以及 TLS 握手)这一段的预算,**全端共用这一个值**。
*
* ## 为什么从 10 秒缩到 2.5 秒(W45)
* 它就是「**完全无网时快速进队列**」这条性质的唯一依据。W41 把这条性质寄托在一个 2.5 秒的探针上
* (W41 那个探针 client 的 `callTimeout`),而那个探针**从不复用连接池**、每次都要现做一次冷握手
* ——于是真机上出现了「探了,但请求一个字节都没写出去」,而它与「压根没探」在外部完全同形,推断
* 不出是哪一档。W45 删掉探测、改成**直接试 REST** 之后,这条性质改由**连接超时**承担:连不上就在 2.5 秒
* 内失败,消息进队列(内容一个字节都不丢,重发仍是同一个 `clientMsgId`)。
*
* 取值 2.5 秒:正常网络下一次 TCP 握手是毫秒到几十毫秒量级(局域网、ZeroTier / Tailscale 也远达
* 不到秒级),2.5 秒是「人开始觉得卡」之前的量级,也与 W41 那 2.5 秒的探针预算**对齐**——换成直接
* 试 REST 之后,「无网时用户最多等多久」因此没有变长。**绝不许**再回到 10 秒:那会让每一条离线
* 消息的用户都白等十倍的时间,换来的只是「极端慢网下多一次成功的机会」。
*
* ## 它不管什么
* 「连上了但服务端一直不回话」(半死的代理、接住连接却不回应)不是它的事——那是读 / 写超时
* ([READ_TIMEOUT_SECONDS])与回落那一次投递自己的整通上限
* ([top.yeij.cyrene.ui.chat.REST_FALLBACK_TIMEOUT_MILLIS])的事。两个闸各管一段,合起来才是
* 「没网时用户最多等多久」。
*/
const val CONNECT_TIMEOUT_MILLIS = 2_500L
/** 读 / 写超时(秒):真请求(长文本、内联图片、文档字节)的耐心,与 [CONNECT_TIMEOUT_MILLIS] 分开。 */
private const val READ_TIMEOUT_SECONDS = 60L
object ApiClientFactory {
val json: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
/** 登录 / 刷新 / 登出接口(不注入 token、不参与续期)。 */
fun create(
baseUrl: String,
tokenProvider: () -> String? = { null },
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, unauthorizedPolicy)
/**
* 通用入口:按需构建任意 Retrofit service。
*
* 超时**只有两个值**,都由文件顶上的常量解释:连接 [CONNECT_TIMEOUT_MILLIS](短,保证「完全
* 无网时快速失败」)、读 / 写 [READ_TIMEOUT_SECONDS](长,真请求的耐心)。**刻意不设
* `callTimeout`**:那会把文档字节下载与图片上传一并框住,而它们本来就是长请求;哪一次投递需要
* 整通上限,就写在哪一次投递的调用点上(例:回落那一次的
* [top.yeij.cyrene.ui.chat.REST_FALLBACK_TIMEOUT_MILLIS])。
*
* DEBUG 下挂 BASIC 日志(`logcat -s OkHttp`):`-->` 打在 `chain.proceed()` **之前**,所以
* 「这条请求有没有发出去」与「死在连接还是死在回执」当场可分——回落那条路下一轮要靠它取证。
* 不动 `Authorization`(BASIC 根本不打印任何头),级别**绝不许**再往上调:`/auth/refresh` 与
* `/auth/logout` 把 refresh token 放在请求体里。
*/
fun <T : Any> create(
baseUrl: String,
service: Class<T>,
tokenProvider: () -> String? = { null },
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
): T {
val client = OkHttpClient.Builder()
// 连接这一段刻意短(理由见 [CONNECT_TIMEOUT_MILLIS]):它是「无网时消息快速进队列」的
// 唯一依据,所以它**不含**任何整通上限的意味。
.connectTimeout(CONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.addInterceptor(AuthInterceptor(tokenProvider, unauthorizedPolicy))
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(
HttpLoggingInterceptor().apply {
// BASIC 是上限:`/auth/refresh` 与 `/auth/logout` 把 refresh token 放在**请求体**里,
// 再高一级就会把凭据打进 logcat。
level = HttpLoggingInterceptor.Level.BASIC
redactHeader("Authorization")
},
)
}
}
.build()
return Retrofit.Builder()
.baseUrl(normalizeBaseUrl(baseUrl))
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(service)
}
/** Retrofit 要求 baseUrl 以 `/` 结尾;用户没写 scheme 时补 `http://`。 */
fun normalizeBaseUrl(raw: String): String {
var url = raw.trim()
if (url.isEmpty()) return url
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "http://$url"
}
if (!url.endsWith("/")) url += "/"
return url
}
/**
* 注入 `X-API-Version` 与 `Authorization`,并兜住「**带了令牌仍然** 401」:
* 先向策略要一次新令牌,拿到就用它**重试一次**;明确被拒才判终态。
*
* 为什么是应用层拦截器而不是 OkHttp `Authenticator`:
* `Authenticator` 对**任何** 401 都会被调用(包括不带令牌的登录请求),而且它由
* RetryAndFollowUp 在内部循环里驱动,重试次数(上限 20 次)不受本类控制——一个持续 401 的
* 令牌会打出一串刷新风暴。应用层拦截器只在带令牌时介入,且重试严格一次
* (OkHttp 允许应用层拦截器多次 `proceed()`,`calls != 1` 的检查只对网络拦截器生效)。
*/
private class AuthInterceptor(
private val tokenProvider: () -> String?,
private val unauthorizedPolicy: UnauthorizedPolicy,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider()
var response = chain.proceed(chain.request().withAuth(token))
// 只有「带了 token 仍 401」才算会话被拒;登录/刷新本身不带 token,不受影响。
if (response.code != HTTP_UNAUTHORIZED || token == null) return response
when (val recovery = unauthorizedPolicy.renew(token)) {
is AuthRecovery.Renewed -> {
response.close()
response = chain.proceed(chain.request().withAuth(recovery.token))
// 换上刚签发的令牌仍然 401:服务端根本不接受这个账号,再刷也没有意义,判终态。
if (response.code == HTTP_UNAUTHORIZED) unauthorizedPolicy.onSessionEnded()
}
AuthRecovery.Unrecoverable -> unauthorizedPolicy.onSessionEnded()
// 网络 / 5xx:会话可能还好着,原样把 401 交回去,绝不清会话。
AuthRecovery.Transient -> Unit
}
return response
}
private fun Request.withAuth(token: String?): Request = newBuilder()
.header("X-API-Version", "1")
.apply { if (token != null) header("Authorization", "Bearer $token") }
.build()
}
private const val HTTP_UNAUTHORIZED = 401
}
@@ -0,0 +1,21 @@
package top.yeij.cyrene.data.api
import kotlinx.serialization.Serializable
/** 后端统一信封:成功 `{data}`,失败 `{error:{code,message}}`。 */
@Serializable
data class ApiEnvelope<T>(
val data: T? = null,
val error: ApiError? = null,
)
@Serializable
data class ApiErrorEnvelope(
val error: ApiError? = null,
)
@Serializable
data class ApiError(
val code: String = "",
val message: String = "",
)
@@ -0,0 +1,76 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.core.error.CyreneError
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.SerializationException
import retrofit2.Response
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
/**
* 统一错误映射(S1.1):已建连接用服务端 `error`,本地异常按类型分类。
* 供所有 REST 仓库复用;不要在各个仓库里各写一份。
*/
object ApiErrorMapper {
/** 有 HTTP 响应:401 → [CyreneError.Unauthorized],其余 → [CyreneError.Server]。 */
fun fromHttp(httpStatus: Int, errorBody: String?): CyreneError {
val error = parseErrorEnvelope(errorBody)
val code = error?.code.orEmpty()
val message = error?.message.orEmpty()
return if (httpStatus == 401) {
CyreneError.Unauthorized(code = code, message = message)
} else {
CyreneError.Server(code = code, message = message, httpStatus = httpStatus)
}
}
/** 本地/网络异常 → [CyreneError.Local]。 */
fun fromThrowable(throwable: Throwable): CyreneError = when (throwable) {
is UnknownHostException -> CyreneError.Local(CyreneError.LocalKind.Dns)
is SSLException -> CyreneError.Local(CyreneError.LocalKind.Tls)
is SocketTimeoutException -> CyreneError.Local(CyreneError.LocalKind.Timeout)
is ConnectException -> CyreneError.Local(CyreneError.LocalKind.ConnectFailed)
is SerializationException -> CyreneError.Local(CyreneError.LocalKind.Parse)
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
}
/** 解析 `{"error":{code,message}}`;非信封 / 空白 / 解析失败均返回 null。 */
fun parseErrorEnvelope(errorBody: String?): ApiError? = errorBody
?.takeIf { it.isNotBlank() }
?.let {
runCatching {
ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
}.getOrNull()
}
}
/** 把 [CyreneError] 塞进 Kotlin `Result` 的载体,便于 `Result<T>` 统一传递失败。 */
class CyreneException(val error: CyreneError) : Exception(error.userMessage)
/**
* 执行一次信封调用并把结果收敛成 `Result<T>`:
* 成功且 `data != null` → success;成功但 `data == null` → Parse;否则走 [ApiErrorMapper]。
*/
suspend fun <T : Any> apiCall(block: suspend () -> Response<ApiEnvelope<T>>): Result<T> =
try {
val response = block()
val data = response.body()?.data
when {
response.isSuccessful && data != null -> Result.success(data)
response.isSuccessful -> Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
else -> Result.failure(
CyreneException(
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
),
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
}
@@ -0,0 +1,23 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.ChatAckDto
import top.yeij.cyrene.data.api.dto.ChatRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
/**
* 发送一条消息(`POST /api/v1/chat`,api-client §1 行 3)。权限 `owner|admin`。
*
* 与会话只读的 [SessionsApi]、管理面 [AdminApi] 分开:这是**写**路径,且它的失败面比只读端点
* 复杂得多(`202` / `409` / `500` / `503` 都是编排器的真实回执,不是「请求错了」),
* 放在一起会让只读端点的调用方以为错误处理是同一种。
*
* 返回原始 [Response](而不是直接返回信封):状态码本身是语义的一部分——
* `202` 排队 与 `409` 排队满 必须能被调用方分开,折进 `Result` 前就先丢了。
*/
interface ChatApi {
@POST("api/v1/chat")
suspend fun sendChat(@Body body: ChatRequest): Response<ApiEnvelope<ChatAckDto>>
}
@@ -0,0 +1,23 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.LoginRequest
import top.yeij.cyrene.data.api.dto.LoginResponse
import top.yeij.cyrene.data.api.dto.LogoutRequest
import top.yeij.cyrene.data.api.dto.LogoutResponse
import top.yeij.cyrene.data.api.dto.RefreshRequest
import top.yeij.cyrene.data.api.dto.RefreshResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
interface CyreneApi {
@POST("api/v1/auth/login")
suspend fun login(@Body body: LoginRequest): Response<ApiEnvelope<LoginResponse>>
@POST("api/v1/auth/refresh")
suspend fun refresh(@Body body: RefreshRequest): Response<ApiEnvelope<RefreshResponse>>
@POST("api/v1/auth/logout")
suspend fun logout(@Body body: LogoutRequest): Response<ApiEnvelope<LogoutResponse>>
}
@@ -0,0 +1,68 @@
package top.yeij.cyrene.data.api
import okhttp3.MultipartBody
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Streaming
import top.yeij.cyrene.data.api.dto.DocumentUploadResponse
/**
* 文档存储端点(契约 **v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」)**,形状依据见 `02-前后端协同事项.md` 回执五 §一 S1)。
* 权限 `owner|admin`。与图片链路([UploadApi] + `/api/v1/files/{name}`)**刻意分离**:
*
* | | 图片 | 文档 |
* |---|---|---|
* | 上传字段名 | `file` | `file`(同) |
* | 上限 | 5 MiB | 20 MiB(`CHAT_DOCUMENT_MAX_BYTES` 默认,**与图片上限解耦**) |
* | 读侧鉴权 | **不需要**(名字即凭据,供 `<img>`/provider 直接取) | **要 Bearer `owner|admin`** |
* | 读侧响应 | 可内联渲染 | `Content-Disposition: attachment` + `nosniff`(**永不渲染**) |
* | 成功载荷的 url | **绝对**同源地址,可直接当 `chat.image` | **相对**路径,客户端要的是 `name` |
*
* 为什么文档读侧要鉴权而图片不要:文档的摄取在**服务端**完成(正文进上下文),
* 客户端不需要把一个「名字即凭据」的读侧打开;图片则必须被 `<img>` 与 provider 直接消费。
*
* ## 鉴权拦截器这一侧没问题的依据
* Bearer 不是按接口挂的,而是**按 OkHttpClient** 挂的:[ApiClientFactory.create] 给每个
* 客户端都装了 `AuthInterceptor`(`Authorization: Bearer <token>` + 401 单飞续期),
* 因此只要 [DocumentRepository] 拿到的 `DocumentsApi` 是从 `ApiClientFactory.create` 建的
* (`AppContainer` 里照 [UploadApi] 的写法),**两个端点都自带 Bearer**,不需要额外加头。
*/
interface DocumentsApi {
/**
* 上传一份文档。
*
* @param file **部件名必须是 `file`**(服务端 `r.FormFile("file")`);**文件名必须带扩展名**,
* 且该扩展名要落在服务端的 deny-by-default 白名单里——与图片不同,这里的名字
* **真的参与裁决**(白名单扩展名 + 字节嗅探**合取**,两者都过才放行)。
* 服务端随后丢弃该名字,按内容寻址重命名为 `<sha256[:32]><ext>`,所以名字里
* 除了扩展名以外的内容不会被采用(也不会进路径)。
*
* 返回原始 [Response]:**`200` 不是错误**——同一份字节重复上传回 `200` + 同一个 `name` +
* `deduped:true`,客户端必须当成功。状态码本身不承载额外语义(`413`/`415` 的区分在
* `error.code` 里),所以仓库层可以直接走 [apiCall]。
*/
@Multipart
@POST("api/v1/documents")
suspend fun upload(@Part file: MultipartBody.Part): Response<ApiEnvelope<DocumentUploadResponse>>
/**
* 取回一份文档的原始字节。
*
* 它**不是信封**(响应体就是文件本身),所以返回 [ResponseBody]:解码器只处理
* `application/json`,这里必须自己读字节并在仓库层负责关闭。
* [Streaming] 让 Retrofit 不把响应体一次性读进内存(单份最大 20 MiB,边界情况不小)。
*
* @param name 服务端存储名(`<32hex>.<ext>`)。形态非法 / 不存在服务端都回
* `404 document_not_found`(不区分「非法」与「没有」),因此这里不做本地形态校验、
* 也不拿它拼任何本地路径。
*/
@Streaming
@GET("api/v1/documents/{name}")
suspend fun download(@Path("name") name: String): Response<ResponseBody>
}
@@ -0,0 +1,16 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.EnumTableDto
import retrofit2.Response
import retrofit2.http.GET
/**
* 共享枚举权威表端点(api-client §1.1 / api-reference §3.7)。
* 任意已认证角色可读(与 `GET /sessions` 同级),无 token 回 `401`;
* 纯内存投影,没有其它错误码。按本工程惯例单独成接口。
*/
interface MetaApi {
@GET("api/v1/meta/enums")
suspend fun enums(): Response<ApiEnvelope<EnumTableDto>>
}
@@ -0,0 +1,18 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.UserPersonaDto
import retrofit2.Response
import retrofit2.http.GET
/**
* **用户侧**角色目录(api-client §1 行 9)。任意已认证角色可读,无 token 才 `401`。
*
* 与 [AdminApi.personas] 分开是有意的:admin 端点每项多一个 `thinking`,而且整条路径要求
* `viewer` 以上的管理档——拿它当用户侧接口用,普通用户身上会拿到 `403`。
*/
interface PersonaApi {
/** 角色列表;服务端顺序即权威顺序,客户端不重排。 */
@GET("api/v1/personas")
suspend fun personas(): Response<ApiEnvelope<List<UserPersonaDto>>>
}
@@ -0,0 +1,147 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.CreateSessionRequest
import top.yeij.cyrene.data.api.dto.CreateSessionResponse
import top.yeij.cyrene.data.api.dto.DeleteMessageResponse
import top.yeij.cyrene.data.api.dto.DeleteSessionResponse
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.api.dto.UpdateMessageRequest
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/**
* 会话与消息相关端点(api-reference §3.2/§3.4/§3.5/§3.6)。
* 与 M1 的 `CyreneApi` 分开,避免互相污染。
*/
interface SessionsApi {
@GET("api/v1/sessions")
suspend fun listSessions(): Response<ApiEnvelope<List<SessionSummaryDto>>>
/**
* 新建次级会话,**完全不带 body**(= 不绑角色,跟随全局默认角色)。
*
* 这个无参重载是给「不选角色」这条路留的:`personaId` 为 null 时走它,线上请求与本端支持
* 选角之前**逐字节相同**——不会因为多了一个可选字段而改变任何既有行为。
* 服务端返回 `201 {data:{sessionId}}`。
*/
@POST("api/v1/sessions")
suspend fun createSession(): Response<ApiEnvelope<CreateSessionResponse>>
/**
* 新建会话并**在创建时绑定角色**(v0.42)。
*
* `personaId` 为**未知角色** → `400 bad_request`;非空时响应会回显它。
* 会话角色**只在创建时写入、之后不可变**:`PATCH /sessions/{id}` 收到 `personaId` 一律 `400`,
* 要换角色只能新开会话(见 [UpdateSessionRequest])。
*/
@POST("api/v1/sessions")
suspend fun createSession(@Body body: CreateSessionRequest): Response<ApiEnvelope<CreateSessionResponse>>
/** 主会话 `web` 删除会返回 `403 forbidden`。 */
@DELETE("api/v1/sessions/{id}")
suspend fun deleteSession(@Path("id") sessionId: String): Response<ApiEnvelope<DeleteSessionResponse>>
/**
* 重命名 / 置顶(`writeAuth`:owner 或 admin)。主会话与次级会话都能改。
*
* - 请求体至少要带一个字段;`{}` → 400。字段缺席即保留原值。
* - `title` 会被 trim;空/全空白 → 400 `title is required`(措辞误导,实际含义是
* **无法把标题清回自动命名**);超过 80 个字符 → 400。
* - 两个字段同时给出时**先改名再置顶**:置顶失败时改名已经落库,不能当成「什么都没发生」。
* - 响应是局部形状,见 [UpdateSessionResponse]。
*/
@PATCH("api/v1/sessions/{id}")
suspend fun updateSession(
@Path("id") sessionId: String,
@Body body: UpdateSessionRequest,
): Response<ApiEnvelope<UpdateSessionResponse>>
/**
* 消息热分页,服务端按 `createdAt` 倒序(newest first)返回。
*
* 普通分页与**会话内内容搜索**共用这一个端点(契约 §1 行 6:`q` 是 messages 端点上的一个
* 可选查询参数,不是另一个端点)。
*
* @param limit 默认 50、上限 200(由 [top.yeij.cyrene.data.repository.MessageRepository] 钳制)
* @param before 上一页最旧消息的 `createdAt`(Unix 毫秒);null = 最新一页
* @param platform 来源平台过滤;null = 全量
* @param query `q`:**会话内内容搜索**(D2)。null / 空 = 未传,走普通分页——线上请求因此与本端
* 支持搜索之前**逐字节相同**(Retrofit 会整个省掉 null 的查询参数)。非空时服务端按
* 大小写不敏感的 ASCII LIKE 匹配 `content`(`%` / `_` / `\` 已转义为字面量),
* 仍按时间倒序、`before` / `limit` 同分页语义,搜索**严格限定在 `{id}` 会话内**。
* **`q` 与 `after` 互斥**(同传 `400`)。
* @param after **正向增量**(A5,契约 §1 行 6):返回 `created_at > after` 的消息,**按时间
* **升序**(与 `before` 的倒序**相反**)——用来补「WS 重连只回放最近 50 条」漏掉的那一段。
* 游标是**本端已经看到的最后一条 `createdAt`**;服务端配 `limit` 从最旧的一条开始给,所以
* 用「本页最后一条的 `createdAt`」反复推进就能一直追到当前。
* **与 `before` / `q` 互斥**(同传 `400`)——三个调用点各自只给一个
* (`loadPage` 只给 `before`、`search` 只给 `q`、`loadAfter` 只给 `after`),天然不冲突。
* 同毫秒并列超过 `limit` 时纯 `createdAt` 游标会跨页重读,调用方按 `id` 去重兜底。
*/
@GET("api/v1/sessions/{id}/messages")
suspend fun listMessages(
@Path("id") sessionId: String,
@Query("limit") limit: Int? = null,
@Query("before") before: Long? = null,
@Query("platform") platform: String? = null,
@Query("q") query: String? = null,
@Query("after") after: Long? = null,
): Response<ApiEnvelope<List<HistoryMessageDto>>>
/**
* 推进已读游标(D4,契约 v0.37 `api-reference §3.6b`)。
*
* `before` 缺席 = 「现在」;游标**只前进**,所以重复 / 乱序 / 重试都幂等安全。
* 错误面:`before` 非法 → `400`、未知会话(含隔离 `sess-xxx`)→ `404`、
* 未注入 HistoryStore → `501`。成功后服务端会广播 `session_read`(§3.9)。
*/
@POST("api/v1/sessions/{id}/read")
suspend fun markRead(
@Path("id") sessionId: String,
@Body body: ReadSessionRequest,
): Response<ApiEnvelope<ReadSessionResponse>>
/**
* 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,body `{content}`。
*
* 响应是 `{data:<消息DTO>}`——`data` **直接就是**更新后的完整消息 DTO,**没有** `message`
* 包一层(v0.30 变更记录里那句 `{data:{message}}` 已作废,以契约正文为准)。服务端只改
* `content`,其余字段一律原值返回。
*
* 失败面:`content` 空 / 纯空白 / 超 4000 rune → `400 bad_request`;消息不存在 →
* `404 not_found`;未注入 HistoryStore → `501`;权限不足 → `403`。
*
* ⚠️ 成功后服务端**还会广播** `message_edited`(§3.8)——发起的这条连接自己也会收到它。
* 于是「REST 响应」与「广播帧」会把同一件事说两遍,两处必须收敛成一次状态变更
* (见 [top.yeij.cyrene.data.repository.applyMessageEdit])。
*/
@PATCH("api/v1/messages/{id}")
suspend fun updateMessage(
@Path("id") messageId: String,
@Body body: UpdateMessageRequest,
): Response<ApiEnvelope<HistoryMessageDto>>
/**
* 删除单条消息(D3,契约 §1 行 6b):`DELETE /api/v1/messages/{id}` →
* `{data:{deleted:<消息 id>}}`。
*
* 失败面:消息不存在 → `404 not_found`;未注入 HistoryStore → `501`;权限不足 → `403`。
* 成功后同样会广播 `message_deleted`(§3.8,只带被删消息的 `id`)。
*/
@DELETE("api/v1/messages/{id}")
suspend fun deleteMessage(
@Path("id") messageId: String,
): Response<ApiEnvelope<DeleteMessageResponse>>
}
@@ -0,0 +1,33 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.UploadResponse
import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
/**
* 图片上传端点(`POST /api/v1/upload`,api-client §1 行 13)。权限 `owner|admin`。
*
* **上传本身不是目的**:服务端把 `chat.image` 字符串**原样落库**,于是每一页历史与每一次
* WS `history` 回放都要把整段 base64 再传一遍(协同 G3)。上传一次、消息里只留 URL,
* 是这条链路上唯一能把「历史重量」永久压下去的手段。
*
* 与 [ChatApi] 分开:它的请求体是 multipart(不是 JSON)、错误面也完全不同
* (`413 payload_too_large` / `415 unsupported_media_type` 是它的日常回执)。
*
* 返回原始 [Response] 而不是直接折成 `Result`:与其它端点一样由仓库层统一走
* [ApiErrorMapper],状态码在这里**不承载额外语义**(`413`/`415` 的机器可读区分在
* `error.code` 里,所以仓库能用 [apiCall])。
*/
interface UploadApi {
/**
* @param file **部件名必须是 `file`**(服务端 `r.FormFile("file")`):字段名错了会回
* `400 {error:{code:"bad_request",message:"file is required"}}`(已对着在线后端实测)。
*/
@Multipart
@POST("api/v1/upload")
suspend fun upload(@Part file: MultipartBody.Part): Response<ApiEnvelope<UploadResponse>>
}
@@ -0,0 +1,127 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* `GET /api/v1/admin/observability/metrics`(api-client §2「可观测性契约」)。
* 后端不统计 token / 成本,因此这里也没有对应字段——不伪造指标。
*/
@Serializable
data class AdminMetricsDto(
val totalRequests: Long = 0,
val qps: Double = 0.0,
val avgLatencyMs: Double = 0.0,
val maxLatencyMs: Long = 0,
val perMethod: Map<String, Long> = emptyMap(),
val perPath: Map<String, Long> = emptyMap(),
val statusCounts: Map<String, Long> = emptyMap(),
val llmCalls: Long = 0,
val subagentEvents: Long = 0,
)
/**
* `GET /api/v1/admin/observability/trace?limit=N` 单个 span。
* `kind` / `status` 是开放集合,保留原始字符串(不建枚举)。
* `startedAt` / `endedAt` 为 RFC3339;`props` / `events` 为任意结构,原样透传。
*/
@Serializable
data class AdminTraceSpanDto(
val id: String,
val parentId: String? = null,
val name: String = "",
val kind: String = "",
val status: String = "",
val startedAt: String = "",
val endedAt: String? = null,
val durationNs: Long = 0,
val props: Map<String, JsonElement>? = null,
val events: List<AdminTraceEventDto>? = null,
)
/** `trace.events[]` 一项;`time` 为 RFC3339(纳秒精度)。 */
@Serializable
data class AdminTraceEventDto(
val time: String = "",
val type: String = "",
val props: Map<String, JsonElement>? = null,
)
/**
* 一条后端日志,**两个来源共用这一个形状**(api-client §2「可观测性契约」与 §3.12):
* `GET /api/v1/admin/observability/logs` 的 `data[]`,以及 `observability_log` 帧的
* `data.entries[]`。后端是同一个生产者(`gateway.AdminLogEntry`,`adminLogOf` 映射),
* 契约也明说帧「与 REST 同形」——所以这里只有一份 DTO,不另建一份等价形状。
*
* - [time] 是 **RFC3339(UTC,秒精度)**——与 `trace.startedAt` / `audit.when` 同源同形状,
* 而**不是** backthink 那种 Unix 毫秒([AdminBackthinkItemDto.at]),两者不可共用解析器。
* - [level] ∈ `debug|info|warn|error`,是**开放集合**:本层原样保留,未知等级既不丢弃也不归一。
* - [source] 是 logger 的来源标签,记录没带来源时是空串(不是 null)。
* - 条目**没有 id**:去重只能按这四个字段(见 `ui/admin/AdminOverviewMerge.kt`)。
*/
@Serializable
data class AdminLogEntryDto(
val time: String = "",
val level: String = "",
val source: String = "",
val msg: String = "",
)
/**
* 人格摘要(用户侧 `GET /api/v1/personas` 与 admin 侧 `GET /api/v1/admin/personas` 同形)。
* admin 侧多一个 [thinking];未配置该人格时**该键完全不出现**,故为 nullable。
*/
@Serializable
data class PersonaSummaryDto(
val id: String,
val name: String = "",
val character: String = "",
val isDefault: Boolean = false,
val mode: String = "",
val memoryNamespace: String = "",
val avatar: String? = null,
val chatBackground: ChatBackgroundDto? = null,
val mood: String? = null,
val thinking: PersonaThinkingDto? = null,
)
@Serializable
data class ChatBackgroundDto(
val landscape: String? = null,
val portrait: String? = null,
)
/**
* 人格 `thinking` 读侧形状(v0.43)。
* [showToUser] 是**三态**:`null` = 未配置(保持现行为),true/false = 显式选择;
* 不得把 null 拍平成 false。
*/
@Serializable
data class PersonaThinkingDto(
val budgetTokens: Int = 0,
val effort: String = "",
val showToUser: Boolean? = null,
val mode: String = "",
)
/** `GET /api/v1/admin/memories` 一条记忆;`scope` 为开放集合(`user|persona|project`)。 */
@Serializable
data class MemorySummaryDto(
val id: String,
val scope: String = "",
val text: String = "",
)
/**
* `GET /api/v1/admin/backthink` 一条后台思考记录。
* [at] 是 **Unix 毫秒**(与 trace 的 RFC3339 不同源,切勿共用解析)。
*/
@Serializable
data class AdminBackthinkItemDto(
val id: String,
val trigger: String = "",
val conclusion: String? = null,
val shouldNotify: Boolean = false,
val pushed: Boolean = false,
val at: Long = 0,
)
@@ -0,0 +1,85 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `POST /api/v1/chat` 请求体(契约 v0.56,api-client §1 行 3)。
*
* 刻意只带这一个端点要的字段——**不复用整条会话对象**:请求体越小,「WS 帧 4096 字节上限」
* 的反面(REST 请求体没有长度上限)就越不会被无关字段吃掉,抓包时也一眼能看出这一轮发了什么。
*
* 字段语义:
* - [text] / [image] **至少一项非空**,两项都空服务端回 `400 bad_request`;
* - [image] 是 base64 `data:` URI 或 http(s) URL,**解码后上限 5 MiB**
* (超限 `400 image_too_large`,文案指路 `POST /api/v1/upload`);
* - [clientMsgId] 是客户端自带的幂等键:同一会话内**同一个非空值只承认第一条**,
* 重复到达回 `200 {data:{duplicate:true,messageId,sessionId}}` 且不落库、不起回合;
* - [stream] 必须为 `true`(本端**不用** `stream:false`:它是同步的,服务端没有写超时,
* 客户端 60s 读超时会先超时、而回合还在跑——重发就是第二次对话)。
*
* ⚠️ 本项目 `ApiClientFactory.json` 用 kotlinx 默认的 `encodeDefaults = false`:
* 取值等于默认值的字段**整键省略**。因此**纯图片消息**(`text == ""`)线上不会带 `text` 键——
* 服务端 Go 侧零值同为空串,语义完全等价(已按此实现,见 [ChatDtoRealPayloadTest])。
*
* [documents] 同理:默认空列表 ⇒ **不带附件的请求线上与加入该字段之前逐字节相同**
* (服务端 `Documents []chatDocumentRef \`json:"documents,omitempty"\`` 零值也省略,
* 见 `DocumentDtoTest` 里拿字面量 JSON 对比的那条钉子)。
*/
@Serializable
data class ChatRequest(
val sessionId: String? = null,
val text: String = "",
val image: String? = null,
val deviceId: String? = null,
val mode: String? = null,
val stream: Boolean? = null,
val clientMsgId: String? = null,
/**
* 本回合附带的文档引用(契约 v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」),形状见 [DocumentRefDto]):**≤5 条**,
* 每条**只给服务端存储名**(`{"name":"<32hex>.<ext>"}`),**不收 URL**。
*
* 上行只发 `name`:其它字段服务端一律不采信并覆盖(字节嗅探是唯一的类型裁决)。
* 空列表 → 整键省略(`encodeDefaults = false`),旧请求逐字节不变。
*
* ⚠️ **带附件一律走 REST**:文档本体经 `POST /api/v1/documents` 上传,引用随
* `POST /api/v1/chat` 发出([top.yeij.cyrene.domain.chat.DocumentDelivery] 的判定);
* WS `chat` 帧的 4096 字节读上限仍在,且上行帧里没有承载附件的余地。
*/
val documents: List<DocumentRefDto> = emptyList(),
/**
* **请求把本条作为独立一路处理**(忙时并行规范 §2.2,REST 与 WS `chat` **同形**)。
*
* `null` = **整键省略**(`encodeDefaults = false`),与加入该字段之前**逐字节相同**:
* 用户把设置页那栏「她忙时发消息」选成「**并行**」**且会话正忙**时才给 `true`(判据是纯函数
* `parallelForSend`;另外两档、以及空闲时都不带这个键)。
* 服务端照准与否都不报错——额度满时落回排队并回 `202 {queued:true}`(伴一条
* `pending_ack{reason:"parallel_limit"}`),已被在飞路 steer 吃进时回 `200 {stream:true}`。
*/
val parallel: Boolean? = null,
)
/**
* `POST /api/v1/chat` 的**受理回执**(`data` 载荷)。v0.56 起响应面不再是「任何情况都 200」,
* 四种判定各有形状,因此这里**全部字段可空**、按组合判读,绝不假设某字段恒在:
*
* | 判定 | HTTP | 载荷 |
* |---|---|---|
* | 已起回合 | 200 | `{stream:true}` |
* | 正忙已续接排队 | 202 | `{stream:true,queued:true,queueDepth:n}` |
* | 幂等键重复 | 200 | `{duplicate:true,messageId,sessionId}` |
*
* 其余三种(`409 chat_busy` / `500 chat_unavailable` / `503 chat_dispatch_failed`)是
* `{error:{code,message}}` 信封,由 [top.yeij.cyrene.data.repository.ChatRepository] 走错误路径分类。
*
* 不开枚举:服务端还有别的判定会在将来加进来,未知组合一律按「已受理但形状不认识」处理,
* 不因为不认识一个布尔而把整轮当失败。
*/
@Serializable
data class ChatAckDto(
val stream: Boolean? = null,
val queued: Boolean? = null,
val queueDepth: Int? = null,
val duplicate: Boolean? = null,
val messageId: String? = null,
val sessionId: String? = null,
)
@@ -0,0 +1,99 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* 文档 DTO —— 「文件进对话」的**唯一形状落点**(契约 **v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」)**)。
*
* ## 形状从哪来
* 写这份代码时 `Cyrene_v2/Docs/api-client.md` 的最新契约版本仍是 **v0.58**(里面**没有**
* `documents` 任何形状)。因此形状依据是
* `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 **回执五**(2026-10-04 深夜,
* 标题即「一条契约形状修正」),并用后端仓库已落库的三个切片逐字段核对过:
* `407c934`(S1 存储端点)/ `a4789ec`(S3-B 网关入口)/ `5b2b385`(S3.5 文档多值化)。
*
* **形状要变只改本文件。** 上行(`documents:[{name}]`)与下行/历史(`documents:[{…}]`)
* 是**同一个元素类型**([DocumentRefDto]):服务端两条入口共用一处解析,这里也共用一个类。
*
* ## 回执五 §二「请以此为准」的三条
* 1. **单对象 `document` 已作废**,一律**数组** `documents`(≤5)——修的是「一回合 5 个附件,
* 历史与后续上下文里只留第一个」这个实测确认的缺陷;
* 2. 上行元素**只给服务端存储名 `name`,绝不收 URL**(不收 URL 是为了不重开 `F3` 那种
* 「服务端代取 URL」的 SSRF 面);谎报的 `mime`/`url`/`size` 会被服务端覆盖,
* **类型裁决只有字节嗅探一条**(客户端声明的 `Content-Type` 与文件名只作参考、可被否决,
* 所以一个 `data.json` 可能被回成 `text/plain`);
* 3. 下行/历史元素只回**引用与统计,绝不回正文**(吸取 `G3`「base64 原样落库、每页历史重传」
* 的教训)。`status:"unreadable"` 才是「读不到这个文件」的可观测信号,**不是** `truncated`
* (`truncated` 只说明注入正文被预算截断,正文照样进得了上下文)。
* **契约 v0.63(后端 `902b6ee`)在此之上加了可选 [DocumentRefDto.reason]**:读不到时随
* `status:"unreadable"` 一起下发的**服务端原话**,客户端原样显示(详见该字段的 KDoc)。
*
* ## 客户端必须两种都吃的一件事
* 「0 个附件」这一形状在后端**尚未定死**(回执五原文:期望「不出现或空数组」,S3.5 回报里定死)。
* 因此本包的解析一律按**缺失键 → 空列表**、**空数组 → 空列表**两条都收:
* 详见 [HistoryMessageDto.documents] 与 [ChatRequest.documents] 的默认值。
*/
@Serializable
data class DocumentUploadResponse(
/** 服务端存储名:32 位小写十六进制 + 白名单扩展名。**上行引用时唯一能用的字段**。 */
val name: String = "",
/**
* **相对**同源路径(`/api/v1/documents/<name>`),与图片上传的**绝对** URL 不同:
* 读侧要 Bearer `owner|admin`,所以它不是「贴进 `<img>` 就能取」的能力 URL,绝对化没有意义。
* 客户端真正要用的是 [name]。
*/
val url: String = "",
/** 服务端**字节嗅探**所得的基类型(不是客户端声明的),如 `text/plain`。 */
val mime: String = "",
val size: Long = 0,
/** 内容寻址的哈希(64 位小写十六进制):落盘名就是它的前 32 位 + 扩展名。 */
val sha256: String = "",
/**
* `true` = 同一份字节(同扩展名)之前已经上传过:服务端回 `200` + **同一个** `name`,
* **必须当成功处理,不是错误**。`false` = 首次上传(`201`)。
*/
val deduped: Boolean = false,
)
/**
* 一条文档引用。**上行只发 [name]**(其余字段服务端不采信、会覆盖),
* 下行/历史则带上全部统计字段。
*
* 字段可出现性(后端 `history.DocumentRef` 的 `omitempty` 口径,逐字段核对过):
* `name` / `mime` / `size` **恒在**;`chars` / `truncated` / `status` / `reason` 为 Go 的 `omitempty`
* —— 零值(0 / false / "" / nil)时**整键省略**,所以取值一律给默认值,解码不会因缺键失败。
*/
@Serializable
data class DocumentRefDto(
/** 服务端存储名(`<32hex>.<ext>`)。客户端**本地**的原始文件名不在这里,也不该在这里。 */
val name: String = "",
val mime: String = "",
val size: Long = 0,
/** 本次**注入上下文**的字符数(被预算截断者填截断后的值);`null` = 服务端未报告。 */
val chars: Int? = null,
/**
* 注入正文是否被预算截断:`true` 只说明进上下文的那段被截短了,**不是**「读不到」——
* 读不到看 [status](`unreadable`)。两个信号刻意分开,别互相顶替。
*/
val truncated: Boolean = false,
/**
* 抽取状态:`extracted` / `listed` / `unreadable`(见
* [top.yeij.cyrene.domain.chat.DocumentStatus])。**开放集合,不用 enum**:
* 未知值**原样保留**(不丢弃、不报错、不占位符),由客户端决定如何显示。
*/
val status: String = "",
/**
* **读不到的原因**(契约 v0.63 新增,后端 `902b6ee`):`status == "unreadable"` 时下发的
* 那句**服务端原话**;`status == "extracted"` 时**整键缺席**,因此是可选字段。
*
* 两句纪律:
* 1. **原样显示**。[reason] 与后端**注入模型清单里的那 10 条是同一份字符串**(逐字相同,
* 最长的一条 40+ 字),客户端**不替服务端改写、不截断、不归纳**成自己的话——
* 本端能编的只有像 [top.yeij.cyrene.domain.chat.DocumentPreviewGate.refusalMessage]
* 那样「按类型泛泛说一句」,比这里的原因粗得多;
* 2. **绝不做整句相等判断**。分类若真的需要分支,只允许用**稳定子串**(如「需要 OCR」)——
* 整句比对会在后端改一个字时静默失效,且失败的样子是「显示成兜底那句话」,
* 看起来完全正常。
*/
val reason: String? = null,
)
@@ -0,0 +1,59 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
@Serializable
data class LoginRequest(
val username: String,
val password: String,
)
@Serializable
data class LoginResponse(
val token: String,
/** **Unix 秒**(不是毫秒):`time.Now().Add(24h).Unix()`。 */
val expiresAt: Long,
val role: String,
/** B2 的长期凭据;服务端没接线 refresh 存储时整个键缺失。 */
val refreshToken: String? = null,
)
/** `POST /api/v1/auth/refresh` 的请求体(公开接口,凭据就在请求体里)。 */
@Serializable
data class RefreshRequest(
val refreshToken: String,
)
/**
* `POST /api/v1/auth/refresh` 的成功载荷。
*
* - [refreshToken] 在默认(一次性轮换)模式下是**新的**凭据,旧的同一笔交易里作废;
* `AUTH_REFRESH_ROTATE_DISABLED=true` 时服务端不下发,此时旧凭据继续有效——所以这里必须可空,
* 调用方也要在缺失时保留原值。
* - [role] 官方文档漏写,但实现一定会返回;给默认值以免文档形状的响应解不动。
*/
@Serializable
data class RefreshResponse(
val token: String,
/** **Unix 秒**。 */
val expiresAt: Long,
val role: String = "",
val refreshToken: String? = null,
)
/** `POST /api/v1/auth/logout` 的请求体。 */
@Serializable
data class LogoutRequest(
val refreshToken: String,
)
/**
* `/auth/logout` 的响应体。文档与实现互相矛盾(`{"ok":true,"revokedCount":n}` / `{"revokedCount":n}`
* 对上真实信封里的 `{"revoked":bool}`),而本端**只看 HTTP 2xx**——所以每个字段都给默认值,
* 三种形状都能宽容解出,解不动也不影响登出。
*/
@Serializable
data class LogoutResponse(
val revoked: Boolean = false,
val revokedCount: Int = 0,
)
@@ -0,0 +1,88 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `GET /api/v1/sessions/{id}/messages` 单条消息(api-reference §3.4)。
* 服务端按 `createdAt` 倒序返回,渲染前需 reverse / merge。
*
* 它同时是 WS 握手 `history` 回放与 `message_persisted` 的载荷消息类型
* (服务端三处共用同一个 `historyMessage`),所以 [documents] 的解析一处覆盖三条路。
*/
@Serializable
data class HistoryMessageDto(
val id: String,
val sessionId: String,
val role: String,
val content: String = "",
val mode: String? = null,
val mood: String? = null,
val image: String? = null,
val thinking: String? = null,
val platform: String? = null,
val deviceId: String? = null,
val senderName: String? = null,
val adapterId: String? = null,
/**
* 客户端幂等键(契约 v0.56 §3.13,`omitempty`):带 `clientMsgId` 发出的 user 消息会被
* 服务端**原样回填**,`GET /messages` / WS `history` / `message_persisted` 都能看到。
* 客户端据此把本地乐观气泡对齐到服务端真相——取代「靠文本相等」的脆弱匹配。
* 空 = 这条消息没有键(老客户端发的、IM 镜像进来的、v0.56 之前的历史行)。
*/
val clientMsgId: String? = null,
/**
* 这条消息携带的文档引用与抽取统计(契约 v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」);形状见 [DocumentRefDto])。
*
* 默认 [emptyList] 同时吃掉后端**尚未定死**的两种「0 个附件」形状:
* **键缺失 → 空列表**(后端 `historyMessage.Documents` 带 `omitempty`,空/缺失即整键省略——
* 这一点已对着 `5b2b385` 的实现与 `history_document_test.go` 的断言核对过)、
* **空数组 → 空列表**(S3.5 之前写下的行、以及将来若改回恒下发 `[]`)。
*
* 只回引用与统计、**绝不回正文**;未知 `status` **原样保留**(开放集合,见 [DocumentRefDto])。
* WS `message_persisted` 与握手 `history` 回放用的是同一个 DTO,因此这一处解析两条路都覆盖。
*/
val documents: List<DocumentRefDto> = emptyList(),
val createdAt: Long,
/**
* 产出 / 吃进这条消息的那一路的回合标识(忙时并行规范 §2.7,**纯增量**)。
*
* 语义按角色分(规范 §2.7 明写):
* - **user 行** = 吃进它 / 起它的那一路的 `turnId`(被 steer 吃进的行 = 吃进它的那一轮);
* - **assistant 行** = 产出它的那一路的 `turnId`。
*
* 于是重连后能把「回复 ↔ 提问」精确配对,两条并行回复也天然分属两个 `turnId`(分桶有据),
* **不必靠时间戳猜**(历史顺序的真源是 `(created_at, rowid)` = **完成先后**,
* 不承诺与提问相邻)。
*
* **旧行整键省略**(不是空串,规范 §2.7 末条)⇒ [null] 时客户端回落既有的「按顺序渲染」,
* 与加入这一列之前逐字相同。四处同源:`message_persisted` / `GET /messages` / 握手 `history`
* 回放 / `channel_persisted`(本端不消费最后一条)。
*/
val turnId: String? = null,
)
/**
* `PATCH /api/v1/messages/{id}` 的请求体(D3,契约 §1 行 6a)。
*
* **只有 `content` 一个键**:服务端按它改 `content`,role / 时间戳 / 平台 / 图片 / 附件一律保持原值,
* 所以这里**不能**多带任何字段(多带也不会被采纳,只会让后来的读者以为客户端能改这些)。
*
* 服务端口径:非空、≤ **4000 rune**;空 / 纯空白 / 超长回 `400 bad_request`。
* [content] 要发**用户原样输入的内容**(含首尾空白)——服务端存的就是这个字符串,
* 客户端替它 trim 一次等于悄悄改掉用户的消息。
*/
@Serializable
data class UpdateMessageRequest(
val content: String,
)
/**
* `DELETE /api/v1/messages/{id}` → `200 {"data":{"deleted":"<消息 id>"}}`(契约 §1 行 6b)。
*
* 与 [DeleteSessionResponse] 逐字同形,但**刻意分开**:这两个 id 的命名空间和「删掉了什么」
* 是两件事,共用一个类型会让调用方以为删的是会话。
*/
@Serializable
data class DeleteMessageResponse(
val deleted: String,
)
@@ -0,0 +1,29 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `GET /api/v1/meta/enums` 表内一项(api-client §1.1)。
* [id] 是**唯一可比对**的值(契约 id 为小写、大小写敏感);[label] 只是显示默认值,
* 自带 i18n 的客户端可以覆盖。
*/
@Serializable
data class EnumItemDto(
val id: String = "",
val label: String = "",
)
/**
* `platform` / `mood` / `mode` 的共享枚举权威表——客户端**不必硬编码**即可拿到稳定映射。
*
* 字段全部带默认值:只下发部分表(或 `null`)时仍能解码,缺失的那一类按「表内无此项」
* 走原样回落,而不是让整次解码失败。
* [unknown] 是机器可读的未知值回落约定,当前恒为 `"display-raw"`(原样显示,见 §1.1 红线)。
*/
@Serializable
data class EnumTableDto(
val platforms: List<EnumItemDto> = emptyList(),
val moods: List<EnumItemDto> = emptyList(),
val modes: List<EnumItemDto> = emptyList(),
val unknown: String = "",
)
@@ -0,0 +1,26 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `GET /api/v1/personas`(**用户侧**角色目录,api-client §1 行 9)的一项。
*
* 与 admin 侧的 [PersonaSummaryDto] 同源同形,但**用户侧不下发 admin 专属的 `thinking`**
* ——这正是不能拿 admin 端点当用户侧端点用的原因之一(用户侧不暴露模型调用旋钮)。
*
* [mode] / [mood] 都是**开放集合**:未知取值原样保留(不丢弃、不报错、不占位符),渲染时才映射。
* `mood` 为空串是「该角色暂无被跟踪的情绪」,不是异常,也不伪造默认值。
* [chatBackground] 与 admin 侧共用 [ChatBackgroundDto](同一个后端结构)。
*/
@Serializable
data class UserPersonaDto(
val id: String,
val name: String = "",
val character: String = "",
val isDefault: Boolean = false,
val mode: String = "",
val memoryNamespace: String = "",
val avatar: String? = null,
val chatBackground: ChatBackgroundDto? = null,
val mood: String? = null,
)
@@ -0,0 +1,146 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `GET /api/v1/sessions` 持久化会话摘要(api-reference §3.2 A)。
*
* 主会话 `id` 恒为 `"web"`、`type = "main"`,但**不恒在首位**:
* 服务端排序是 `pinned DESC, type = 'main' DESC, created_at ASC`,
* 被置顶的次级会话合法地排在主会话前面。
*
* [title] 在会话还没有任何用户消息时**整个键缺席**(自动命名只在那时写入)——
* 这是正常状态,不是异常;渲染时用客户端兜底标题。
*/
@Serializable
data class SessionSummaryDto(
val id: String,
val type: String,
val title: String? = null,
val createdAt: Long,
val pinned: Boolean = false,
/** 会话还没有消息时**整个键缺席**。 */
val lastMessage: SessionLastMessageDto? = null,
/**
* 未读条数(D4,契约 v0.37 起**恒下发**):`createdAt >` 该会话已读游标的消息条数,
* `0` = 无未读。
*
* 有默认值是为了**旧快照仍能解码**:[top.yeij.cyrene.core.prefs.SessionPreferences]
* 把整个列表原样持久化成 JSON,非默认值会让升级后第一次启动的整份快照解码失败,
* 临时失效(列表先空、等 REST 回来)——那正是本应用最不该出现的表现。
* 默认 `0` 的口径也是安全的:它只会让角标少显示一会儿,`Get /sessions` 一到就被纠正。
*/
val unreadCount: Int = 0,
/**
* 该会话绑定的角色 id(v0.42,可选);**空/缺省 = 继承全局默认角色**。
*
* 只读字段:会话角色**只在创建时**写入,之后不可变(`PATCH` 一旦收到 `personaId` 一律 400),
* 所以它不可能被本端改。默认值是为了旧快照仍能解码(同 [unreadCount] 的理由)。
*/
val personaId: String? = null,
) {
val isMain: Boolean
get() = type == TYPE_MAIN || id == MAIN_SESSION_ID
companion object {
const val MAIN_SESSION_ID = "web"
const val TYPE_MAIN = "main"
const val TYPE_SUB = "sub"
}
}
/**
* `POST /api/v1/sessions` → `201 {"data":{"sessionId":"web:<uuid>"}}`。
*
* [personaId] 只在**创建时显式绑了角色**才回显(v0.42);省略时响应形状与 v0.41 完全一致,
* 所以它是可空字段,不能拿它的有无去判断别的事。
*/
@Serializable
data class CreateSessionResponse(
val sessionId: String,
val personaId: String? = null,
)
/**
* `POST /api/v1/sessions` 的请求体(v0.42:创建时绑定角色)。
*
* 服务端把「省略 `personaId`」与「`personaId` 为空」当成同一件事:**不绑**
* (`persona_id=''`,继承全局默认角色)。`personaId` 为**未知角色**时回 `400 bad_request`。
*
* 生产路径上 null 不会走到这里——仓库对 null 走的是**完全不带 body** 的重载
* (与本端支持选角之前的线上请求逐字节相同)。这里保留可空字段,是为了让
* 「null 序列化后整个键缺席」这件事有一处**可被测试钉住**的表达
* (生产 `Json` 的 `explicitNulls = false`)。
*/
@Serializable
data class CreateSessionRequest(
val personaId: String? = null,
)
/**
* 会话最后一条消息的摘要(服务端 `historyLastMessage`)。
*
* [content] 已被服务端折叠空白、截断到 60 个字符 + `…`,但**保留原始的 `<p>` / `<action>` 标记**:
* 直接拿来显示会露出标签,渲染前必须剥掉。
*/
@Serializable
data class SessionLastMessageDto(
val role: String,
val content: String = "",
val createdAt: Long,
)
/**
* `PATCH /api/v1/sessions/{id}` 的请求体。
*
* 服务端逐字段用指针判断:**字段缺席即保留原值**,没有「省略即清空」的陷阱;
* 因此这里也必须可空,并且不能被序列化成显式 `null`——
* 生产 `Json` 的 `explicitNulls = false` 正是保证这一点的地方。
*
* **绝不能加 `personaId`**:服务端对任何值的 `personaId` 都先判 400。
*/
@Serializable
data class UpdateSessionRequest(
val title: String? = null,
val pinned: Boolean? = null,
)
/**
* `PATCH /api/v1/sessions/{id}` 的响应是**局部**形状(`{id, title?}` 和/或 `{id, pinned?}`),
* 不是完整的会话行——绝不能拿它当列表行用。
*/
@Serializable
data class UpdateSessionResponse(
val id: String = "",
val title: String? = null,
val pinned: Boolean? = null,
)
/** `DELETE /api/v1/sessions/{id}` → `200 {"data":{"deleted":"<id>"}}`。 */
@Serializable
data class DeleteSessionResponse(
val deleted: String,
)
/**
* `POST /api/v1/sessions/{id}/read`(D4)的请求体:可选地把已读游标推进到 `before`。
*
* - **键缺席 = 「现在」**(「我看到的都算已读」)。生产 `Json` 的 `explicitNulls = false`
* 保证 `before = null` 时整个键缺席,序列化出来是 `{}`——服务端明确接受空对象
* (`api-reference §3.6b`:**空 body / `{}` = 现在**)。
* - 服务端对 `0` / 负数 / 非数字一律 `400`,所以**绝不能**把 `0` 发上去:调用方拿到
* 非正的「没有时间戳」时必须折成缺席(null),而不是原样透传。
* - 游标**只前进**(SQL `MAX(read_at, before)`),重复 / 乱序 / 重试都幂等安全。
*/
@Serializable
data class ReadSessionRequest(
val before: Long? = null,
)
/** `POST /api/v1/sessions/{id}/read` → `200 {"data":{"sessionId":"<id>","readAt":<毫秒>}}`。 */
@Serializable
data class ReadSessionResponse(
val sessionId: String = "",
/** 推进**后**的游标(毫秒 Unix 时间戳,与该会话消息的 `createdAt` 同单位)。 */
val readAt: Long = 0L,
)
@@ -0,0 +1,31 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `POST /api/v1/upload` 的成功载荷(api-client §1 行 13,v0.24 起的 B1 端点)。
*
* 线上形状(`internal/services/gateway/upload.go` 的 `uploadResponse`,字段名逐字如此):
* ```
* 201 {"data":{"url":"http://192.168.1.10:10942/api/v1/files/<32hex>.png","size":1234,"mime":"image/png","name":"<32hex>.png"}}
* ```
*
* 语义要点:
* - [url] 是**绝对同源地址**(`scheme://host/api/v1/files/<name>`),**可直接作 `chat.image`**:
* 视觉层只把 `http(s)://` 开头当 URL,裸路径会被当成 base64 —— 所以这个字段必须按 URL 校验,
* 不能原样塞回去(见 `UploadRepository`);
* - [name] 是服务端生成的 **32 位小写 hex + 白名单扩展名**,客户端原始文件名**被丢弃**(不可信输入);
* - [size] 字节数与 [mime] 都是**服务端嗅探/统计的结果**,不是客户端声明的——客户端传的
* `Content-Type` 一律不被采信(`sniffImageMIME`)。
*
* 全部字段带默认值:解码器是生产同一份 `Json`(`ignoreUnknownKeys` / `coerceInputValues`),
* 将来服务端加键不会让整次上传变成解析失败。**缺 `url`(或 `url` 不是绝对 http(s))仍要判失败**——
* 那是「上传成功但拿不到可用地址」,由仓库层拦,绝不把空串/路径当 URL 发出去。
*/
@Serializable
data class UploadResponse(
val url: String = "",
val size: Long = 0,
val mime: String = "",
val name: String = "",
)
@@ -0,0 +1,133 @@
package top.yeij.cyrene.data.image
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Base64
import top.yeij.cyrene.domain.chat.ChatImageBudget
import java.io.ByteArrayOutputStream
/**
* 一次重编码的产物。
*
* [dataUri] 与 [bytes] 是**同一张图**的两种形态:内联(base64 `data:` URI)用前者,
* 上传(`POST /api/v1/upload`)用后者。上传时**不能**从 [dataUri] 反解一遍——那既多一次
* 解码与分配,也让「上传的字节」和「内联的字节」有漂移的余地。
*/
class PreparedImage(
val dataUri: String,
val bytes: ByteArray,
val mime: String,
)
/**
* 把用户选的本地图片压成可以内联发送的 `data:` URI(**发送前的必经一步**)。
*
* 为什么要重编码而不是直接读原始字节:服务端把 `image` 字符串**原样落库**,
* 每一页历史与每一次 WS `history` 回放都要重传它(协同文档 G3)。所以这里
* 无论用户选了什么,出口都保证 ≤ [ChatImageBudget.MAX_DECODED_BYTES]:
*
* 1. 先按边界解码拿原尺寸 → 等比缩到最长边 [ChatImageBudget.MAX_DIMENSION_PX];
* 2. 编码(有 alpha 用 PNG,否则用 JPEG);
* 3. 仍超预算 → **尺寸减半重来**(最多 [MAX_ATTEMPTS] 档);
* 4. 出口再按服务端的**字符串长度**上限复核一次(4/3 膨胀 + 前缀余量)。
*
* 任何一步失败(读不到 / 解不出 / 编不出 / 压不到预算内)都返回 `null`——由调用方回滚并报错,
* **绝不**退回「原图 base64」:那既会撞服务端 5 MiB 硬顶,也会把历史背成几 MB。
*
* 产物同时给出 `data:` URI 与**同一份**字节:**内联还是上传由调用方按体积定**
* (见 `ChatImageDelivery`)——本对象只管「编到预算内」,通道选择是另一件事,而且它需要
* 编完之后的真实字节数才谈得上。
*
* 预算数学在 [ChatImageBudget](纯函数,可单测);本对象只剩「必须真机才有」的解码与编码。
*/
object ChatImagePreparer {
private const val JPEG_QUALITY = 80
/** 缩不动就减半重来的次数上限(1 MiB 预算下 1280px 起步一般 1–2 次就够)。 */
private const val MAX_ATTEMPTS = 4
/**
* @return 可内联发送的 `data:` URI 与可上传的同一份字节;失败返回 `null`。
* 必须在后台线程调用(解码 + 缩放 + 编码都是重活)。
*/
fun prepare(context: Context, uri: Uri): PreparedImage? {
val raw = readBytes(context, uri) ?: return null
val decoded = decodeScaled(raw) ?: return null
return try {
encodeWithinBudget(decoded)
} finally {
decoded.recycle()
}
}
private fun readBytes(context: Context, uri: Uri): ByteArray? =
runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
/** 按**目标尺寸**两步解码:先读边界算 `inSampleSize`(省内存),再精确缩放到目标。 */
private fun decodeScaled(raw: ByteArray): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(raw, 0, raw.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val target = ChatImageBudget.scaledSize(bounds.outWidth, bounds.outHeight)
val options = BitmapFactory.Options().apply {
inSampleSize = sampleSizeFor(bounds.outWidth, bounds.outHeight, target.width, target.height)
}
val sampled = runCatching { BitmapFactory.decodeByteArray(raw, 0, raw.size, options) }.getOrNull()
?: return null
if (sampled.width == target.width && sampled.height == target.height) return sampled
val scaled = runCatching {
Bitmap.createScaledBitmap(sampled, target.width, target.height, true)
}.getOrNull() ?: return null
if (scaled !== sampled) sampled.recycle()
return scaled
}
/** 在不小于目标尺寸的前提下,取最大的 2 的幂作为采样步长。 */
private fun sampleSizeFor(width: Int, height: Int, targetWidth: Int, targetHeight: Int): Int {
var sample = 1
while (width / (sample * 2) >= targetWidth && height / (sample * 2) >= targetHeight) {
sample *= 2
}
return sample
}
/** 逐档降尺寸,直到编码后字节数落进预算;都落不进返回 `null`。 */
private fun encodeWithinBudget(source: Bitmap): PreparedImage? {
var width = source.width
var height = source.height
repeat(MAX_ATTEMPTS) { attempt ->
val frame = if (attempt == 0) source else scale(source, width, height) ?: return null
// 有 alpha 的图走 JPEG 会把透明区压成黑块,所以这类图保持 PNG(无损、体积更大)。
val mime = if (frame.hasAlpha()) MIME_PNG else MIME_JPEG
val bytes = compress(frame, mime)
if (frame !== source) frame.recycle()
if (ChatImageBudget.withinTarget(bytes.size)) {
val dataUri = ChatImageBudget.dataUri(mime, Base64.encodeToString(bytes, Base64.NO_WRAP))
if (!ChatImageBudget.withinServerLimit(dataUri.length)) return null
return PreparedImage(dataUri = dataUri, bytes = bytes, mime = mime)
}
width = (width / 2).coerceAtLeast(1)
height = (height / 2).coerceAtLeast(1)
}
return null
}
private fun scale(source: Bitmap, width: Int, height: Int): Bitmap? =
runCatching { Bitmap.createScaledBitmap(source, width, height, true) }.getOrNull()
private fun compress(bitmap: Bitmap, mime: String): ByteArray {
val format = if (mime == MIME_PNG) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG
val out = ByteArrayOutputStream()
bitmap.compress(format, JPEG_QUALITY, out)
return out.toByteArray()
}
private const val MIME_JPEG = "image/jpeg"
private const val MIME_PNG = "image/png"
}
@@ -0,0 +1,72 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.api.AdminApi
import top.yeij.cyrene.data.api.apiCall
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 管理只读监控取数层(api-client §2)。
* 权限判断不在这里:仓库只管取数,403 由调用方按 [top.yeij.cyrene.domain.admin.AdminFailure] 收敛。
*/
class AdminRepository(
private val api: AdminApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun metrics(): Result<AdminMetricsDto> = withContext(dispatcher) {
apiCall { api.metrics() }
}
/** `GET /observability/trace`;`limit` 与服务端对齐,钳制在 [1, [MAX_TRACE_LIMIT]]。 */
suspend fun trace(limit: Int = DEFAULT_TRACE_LIMIT): Result<List<AdminTraceSpanDto>> =
withContext(dispatcher) {
apiCall { api.trace(limit.coerceIn(1, MAX_TRACE_LIMIT)) }
}
/**
* `GET /observability/logs`(后端日志 tail)。`limit` 钳制在 [1, [MAX_LOG_LIMIT]] 后
* **显式**发出去(服务端对 ≤0 / 非法值有一条「回落缺省」的兜底,这里不依赖它):
* 这样「向服务端要多少条」与客户端的保留窗口(`LOG_WINDOW`)永远同源。
*/
suspend fun logs(limit: Int = DEFAULT_LOG_LIMIT): Result<List<AdminLogEntryDto>> =
withContext(dispatcher) {
apiCall { api.logs(limit.coerceIn(1, MAX_LOG_LIMIT)) }
}
suspend fun personas(): Result<List<PersonaSummaryDto>> = withContext(dispatcher) {
apiCall { api.personas() }
}
suspend fun memories(): Result<List<MemorySummaryDto>> = withContext(dispatcher) {
apiCall { api.memories() }
}
suspend fun backthink(): Result<List<AdminBackthinkItemDto>> = withContext(dispatcher) {
apiCall { api.backthink() }
}
companion object {
/** trace 拉取条数:够看近期活动,又不会把一屏塞满。 */
const val DEFAULT_TRACE_LIMIT = 100
/** 服务端 `?limit=` 上限。 */
const val MAX_TRACE_LIMIT = 500
/**
* 日志拉取条数:契约的缺省值,也就是「最近日志」区块的保留窗口
* (`ui/admin/AdminOverviewMerge.kt` 的 `LOG_WINDOW`)与推送帧的单帧上限。
*/
const val DEFAULT_LOG_LIMIT = 200
/** 服务端 logs `?limit=` 上限(契约 §2)。 */
const val MAX_LOG_LIMIT = 1000
}
}
@@ -0,0 +1,102 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiErrorMapper
import top.yeij.cyrene.data.api.ChatApi
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.ChatAckDto
import top.yeij.cyrene.data.api.dto.ChatRequest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* `POST /api/v1/chat` 的受理判定(契约 v0.56)。
*
* 它是一个**密封结果**而不是 `Result.failure`:v0.56 起「会话正忙」与「排队已满」不再是异常,
* 而是编排器的正常回执(修复前 handler 提前 `return 200`,这两种状态**永远不可能出现**)。
* 把它们和网络错误混在同一条失败流里,调用方就只能靠字符串猜——这正是本类型要消除的东西。
*/
sealed interface ChatAck {
/** `200 {stream:true}`:回合已起,内容帧走**已连接的 WS**。 */
data object Accepted : ChatAck
/** `202 {stream:true,queued:true,queueDepth:n}`:会话正忙,这条已续接排队(**已落库**)。 */
data class Queued(val queueDepth: Int) : ChatAck
/** `200 {duplicate:true,messageId,sessionId}`:幂等键重复,服务端**没有**再落库 / 再起回合。 */
data class Duplicate(val messageId: String?, val sessionId: String?) : ChatAck
/** `409 {error:{code:"chat_busy"}}`:待处理队列已满,这条**没有被受理、没有落库**。 */
data object QueueFull : ChatAck
/**
* `500 chat_unavailable` / `503 chat_dispatch_failed`:服务端接不住这一轮。
* [code] 保留原始错误码——两者的区别对排查有用,对用户文案无差别。
*/
data class Unavailable(val code: String, val message: String) : ChatAck
/** 其余明确的拒绝(`400 bad_request` / `400 image_too_large` / 未知码)。 */
data class Rejected(val code: String, val message: String) : ChatAck
}
/**
* 发送一条消息(REST 通道)。
*
* 与别的仓库不同的一点:**非 2xx 里有一半是业务态而不是失败**(见 [ChatAck]),
* 所以这里刻意**不使用** [top.yeij.cyrene.data.api.apiCall]——它把 4xx/5xx 一律折进
* `Result.failure`、且只回 `data` 丢掉状态码,于是 `202` 与 `409` 的差别在折进去的那一刻
* 就没了。信封解析与错误映射仍走 [ApiErrorMapper],口径与其它端点一致。
*
* `Result.failure` 只留给**真正发不出去**的情况:网络 / 超时 / 解析不出来 / 401。
*/
class ChatRepository(
private val api: ChatApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun send(request: ChatRequest): Result<ChatAck> = withContext(dispatcher) {
try {
val response = api.sendChat(request)
val data = response.body()?.data
when {
response.isSuccessful && data != null -> Result.success(ackOf(data))
// 2xx 却没有 data:与 apiCall 同一口径,判解析失败而不是「已受理」。
response.isSuccessful -> Result.failure(parseFailure())
else -> {
val error = ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string())
val ack = serviceAck(error)
if (ack != null) Result.success(ack) else Result.failure(CyreneException(error))
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
}
}
private fun ackOf(data: ChatAckDto): ChatAck = when {
data.duplicate == true -> ChatAck.Duplicate(data.messageId, data.sessionId)
data.queued == true -> ChatAck.Queued(data.queueDepth ?: 0)
// `{stream:true}` 以及将来新增的未知组合:受理了、形状不认识 → 按已受理处理。
else -> ChatAck.Accepted
}
/** 非 2xx 里属于**编排器真实回执**的部分;返回 null = 真失败(401 或未知服务端错误)。 */
private fun serviceAck(error: CyreneError): ChatAck? {
if (error is CyreneError.Unauthorized) return null
val code = (error as? CyreneError.Server)?.code.orEmpty()
val message = error.userMessage
return when (code) {
"chat_busy" -> ChatAck.QueueFull
"chat_unavailable", "chat_dispatch_failed" -> ChatAck.Unavailable(code, message)
else -> ChatAck.Rejected(code, message)
}
}
private fun parseFailure() =
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse))
}
@@ -0,0 +1,380 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiErrorMapper
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.domain.chat.DocumentAttachments
import top.yeij.cyrene.domain.chat.DocumentSource
import top.yeij.cyrene.domain.chat.ImageDataUri
import top.yeij.cyrene.domain.chat.ImageFormats
import top.yeij.cyrene.domain.chat.ImageNames
import top.yeij.cyrene.domain.chat.ImageSource
import top.yeij.cyrene.domain.chat.readBounded
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
/** 待发附件的本地读取失败(授权失效 / 文件已被删 / provider 报错)。 */
class LocalDocumentUnavailableException : Exception("local document unreadable")
/** 待发附件超过本端能处理的体积上限(服务端另有 `413 document_too_large`)。 */
class LocalDocumentTooLargeException : Exception("local document too large")
/** 缓存目录写不进去(连预览都做不了——字节拿到了也没地方交给别的 App)。 */
class DocumentCacheUnavailableException : Exception("document cache unavailable")
/** 服务端返回的图片字节超过本端一次性读取的上限(见 [ImageCacheStore] 那把尺子)。 */
class ImageTooLargeException : Exception("image too large")
/**
* `cacheDir/documents/` 的路径推导、命中判定与原子落盘(纯逻辑 + 文件操作,可单测)。
*
* ## 为什么命中就等于「永远有效」
* 服务端落盘名是**内容寻址**的(`sha256(bytes)[:32] + ext`,契约 v0.59 §1.2 ①),且读侧响应带
* `Cache-Control: private, max-age=86400, immutable`(§1.2 ②)。名字由内容决定 ⇒ **同一个
* `name` 的字节永不改变**。所以这里不做任何过期 / 校验 / 大小比对:文件在且非空就是命中。
* 反过来说,服务端就算把源文件删了,本地这份也仍是当初的那一份内容(这正是缓存的意义)。
*
* ## 为什么必须原子落盘
* 下载中途失败(断网 / 进程被杀)会留下一个**截断的文件**。如果它落在正式名下,下一次打开
* 就会「命中」并把这个半截文件当成完整内容显示——**静默的错误内容比一次失败糟得多**。
* 因此先写 `.part` 临时文件,成功了再改名;任何一步失败都把临时文件删掉。
*/
object DocumentCache {
/** FileProvider(见 `AndroidManifest.xml` 与 `res/xml/file_paths.xml`)暴露的那一层目录名。 */
const val DIRECTORY_NAME: String = "documents"
/** 缓存目录:`<cacheDir>/documents`。 */
fun directory(cacheDir: File): File = File(cacheDir, DIRECTORY_NAME)
/**
* 某个名字在缓存里的路径;名字**不安全**时回 `null`。
*
* 缓存目录是唯一被 FileProvider 暴露出去的根,所以这里对名字的容忍度取最小:单段、
* 非空、不是 `.` / `..`、不含分隔符与 NUL。服务端形态(`<32hex>.<ext>`)与
* [localName] 生成的形态都天然满足;不满足的名字一律拒绝,绝不用它拼出一条能爬出
* `documents/` 的路径。
*/
fun fileFor(cacheDir: File, name: String): File? =
if (isSafeName(name)) File(directory(cacheDir), name) else null
/** 名字是否可以安全地当**单个**文件名用。 */
fun isSafeName(name: String): Boolean =
name.isNotBlank() &&
name != "." &&
name != ".." &&
name.none { it == '/' || it == '\\' || it == '\u0000' }
/** 命中判定:**非空文件**才算。0 字节(失败落盘 / 被外部清空的残留)一律当未命中。 */
fun isHit(file: File): Boolean = file.isFile && file.length() > 0L
/**
* 原子落盘:写临时文件 → 改名到 [file]。失败时**不留半截文件**在任何正式名下。
*
* @throws IOException 写不动 / 改名失败(临时文件已清掉)。
*/
fun write(file: File, bytes: ByteArray) {
val directory = file.parentFile ?: throw IOException("cache file has no parent: $file")
if (!directory.isDirectory && !directory.mkdirs()) {
throw IOException("cannot create cache directory: $directory")
}
val temporary = File.createTempFile(TEMP_PREFIX, TEMP_SUFFIX, directory)
try {
temporary.outputStream().use { it.write(bytes) }
moveInto(temporary, file)
} catch (e: Exception) {
temporary.delete()
throw e
} finally {
// 成功时临时文件已被改名(不再存在);失败时这里再兜一次,别把 .part 留在缓存里。
if (temporary.exists()) temporary.delete()
}
}
/**
* 本地待发附件的缓存名:**也按内容寻址**(`sha256(bytes)[:32] + 扩展名`)。
*
* 复用服务端那条规则有两个好处:同一份字节重复打开只落一份;名字形态与服务端存储名同构,
* 「打开 / 保存」两条路不必分支处理。扩展名取自待发附件的显示名(本地这一侧它是可信的,
* 用户选的就是它)——拿不到就用空扩展名,**不猜**。
*/
fun localName(bytes: ByteArray, displayName: String): String =
sha256Hex(bytes).take(NAME_HEX_LENGTH) + extensionOf(displayName)
private fun moveInto(from: File, to: File) {
try {
Files.move(
from.toPath(),
to.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(from.toPath(), to.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
}
private fun sha256Hex(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
/** 显示名里的扩展名(带点、小写、≤8 个字母数字);拿不到就回空串。 */
private fun extensionOf(displayName: String): String {
val extension = displayName.substringAfterLast('.', "").lowercase()
val usable = extension.isNotEmpty() &&
extension.length <= MAX_EXTENSION_LENGTH &&
extension.all { it.isLetterOrDigit() }
return if (usable) ".$extension" else ""
}
private const val TEMP_PREFIX = "cache-"
private const val TEMP_SUFFIX = ".part"
private const val NAME_HEX_LENGTH = 32
private const val MAX_EXTENSION_LENGTH = 8
}
/**
* 缓存里的一份文档:[file] 是落盘的那一份(交给 FileProvider / 写进下载目录),[bytes] 是它的
* 内容(预览切段用)。
*
* 两者一起返回是刻意的:下载刚拿到的字节与随后写进缓存的是**同一份**,再读一次盘只是白花
* 一次 20 MiB 的 IO。
*/
class CachedDocument(val file: File, val bytes: ByteArray)
/**
* 「按需取字节 + 落盘缓存」的取数层(`GET /api/v1/documents/{name}` 的**第一个调用方**)。
*
* 服务端([DocumentRepository.download])与本地(待发附件的 `content://`)两条来源在这里汇成
* 同一个结果。来源判定的**唯一**落点是 [DocumentSource](见 `DocumentTarget.of`)——本类只按
* 它分支,不再自己判断「这是服务端还是本地」。
*
* [openLocal] 收成一个 `String -> InputStream?`,把 `ContentResolver` 挡在类外:这样本类可以在
* 单测里用临时目录 + 假流跑完(Android 的 `Uri` / `ContentResolver` 在 JVM 单测里是 stub,
* 一碰就抛)。
*/
class DocumentCacheStore(
private val repository: DocumentRepository,
private val cacheDir: File,
private val openLocal: (String) -> InputStream?,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* 取回一份附件的字节并确保它已落进缓存。
*
* 服务端来源先看缓存([DocumentCache.isHit])——命中就**一个请求都不发**;本地来源没有
* 「命中」可言(URI 指向的内容可能变),每次都按当前字节重新算内容寻址名。
*/
suspend fun load(source: DocumentSource): Result<CachedDocument> = withContext(dispatcher) {
when (source) {
is DocumentSource.Server -> loadServer(source.name)
is DocumentSource.Local -> loadLocal(source)
}
}
private suspend fun loadServer(name: String): Result<CachedDocument> {
val file = DocumentCache.fileFor(cacheDir, name)
// 名字不安全 = 服务端给了一个本端不敢拼路径的名字;判解析失败,绝不退回本地文件名。
?: return Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
if (DocumentCache.isHit(file)) {
val cached = runCatching { file.readBytes() }.getOrNull()
if (cached != null) return Result.success(CachedDocument(file, cached))
// 缓存读不动(被外部清理 / 权限异常):删掉重下,绝不当成「空文件」返回。
file.delete()
}
val bytes = repository.download(name).getOrElse { return Result.failure(it) }
return cache(file, bytes)
}
private fun loadLocal(source: DocumentSource.Local): Result<CachedDocument> {
val bytes = try {
val input = openLocal(source.uri)
?: return Result.failure(LocalDocumentUnavailableException())
input.use { stream ->
// 与上传路径同一把尺子:本端能处理的体积上限就是附件上限。
readBounded(stream, DocumentAttachments.MAX_FILE_BYTES)
} ?: return Result.failure(LocalDocumentTooLargeException())
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
return Result.failure(LocalDocumentUnavailableException())
}
val file = DocumentCache.fileFor(cacheDir, DocumentCache.localName(bytes, source.displayName))
?: return Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
// 内容寻址:同一份字节已经落过盘就不重写(重复打开一份大附件不该反复写 20 MiB)。
if (!DocumentCache.isHit(file)) return cache(file, bytes)
return Result.success(CachedDocument(file, bytes))
}
private fun cache(file: File, bytes: ByteArray): Result<CachedDocument> = try {
DocumentCache.write(file, bytes)
Result.success(CachedDocument(file, bytes))
} catch (_: Exception) {
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——预览本身也是靠缓存文件那条路
// 把完整内容交给别的 App 的,所以这里不回退成「只看得到一半」。
file.delete()
Result.failure(DocumentCacheUnavailableException())
}
}
/**
* 图片字节的取数与落盘(读侧是 `GET /api/v1/files/{name}`)。
*
* ## 为什么与附件挤在同一个缓存层
* 图片与附件落的是**同一个目录**(`cacheDir/documents/`),因此共用同一个 FileProvider 根。
* 「缓存目录是唯一被暴露出去的根」这条不变量只允许有一处实现:名字规则、命中判定、原子落盘
* 三条全部复用 [DocumentCache]——图片只是多用了「名字推不出来时按内容寻址」这一条。
* 不新开 `images/` 目录也不改 `file_paths.xml`:暴露面一个字节都没变。
*
* ## 三种来源各自的取字节方式
* - [ImageSource.Server]:先按 URL 里的名字看缓存(**命中一个请求都不发**,名字由内容决定
* ⇒ 命中永不失效),未命中才 GET 那个绝对地址;
* - [ImageSource.Inline]:小图内联进历史的那份 `data:` URI,字节就在字符串里,**不联网**;
* - [ImageSource.Local]:相册选择器的 `content://` / 分享落地的 `file://`,按 URI 现读。
*
* ## 为什么用一条**裸的** OkHttp 客户端
* 图片读侧是 128-bit 随机名构成的**能力 URL**,服务端刻意不校验 Bearer(`<img>` 也带不了头)。
* 所以这里不发 `Authorization`:把主人的 token 发到一个免鉴权端点上没有任何意义,只多一个泄漏面。
* ⚠️ 文档读侧(`GET /api/v1/documents/{name}`)**要** Bearer,那条路走 [DocumentCacheStore]。
*
* ## 为什么不复用 Coil 已经取过的那一份
* 气泡缩略图是 Coil 拉的,它的磁盘缓存**不给**「这一份普通文件在哪」这种契约(键与落盘格式都是
* 它内部的事)。而两条出口要交给系统别的 App 的恰恰是一份普通文件(FileProvider 只认路径)。
* 所以这里自己取一次、按名字落盘:代价是**首次打开一张服务端图会多下一次**(第二次起走本地
* 缓存),换来的是「预览看到的、打开给出的、保存写下的」是同一份字节,不会出现三条路各下一个。
*
* [download] 与 [openLocal] 都收成 lambda,把 OkHttp 与 `ContentResolver` 挡在类外:本类因此
* 可以在单测里用临时目录 + 假流跑完(`android.net.Uri` 在 JVM 单测里是 stub,一碰就抛)。
*/
class ImageCacheStore(
private val cacheDir: File,
private val openLocal: (String) -> InputStream?,
private val download: suspend (String) -> Result<ByteArray> = ::httpGetImage,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* 取回一张图的字节并确保它已落进缓存。
*
* 三种来源的产物是同一个形状([CachedDocument]):文件名 + 字节。这样「用其它应用打开 /
* 保存到下载目录」两条出口不必按来源分支——交给 FileProvider 的永远是缓存里那一份。
*/
suspend fun load(source: ImageSource): Result<CachedDocument> = withContext(dispatcher) {
when (source) {
is ImageSource.Server -> loadServer(source.raw)
is ImageSource.Inline -> loadInline(source.raw)
is ImageSource.Local -> loadLocal(source.raw)
}
}
private suspend fun loadServer(url: String): Result<CachedDocument> {
val name = ImageNames.fromServerUrl(url)
if (name != null) cachedHit(name)?.let { return it }
val bytes = download(url).getOrElse { return Result.failure(it) }
// 名字推不出来 / 拼不出路径(形态陌生、含可疑字符)时按**字节**内容寻址:
// 绝不拿一个不可信的名字去拼路径,也绝不因此拒绝打开这张图。
return write(name?.let { DocumentCache.fileFor(cacheDir, it) } ?: contentAddressed(bytes), bytes)
}
private fun loadInline(dataUri: String): Result<CachedDocument> {
val bytes = ImageDataUri.bytes(dataUri)
?: return Result.failure(LocalDocumentUnavailableException())
return write(contentAddressed(bytes), bytes)
}
private fun loadLocal(uri: String): Result<CachedDocument> {
val bytes = try {
val input = openLocal(uri)
?: return Result.failure(LocalDocumentUnavailableException())
input.use {
// 与附件同一把尺子:本端能一次读进内存的上限就是附件上限。
readBounded(it, DocumentAttachments.MAX_FILE_BYTES)
} ?: return Result.failure(LocalDocumentTooLargeException())
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
return Result.failure(LocalDocumentUnavailableException())
}
return write(contentAddressed(bytes), bytes)
}
/** 缓存命中:名字可信、文件在且非空、且真的读得出来。读不动就删掉重来,绝不当成空图返回。 */
private fun cachedHit(name: String): Result<CachedDocument>? {
val file = DocumentCache.fileFor(cacheDir, name) ?: return null
if (!DocumentCache.isHit(file)) return null
val bytes = runCatching { file.readBytes() }.getOrNull() ?: run {
file.delete()
return null
}
return Result.success(CachedDocument(file, bytes))
}
/** 内容寻址的缓存名(与服务端文档同一规则):`sha256(bytes)[:32] + 嗅探所得的扩展名`。 */
private fun contentAddressed(bytes: ByteArray): File? =
DocumentCache.fileFor(
cacheDir,
DocumentCache.localName(bytes, CONTENT_ADDRESSED_HINT + ImageFormats.extensionFor(ImageFormats.sniff(bytes))),
)
private fun write(file: File?, bytes: ByteArray): Result<CachedDocument> {
val target = file
?: return Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
// 内容寻址的名字相同 ⇒ 字节相同:已经落过盘就不重写(同一张图重复打开不该反复写盘)。
if (DocumentCache.isHit(target)) return Result.success(CachedDocument(target, bytes))
return try {
DocumentCache.write(target, bytes)
Result.success(CachedDocument(target, bytes))
} catch (_: Exception) {
target.delete()
Result.failure(DocumentCacheUnavailableException())
}
}
private companion object {
/** 内容寻址名的扩展名载体:`localName` 只看扩展名,名字本身会被 sha256 换掉。 */
const val CONTENT_ADDRESSED_HINT = "image"
}
}
/** 只服务免鉴权图片读侧的裸客户端;进程内一份,连接池与线程池复用。 */
private val imageHttpClient: OkHttpClient by lazy { OkHttpClient() }
/**
* GET 一个图片地址并读回字节(至多 [DocumentAttachments.MAX_FILE_BYTES])。
*
* 非 2xx 走 [ApiErrorMapper]——与所有 REST 仓库同一口径:服务端有话说就用它那句,没有就说不出
* 的时候也不编。2xx 却读不出字节(0 字节 / 超过上限)同样判失败:**绝不**把一份空字节落进缓存,
* 那会让下一次打开「命中」一份什么都没有的文件。
*/
private suspend fun httpGetImage(url: String): Result<ByteArray> = withContext(Dispatchers.IO) {
try {
imageHttpClient.newCall(Request.Builder().url(url).get().build()).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(
CyreneException(ApiErrorMapper.fromHttp(response.code, response.body.string())),
)
}
val bytes = readBounded(response.body.byteStream(), DocumentAttachments.MAX_FILE_BYTES)
when {
bytes == null -> Result.failure(ImageTooLargeException())
bytes.isEmpty() -> Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
else -> Result.success(bytes)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
}
}
@@ -0,0 +1,142 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiErrorMapper
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.DocumentsApi
import top.yeij.cyrene.data.api.apiCall
/**
* 一次成功上传的文档。
*
* **首次上传(`201`)与去重命中(`200`)返回的是同一个结果**——区别只在 [deduped]:
* 服务端对「同一份字节 + 同一扩展名」回同一个 [name],调用方**必须当成功处理**,
* 唯一的差别是可以据此少显示一次「已上传」的进度。
*
* [url] 是**相对**同源路径(`/api/v1/documents/<name>`),与图片上传的绝对 URL 不同:
* 读侧要 Bearer,客户端真正要用的是 [name]。它留着只为排查与展示,**不要**当 `chat.image`
* 那样的可直接取用地址。
*/
data class UploadedDocument(
val name: String,
val url: String,
val mime: String,
val size: Long,
val sha256: String,
val deduped: Boolean,
)
/**
* 文档取数层(`POST /api/v1/documents` 上传、`GET /api/v1/documents/{name}` 取字节)。
*
* ## 与图片上传([UploadRepository])的三处刻意的不同
* 1. **文件名参与裁决**:服务端把**白名单扩展名**与**字节嗅探**合取,两者都过才放行
* (图片只看嗅探)。所以 [fileName] 必须原样带上真实扩展名,不能像图片那样自己编一个;
* 2. **`200` 是成功**:同一份字节重复上传回 `200` + 同一个 name + `deduped:true`,
* **不是错误**。这条由 [apiCall] 天然成立(它只看「2xx 且信封里有 data」);
* 3. **`url` 不做绝对化校验**:文档的 url 本来就是相对路径(读侧要 Bearer),
* 照搬图片那条「不是 http(s) 就判失败」的规则会把每一次成功上传都判成解析失败。
*
* ## 错误语义
* 用 [apiCall]:状态码**不承载额外语义**(`413` / `415` / `400` / `404` 的机器可读区分
* 全在 `error.code` 里),仓库只把它折进 `Result.failure`;调用方用
* [top.yeij.cyrene.domain.chat.toDocumentFailure] 收敛成可分支的失败类型。
*
* ## 客户端不做的事(服务端才是裁决者)
* **不做本地扩展名白名单、不做本地 20 MiB 上限**:白名单与上限都是服务端配置(上限是
* `CHAT_DOCUMENT_MAX_BYTES`,可部署改),客户端复制一份必然漂移——把「本地觉得不行」当成
* 拒绝,就会出现「服务端其实收得下、客户端却拦了」的静默更严。这里宁可让服务端回一个
* 带真实上限的 `413` / `415`,让用户看到准确的理由。
*/
class DocumentRepository(
private val api: DocumentsApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/** 上传一份文档([bytes] 与 [fileName] 必须来自同一个文件)。 */
suspend fun upload(bytes: ByteArray, fileName: String): Result<UploadedDocument> =
withContext(dispatcher) {
apiCall { api.upload(partOf(bytes, fileName)) }.fold(
onSuccess = { data ->
if (data.name.isBlank()) {
// 201/200 却没有可用存储名 = 上传了但引用不上,判解析失败。
// **绝不**退回「用本地文件名」——服务端已经把它丢掉了,引用一个不存在的名字
// 只会让这一轮在服务端回 404。
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
} else {
Result.success(
UploadedDocument(
name = data.name,
url = data.url,
mime = data.mime,
size = data.size,
sha256 = data.sha256,
deduped = data.deduped,
),
)
}
},
onFailure = { Result.failure(it) },
)
}
/**
* 取回一份文档的原始字节。
*
* 响应体**不是信封**(就是文件本身),所以这里不用 [apiCall]:成功即读字节,
* 失败才走 [ApiErrorMapper](`404 document_not_found` / `401`)。
* `@Streaming` 的响应体必须由调用方关闭,[okhttp3.ResponseBody.use] 在这里收口。
*/
suspend fun download(name: String): Result<ByteArray> = withContext(dispatcher) {
try {
val response = api.download(name)
val body = response.body()
when {
response.isSuccessful && body != null -> Result.success(body.use { it.bytes() })
// 2xx 却没有响应体:与 apiCall 同一口径,判解析失败而不是「拿到了空文件」。
response.isSuccessful -> Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
else -> Result.failure(
CyreneException(
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
),
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
}
}
/**
* multipart 部件。
*
* **部件名固定 `file`**(服务端 `r.FormFile("file")`),**文件名必须给且要带扩展名**:
* Go 只把带非空 `filename` 参数的部件当**文件**(不带的落进普通表单值,`FormFile` 就找不到它),
* 而扩展名要参与服务端的白名单判定。[fileName] 为空时回落到
* [FALLBACK_FILE_NAME]——只是为了不让一个「没有名字的部件」变成 `400 需要 multipart 字段 file`;
* 真正的原因(用户到底送的是什么)只有调用方知道,**不要**靠这个兜底掩盖调用方的空名字。
*
* `Content-Type` 固定 `text/plain`:服务端**不采信**客户端声明(字节嗅探是唯一裁决),
* 这里给一个诚实的默认值即可——文档白名单里放行的都是文本类。
*/
private fun partOf(bytes: ByteArray, fileName: String): MultipartBody.Part =
MultipartBody.Part.createFormData(
name = UPLOAD_FIELD_NAME,
filename = fileName.takeIf { it.isNotBlank() } ?: FALLBACK_FILE_NAME,
body = bytes.toRequestBody(MIME_TEXT.toMediaTypeOrNull()),
)
}
private const val UPLOAD_FIELD_NAME = "file"
private const val FALLBACK_FILE_NAME = "document.txt"
private const val MIME_TEXT = "text/plain"
@@ -0,0 +1,123 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
/**
* 编辑内容的上限(契约 §1 行 6a:非空、≤ **4000 rune**)。
*
* 「rune」是**码点**而不是 `String.length`:后者数的是 UTF-16 码元,emoji 与绝大多数中文扩展字符
* 会被算成两个。按 `length` 卡上限会在边界上**误伤**(客户端以为超了、不发;服务端其实收得下),
* 也会**误放**(客户端以为没超、发出去被 400)。
*/
const val MESSAGE_EDIT_MAX_RUNES = 4000
/**
* 一次编辑提交的准入判定(纯函数的结果,见 [validateMessageEdit])。
*
* 分成三档是因为**用户要做的动作不同**:空了要接着打字,超长了要删字,只有 [Accepted] 才该发请求。
*/
sealed interface MessageEditDraft {
/** 可以提交;[content] 就是要发给服务端的字符串(原样,**不 trim**——见 [validateMessageEdit])。 */
data class Accepted(val content: String) : MessageEditDraft
/** 空 / 纯空白:服务端会回 `400`,本端**不发这个请求**。 */
data object Empty : MessageEditDraft
/** 超过 [max] 个码点:服务端会回 `400`,本端同样不发。 */
data class TooLong(val runes: Int, val max: Int = MESSAGE_EDIT_MAX_RUNES) : MessageEditDraft
}
/**
* 校验一次编辑的内容(纯函数)。
*
* **不 trim**:服务端存的就是用户输入的那个字符串(契约只承诺「非空」),客户端替它去掉首尾空白
* 等于悄悄改掉用户的消息——对代码块、Markdown 引用这类内容尤其明显。空白只用来判「空」。
*
* 计数按**码点**([runeCount]),与服务端 Go 的 `utf8.RuneCountInString` 同口径。
*/
fun validateMessageEdit(raw: String): MessageEditDraft {
if (raw.isBlank()) return MessageEditDraft.Empty
val runes = runeCount(raw)
return if (runes > MESSAGE_EDIT_MAX_RUNES) {
MessageEditDraft.TooLong(runes)
} else {
MessageEditDraft.Accepted(raw)
}
}
/** 按 Unicode 码点计数:代理对(emoji、部分汉字)算一个,与服务端 rune 口径一致。 */
fun runeCount(text: String): Int = text.codePointCount(0, text.length)
/**
* 按 `id` **原位**改一条消息的 `content`(纯函数;编辑落地的唯一实现)。
*
* 三条刻意的取舍:
* 1. **只改 `content`,不整条替换**。`message_edited` 帧里的 DTO 是完整消息,但它的 `thinking`
* 恒为空(契约 v0.59 §4.4:实时帧一律剥离思维链)。整条替换会把本端已渲染的思维链擦掉,
* 而契约明说这个操作**只改 content**——照它做,就不会有第二种解读。
* 2. **找不到这个 `id` 就原样返回**,绝不插入。契约 §3.8 写得很直白:「找不到该 id 即忽略,
* 避免把别的会话的消息注入当前列表」。别人的会话里那条消息不该在这里冒出来。
* 3. **空内容是坏帧,不落**。服务端拒空内容(400),合法的编辑结果不可能为空;真收到空白就把
* 气泡擦成空白,是拿坏帧毁掉一条好消息。宁可整帧忽略。
*
* **幂等**:[content] 与现值相同时返回**同一个实例**(Compose 的 key 稳定性靠它),因此
* 「REST 响应先到、广播后到」(或反过来)连做两次与做一次完全等价——这就是双到达收敛的根据。
* 不修改入参。
*/
fun applyMessageEdit(
messages: List<HistoryMessageDto>,
id: String,
content: String,
): List<HistoryMessageDto> {
if (id.isBlank() || content.isBlank()) return messages
var changed = false
val updated = messages.map { message ->
if (message.id != id || message.content == content) {
message
} else {
changed = true
message.copy(content = content)
}
}
return if (changed) updated else messages
}
/**
* 按 `id` 移除一条消息(纯函数;删除落地的唯一实现)。
*
* `id` 为空白即原样返回:`message_deleted` 的载荷是 `{id}`,坏帧(缺键)解出空串,
* 而「删掉所有 id 为空的消息」不是任何人想要的结果。找不到该 `id` 同样原样返回(幂等)——
* 「我们自己的 DELETE 回执 + 广播」两次到达、以及别的端删掉一条本端还没加载的消息,都走这里。
*
* 删除的**路由**由帧壳的 `sessionId` 负责(见
* [top.yeij.cyrene.data.ws.isForSession]);`sessionId` 缺席时契约允许按**全局唯一**的 `id`
* 兜底——那正是这个函数天然的行为:别的会话的列表里根本不存在这个 `id`。不修改入参。
*/
fun removeMessageById(messages: List<HistoryMessageDto>, id: String): List<HistoryMessageDto> {
if (id.isBlank()) return messages
// 没有这一条就原样返回**同一个实例**(幂等的可观察形式:连做两次与做一次完全等价,
// 也不会白分配一个新列表)。
if (messages.none { it.id == id }) return messages
return messages.filterNot { it.id == id }
}
/**
* 这条消息**能不能编辑**(产品口径,UI 与 VM 共用同一份判据)。
*
* 只放 `user`:assistant 的回复是她说过的话的历史记录,改它等于伪造她说过什么;而要「重发」
* 一条说错的回复,正确的动作是删掉它再问一遍(契约也明确没有重发端点)。
*
* 用 `== "user"` 而不是「非 assistant」:`role` 在后端是**开放集合**,将来多出一个角色时
* fail-closed(不改)比「默认都能改」安全。
*/
fun canEditMessage(message: HistoryMessageDto): Boolean = message.role == "user"
/**
* 这条消息**能不能删除**(产品口径,UI 与 VM 共用同一份判据)。
*
* user 与 assistant 都放:删掉一句错答是常见需求,而且服务端对这个端点本就不分角色。
* 未知 `role` 一律不放(fail-closed)——不认识的东西不主动去删。
*/
fun canDeleteMessage(message: HistoryMessageDto): Boolean =
message.role == "user" || message.role == "assistant"
@@ -0,0 +1,192 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.api.apiCall
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.api.dto.UpdateMessageRequest
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 会话消息层:分页 / 搜索 / **正向增量**读取,以及单条消息的**编辑 / 删除**(D3)。
*
* 三条读取出口共用同一个端点,只是给的查询参数不同:
* - [loadPage] / [loadLatest]:`before` 往回翻(服务端**倒序**返回);
* - [search]:`q` 会话内搜索(也是倒序);
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6)。
*
* 三者都统一转成**升序**的 [PagedMessages] 再交出去。去重 / 合并请用 [mergeMessages]。
*/
class MessageRepository(
private val api: SessionsApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* 拉一页消息。
*
* @param before 上一页最旧消息的 `createdAt`(用 [PagedMessages.oldestCreatedAt]);
* null = 最新一页
* @param platform 来源平台过滤;null = 全量(Web 端共享主会话语义)
*/
suspend fun loadPage(
sessionId: String,
limit: Int = DEFAULT_PAGE_SIZE,
before: Long? = null,
platform: String? = null,
): Result<PagedMessages> = withContext(dispatcher) {
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
apiCall { api.listMessages(sessionId, pageSize, before, platform) }
.map { raw -> toPagedMessages(raw, pageSize) }
}
/** 首屏 / 刷新:最新一页。 */
suspend fun loadLatest(
sessionId: String,
limit: Int = DEFAULT_PAGE_SIZE,
platform: String? = null,
): Result<PagedMessages> = loadPage(sessionId, limit, before = null, platform = platform)
/**
* 会话内**内容搜索**(D2,契约 §1 行 6)。
*
* 与 [loadPage] 走的是**同一个端点、同一套分页语义**,唯一差别是会带上 `q`:
* 服务端按大小写不敏感的 ASCII LIKE 在**本会话内**匹配 `content`(`%` / `_` / `\` 已转义为
* 字面量),仍按 `createdAt` 倒序返回,`before` 仍是「上一页最旧那条的时间戳」。
* 搜索绝不跨会话,所以这里没有 `platform` 参数——那是我方对多端来源的过滤,与搜索正交。
*
* **空查询不发请求**:契约里「空 `q` = 未传」意味着服务端会退化成普通分页,
* 把整段历史当成搜索结果回给调用方——那比不发更像一个谎。所以空/纯空白一律返回一个
* 空页(真实的「没有命中」由调用方在**已提交过查询**的前提下渲染)。
*/
suspend fun search(
sessionId: String,
query: String,
limit: Int = DEFAULT_SEARCH_PAGE_SIZE,
before: Long? = null,
): Result<PagedMessages> = withContext(dispatcher) {
val normalized = normalizeSearchQuery(query)
if (normalized == null) return@withContext Result.success(EMPTY_PAGE)
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
apiCall { api.listMessages(sessionId, pageSize, before, platform = null, query = normalized) }
.map { raw -> toPagedMessages(raw, pageSize) }
}
/**
* **正向增量**(A5,契约 §1 行 6):`GET /messages?after=<ms>`,返回 `created_at > after` 的
* 消息,**升序**(与 [loadPage] 的倒序相反)。
*
* 它存在的唯一理由是补断线的洞:WS 重连只回放主会话**最近 50 条**,断线期间超过 50 条的
* 消息用「再拉一页最新」是补不回来的——最新那一页会直接跳过中间那段。[after] 就是那个游标:
* 传本端已看到的最后一条 `createdAt`,服务端从**最旧的一条开始**给。
*
* 分页推进的规矩(与 [loadPage] 不同,别照抄):下一页的游标是本页**最新**一条的
* `createdAt`([PagedMessages.newestCreatedAt]),不是最旧的。
*
* 同毫秒并列且条数超过 `limit` 时,纯 `createdAt` 游标推不动(契约 §A5 明说的已知边界)——
* 调用方按 `id` 去重、并在游标没前进时停下来,不在这里兜。
*/
suspend fun loadAfter(
sessionId: String,
after: Long,
limit: Int = DEFAULT_PAGE_SIZE,
platform: String? = null,
): Result<PagedMessages> = withContext(dispatcher) {
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
apiCall { api.listMessages(sessionId, pageSize, before = null, platform = platform, after = after) }
.map { raw -> toPagedMessages(raw, pageSize) }
}
/**
* 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,成功返回**服务端更新后的
* 完整消息 DTO**。
*
* 校验在**发请求之前**做([validateMessageEdit]):空 / 纯空白 / 超 4000 rune 的内容服务端
* 一定回 `400`,明知会被拒还发一次只是白等一个来回。被拒时返回的失败**带得出原因**——
* 调用方要把「为什么没提交」如实说出来,不能静默什么都不发生。
*
* 返回的是服务端那一份而不是本地改过的副本:调用方据此更新界面([applyMessageEdit]),
* 与随后到达的 `message_edited` 广播收敛成同一次变更。
*/
suspend fun editMessage(messageId: String, content: String): Result<HistoryMessageDto> {
val draft = when (val validation = validateMessageEdit(content)) {
MessageEditDraft.Empty -> return Result.failure(
CyreneException(CyreneError.Server("bad_request", "消息内容不能为空", 400)),
)
is MessageEditDraft.TooLong -> return Result.failure(
CyreneException(
CyreneError.Server(
"bad_request",
"消息内容不能超过 ${validation.max} 个字符(当前 ${validation.runes})",
400,
),
),
)
is MessageEditDraft.Accepted -> validation.content
}
return withContext(dispatcher) {
apiCall { api.updateMessage(messageId, UpdateMessageRequest(content = draft)) }
}
}
/**
* 删除单条消息(D3,契约 §1 行 6b):`DELETE /api/v1/messages/{id}`,成功返回被删消息的 `id`。
*
* 没有本地校验可做(只有一个路径参数),所以失败一律来自服务端:`404 not_found`(这条已经
* 不在服务端了,常见于别的端先删掉)、`403`(权限)、`501`(没接 HistoryStore)、网络。
* 调用方要把这些如实说出来——**静默失败会让人以为删掉了,而她下一条回复里那条还在**。
*/
suspend fun deleteMessage(messageId: String): Result<String> = withContext(dispatcher) {
apiCall { api.deleteMessage(messageId) }.map { it.deleted }
}
companion object {
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 200
/** 搜索结果一页的条数:命中项要一条条读,比历史分页小一些更好翻。 */
const val DEFAULT_SEARCH_PAGE_SIZE = 30
private val EMPTY_PAGE = PagedMessages(
messages = emptyList(),
hasMore = false,
oldestCreatedAt = null,
newestCreatedAt = null,
)
}
}
/**
* 规整搜索关键词:去首尾空白后仍为空 → null(调用方据此**不发请求**,见
* [MessageRepository.search])。
*
* 只做 trim,**不改大小写、不转义 `%` / `_` / `\`**:服务端自己把这三个字符当字面量处理
* (契约 §1 行 6),客户端再转一次会把 `\%` 这种输入搜成别的东西——两边都转义必然有一边是错的。
* 大小写同理:匹配由服务端做 ASCII 不敏感比较,客户端保留用户原样输入。
*/
fun normalizeSearchQuery(raw: String): String? = raw.trim().takeIf { it.isNotEmpty() }
/**
* 原始响应 → [PagedMessages]:**无论服务端给的是倒序(`before` / `q`)还是升序(`after`)**,
* 一律按 [MessageOrder] 排成升序。
*
* [hasMore] 两向同义:拿到满页 ⇒ 该方向**可能还有**(`before` 方向是更旧的、`after` 方向是更新的)——
* 它是保守判断,具体能不能继续由调用方的游标决定。
*/
private fun toPagedMessages(
raw: List<HistoryMessageDto>,
pageSize: Int,
): PagedMessages {
val ascending = raw.sortedWith(MessageOrder)
return PagedMessages(
messages = ascending,
hasMore = raw.size >= pageSize,
oldestCreatedAt = ascending.firstOrNull()?.createdAt,
newestCreatedAt = ascending.lastOrNull()?.createdAt,
)
}
@@ -0,0 +1,22 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import top.yeij.cyrene.data.api.MetaApi
import top.yeij.cyrene.data.api.apiCall
import top.yeij.cyrene.data.api.dto.EnumTableDto
/**
* 枚举权威表取数层(api-client §1.1)。
* 仓库不持有持久化状态:调用方失败时保留上次快照(见 [top.yeij.cyrene.core.prefs.EnumPreferences])。
*/
class MetaRepository(
private val api: MetaApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun enums(): Result<EnumTableDto> = withContext(dispatcher) {
apiCall { api.enums() }
}
}
@@ -0,0 +1,58 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
/**
* 一页消息(已由仓库转成 **升序**,可直接追加渲染)。
*
* @param hasMore 该页是否可能还有更旧的消息(`原始页大小 == 请求页大小` 的保守判断)
* @param oldestCreatedAt 本页最旧一条的 `createdAt`,作为下一页的 `before` 游标
* @param newestCreatedAt 本页最新一条的 `createdAt`
*/
data class PagedMessages(
val messages: List<HistoryMessageDto>,
val hasMore: Boolean,
val oldestCreatedAt: Long?,
val newestCreatedAt: Long?,
)
/** 消息排序:`createdAt` 升序,同毫秒用 `id` 兜底,保证稳定可复现。 */
internal val MessageOrder: Comparator<HistoryMessageDto> = compareBy<HistoryMessageDto>({ it.createdAt }, { it.id })
/**
* 纯函数合并:`existing`(本地已加载,任意序)+ `incoming`(服务端新到的一页,倒序)
* → 按 `id` 去重、按 `createdAt` 升序(同毫秒用 `id` 兜底)的结果。
*
* 冲突时 **incoming 胜出**(§3.2「服务端优先」)。不修改入参,返回新列表。
* 时间复杂度 O((n+m) log(n+m)),空间 O(n+m)。
*/
fun mergeMessages(
existing: List<HistoryMessageDto>,
incoming: List<HistoryMessageDto>,
): List<HistoryMessageDto> {
if (incoming.isEmpty()) return existing.sortedWith(MessageOrder)
if (existing.isEmpty()) return incoming.sortedWith(MessageOrder)
val byId = LinkedHashMap<String, HistoryMessageDto>(existing.size + incoming.size)
existing.forEach { byId[it.id] = it }
incoming.forEach { byId[it.id] = it }
return byId.values.sortedWith(MessageOrder)
}
/**
* 把一帧 `history` 收敛到**本会话**。
*
* WS 首帧 `history` 是**主会话**最近 50 条的回放,但连接是全局的、帧会被投给任意
* `ChatViewModel`——不过滤就会把主会话的消息混进次级会话。
*
* 判定以**消息自身的 `sessionId`** 为准,而不是「只有主会话才收 history」:前者更耐将来,
* 服务端哪天开始给次级会话回放,这里无需改动。
*
* `sessionId` 为空的帧视为「无法判定」**放行**——不因缺一个字段就整段丢掉历史(保持既有行为)。
*
* 纯函数,不修改入参。
*/
fun historyForSession(
messages: List<HistoryMessageDto>,
sessionId: String,
): List<HistoryMessageDto> = messages.filter { it.sessionId.isBlank() || it.sessionId == sessionId }
@@ -0,0 +1,24 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import top.yeij.cyrene.data.api.PersonaApi
import top.yeij.cyrene.data.api.apiCall
import top.yeij.cyrene.data.api.dto.UserPersonaDto
/**
* 用户侧角色目录取数层(api-client §1 行 9),供「新建会话时选角色」用。
*
* 仓库不持有持久化状态:调用方失败时保留上一次拿到的列表——角色目录拿不到只会让弹层少几行,
* 不该连带挡掉「用默认角色新建会话」这条降级路径。
*/
class PersonaRepository(
private val api: PersonaApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun personas(): Result<List<UserPersonaDto>> = withContext(dispatcher) {
apiCall { api.personas() }
}
}
@@ -0,0 +1,84 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.api.apiCall
import top.yeij.cyrene.data.api.dto.CreateSessionRequest
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 会话列表 / 新建 / 重命名置顶 / 删除的数据获取层。
*
* 列表策略(§3.1):调用方拿到结果后**整体替换**本地快照,不做 merge;
* 失败时调用方保留旧快照。仓库本身不持有任何持久化状态。
*/
class SessionRepository(
private val api: SessionsApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* `GET /sessions`,原样返回服务端顺序——**不做客户端排序**:
* `pinned DESC, type='main' DESC, created_at ASC`,置顶的次级会话合法地排在主会话前面。
*/
suspend fun listSessions(): Result<List<SessionSummaryDto>> = withContext(dispatcher) {
apiCall { api.listSessions() }
}
/**
* `POST /sessions`,成功返回新建的 `sessionId`(形如 `web:<uuid>`)。
*
* @param personaId null(或全空白)= **不绑角色**:走**不带 body** 的请求,线上与本端支持选角
* 之前逐字节相同。空白按 null 处理是有依据的——契约里「省略 `personaId`」与「`personaId`
* 为空」是同一件事(不绑,继承全局默认角色),没必要把 `{"personaId":""}` 发上去。
* 非空 = 创建时绑定该角色,未知角色服务端回 `400 bad_request`。
*
* 角色**只在创建时**能定:`PATCH /sessions/{id}` 一旦收到 `personaId` 就回 `400`,要换角色只能新开。
*/
suspend fun createSession(personaId: String? = null): Result<String> = withContext(dispatcher) {
val persona = personaId?.takeIf { it.isNotBlank() }
val created = if (persona == null) {
apiCall { api.createSession() }
} else {
apiCall { api.createSession(CreateSessionRequest(personaId = persona)) }
}
created.map { it.sessionId }
}
/**
* `PATCH /sessions/{id}`:只发本次要改的字段,缺席的字段服务端保持原值
* (所以 `null` 是「不改」,不是「清空」——服务端也无法把标题清回自动命名)。
*/
suspend fun updateSession(
sessionId: String,
title: String? = null,
pinned: Boolean? = null,
): Result<UpdateSessionResponse> = withContext(dispatcher) {
apiCall { api.updateSession(sessionId, UpdateSessionRequest(title = title, pinned = pinned)) }
}
/** `DELETE /sessions/{id}`,成功返回被删除的会话 id;主会话会以 403 失败。 */
suspend fun deleteSession(sessionId: String): Result<String> = withContext(dispatcher) {
apiCall { api.deleteSession(sessionId) }.map { it.deleted }
}
/**
* `POST /sessions/{id}/read`(D4):推进该会话的已读游标,成功返回**推进后**的游标。
*
* @param before 毫秒时间戳;**null = 现在**(「我看到的都算已读」)。服务端对 `0` / 负数
* 一律 `400`,所以调用方必须把「没有时间戳」表达成 null 而不是 0。
*
* 这是**幂等**的(服务端 `MAX(read_at, before)`,只前进),失败也没有副作用——
* 唯一真源仍是服务端游标,漏标一次会在下一次 `GET /sessions` 自然暴露、再被纠正。
*/
suspend fun markRead(sessionId: String, before: Long? = null): Result<ReadSessionResponse> =
withContext(dispatcher) {
apiCall { api.markRead(sessionId, ReadSessionRequest(before = before)) }
}
}
@@ -0,0 +1,112 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.UploadApi
import top.yeij.cyrene.data.api.apiCall
/**
* 一次成功上传的可用形态。
*
* 调用方(`ChatViewModel`)只需要 [url],其余三个字段由服务端生成、留着排查用:
* [size] 是服务端统计的字节数、[mime] 是**服务端嗅探**出的类型(不是客户端声明的)、
* [name] 是服务端存储名(客户端原始文件名已被丢弃)。
*/
data class UploadedImage(
val url: String,
val size: Long,
val mime: String,
val name: String,
)
/**
* 图片上传取数层(`POST /api/v1/upload`)。
*
* ## 为什么这台机器存在
* 服务端把 `chat.image` 字符串**原样落库**(api-client §1 行 3 + 协同 G3):内联的 base64
* 会在**每一页历史**与**每一次 WS `history` 回放**里重传一遍。上传一次、消息里只留一个
* URL,是唯一能让历史不再随图片长大的路。
*
* ## 错误语义(这里是本仓库与只读仓库的差别)
* 用 [apiCall]:它的状态码**不承载额外语义**——`413 payload_too_large` 与
* `415 unsupported_media_type` 的机器可读区分都在 `error.code` 里(已对着在线后端实测,
* 见 `UploadDtoRealPayloadTest`),不像 `POST /api/v1/chat` 那样必须靠状态码分 `202` / `409`。
* 于是网络 / 超时 / `401` / 服务端拒绝一律折进 `Result.failure`,调用方按 `code` 或
* [CyreneError] 的类型分支。
*
* ## 两处**客户端**必须自己把关的地方(服务端帮不了)
* 1. **`url` 必须是绝对 http(s) 地址**:`chat.image` 的视觉层只把 `http(s)://` 当 URL
* (`vision.ParseImageRef` 的判据就是 `strings.HasPrefix(src, "http://")`),裸路径
* (如 `/api/v1/files/x.png`)会被当成 **base64** 载荷。拿到这种 url 还照发,就是把这一轮
* 丢进「图片解析失败」的黑洞——所以这里直接判解析失败,宁可当场重试。
* 2. **`url` 不能为空**:`201` 却没有可用地址 = 上传了但拿不到东西,同样判失败,**绝不**
* 退回内联 base64(那正是本任务要消灭的成本)。
*/
class UploadRepository(
private val api: UploadApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* 上传一张**已重编码**的图片,返回可作 `chat.image` 的绝对 URL。
*
* [bytes] 与 [mime] 必须来自同一次编码(`ChatImagePreparer` 的产物):
* 服务端按**字节嗅探**判类型,声明的 `Content-Type` 只作参考,两者不一致时以字节为准。
*/
suspend fun uploadImage(bytes: ByteArray, mime: String): Result<UploadedImage> =
withContext(dispatcher) {
apiCall { api.upload(partOf(bytes, mime)) }.fold(
onSuccess = { data ->
val url = absoluteHttpUrl(data.url)
if (url == null) {
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
} else {
Result.success(
UploadedImage(url = url, size = data.size, mime = data.mime, name = data.name),
)
}
},
onFailure = { Result.failure(it) },
)
}
/**
* multipart 部件。
*
* **部件名固定 `file`**(服务端 `FormFile("file")`)。文件名也**必须给**:Go 的
* `mime/multipart` 只把带 `filename` 参数的部件当**文件**,不带的会落进普通表单值、
* 于是 `FormFile` 找不到它。扩展名与服务端白名单一致(服务端丢弃原始名,这里只是让
* 线上的部件看起来像一张真图,中间件不会因为怪名字做多余判断)。
*/
private fun partOf(bytes: ByteArray, mime: String): MultipartBody.Part =
MultipartBody.Part.createFormData(
name = UPLOAD_FIELD_NAME,
filename = fileNameFor(mime),
body = bytes.toRequestBody(mime.toMediaTypeOrNull()),
)
private fun fileNameFor(mime: String): String = "upload." + when (mime) {
MIME_PNG -> "png"
MIME_GIF -> "gif"
MIME_WEBP -> "webp"
MIME_BMP -> "bmp"
else -> "jpg"
}
/** 只认绝对 http(s) 地址,且**大小写敏感**(与服务端 `ParseImageRef` 的判据同一口径:放宽只会放行服务端不认的东西)。 */
private fun absoluteHttpUrl(raw: String): String? = raw.trim()
.takeIf { it.startsWith("http://") || it.startsWith("https://") }
}
private const val UPLOAD_FIELD_NAME = "file"
private const val MIME_PNG = "image/png"
private const val MIME_GIF = "image/gif"
private const val MIME_WEBP = "image/webp"
private const val MIME_BMP = "image/bmp"
@@ -0,0 +1,369 @@
package top.yeij.cyrene.data.ws
import top.yeij.cyrene.core.auth.AuthRecovery
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.domain.chat.parallelWireValue
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.TimeUnit
import kotlin.random.Random
/**
* Cyrene 实时通道客户端(`GET /ws/chat?token=<JWT>`,api-reference §6)。
*
* ## 用法
* 进程内只有**一个**实例,由 `AppContainer.chatSocket` 持有;上层要收帧就取它,**不要自己 new**
* (见下面「它不是某个屏幕的私有连接」):
* ```
* val socket = container.chatSocket
* // 收集 socket.events,观察 socket.state;发送用 socket.sendChat / socket.sendInterrupt
* ```
*
* ## 会话归属
* 服务端自 v0.36 起在**每个结构化流帧的帧壳**上带 `sessionId`,本类把它原样透传
* (见 [WsEvent.sessionId]),不补值。服务端广播**不按会话订阅**:同一条连接会收到**所有**
* 会话的帧,因此收集 [events] 的上层**必须**按 [WsEvent.isForSession] 收敛到自己的会话。
*
* ## 它不是某个屏幕的私有连接:谁都不许关它
* 本实例是**进程级共享**的一条连接——所有需要收帧的 ViewModel 订阅的是同一份 [events] / [state]。
* 之所以能共享,正是靠上面那条广播语义:「一条连接收到全部帧」与「N 条连接各收全部帧」对上层是
* **同一个语义**,每个 VM 收到的帧集合逐帧相同,所以共享改的只是「连接有几条」(连带每个连接
* 各带一个 `OkHttpClient` 的成本),不是「帧怎么走」——各 VM 的会话过滤逻辑**一个字都不用动**。
*
* 由此推出两条硬约束:
* 1. **[close] 只归应用级的那条登出 / 会话失效路径**(目前唯一调用点:`SessionsViewModel` 的
* 登录态订阅,见 `AppContainer.chatSocket`)。任何 ViewModel 在 `onCleared` 里关它、页面退出时
* 关它,都会把**别的屏幕**一起掐断——切会话会掐掉抽屉未读与管理页三帧,关管理页会掐掉主会话的
* 流式。连接的生命周期是**进程 + 登录态**,不是屏幕。
* 2. **[events] 的 `replay` 恒为 0**(理由见那里的 KDoc):共享之后,新建的 VM 会立刻订阅同一份流,
* replay > 0 就等于把**历史**下行帧投给一个新来的订阅者。
*
* ## 心跳与重连
* - OkHttp `pingInterval` 30s(服务端 ping 周期 54s / pong 等待 60s,客户端 30s 足够)。
* - 服务端**没有 resume / 补发**:重连成功后由上层用新连接的 `history` 帧 + REST 补差去重。
* - 退避 1s/2s/4s/8s/16s/30s(上限 30s,含抖动)。
* - 握手返回 **HTTP 401** → 先向 [onAuthRecover] 要一次续期(B2):换到新令牌就**立刻重连一次**;
* 明确被拒或重连后仍 401 → [WsState.Offline] 并触发 [onAuthFailed];暂时性失败走普通退避。
*
* ## 坏帧
* 每帧解析都走 [WsEvent.parse],非 JSON / 未知 `type` / 结构不匹配一律丢弃,不会中断连接。
*
* @param baseUrlProvider 服务器根地址(如 `http://host:8080`),每次重连时重新读取。
* @param tokenProvider 当前 JWT,每次重连时重新读取(续期后无需重建实例)。
* @param onAuthRecover 握手 401 时的一次性兜底,见 [AuthRecovery]。
* @param onAuthFailed 终态回调(令牌被拒且无法续期);在协程内调用,注意切主线程。
* @param client 可注入自定义 OkHttpClient(会 `newBuilder` 派生,保留其拦截器);缺省新建。
*/
class ChatSocket(
private val baseUrlProvider: () -> String?,
private val tokenProvider: () -> String?,
private val onAuthRecover: () -> AuthRecovery = { AuthRecovery.Unrecoverable },
private val onAuthFailed: () -> Unit = {},
client: OkHttpClient? = null,
) {
private val _state = MutableStateFlow(WsState.Idle)
/** 连接状态机;见 [WsState]。 */
val state: StateFlow<WsState> = _state.asStateFlow()
/**
* 诊断用:此刻底层连接对象还在不在(`webSocket != null`)。
*
* 只读、不改变任何行为,也**不是**产品逻辑的输入(`send` 判的是 [state] 与这个对象本身)。
* 它存在只为一件事:让「这一帧为什么没走 WS」在 logcat 里可读——[state] 与它**可能不一致**
* (状态还是 `Connected` 而对象已经被 `finally` 清空、或者反过来),而那个不一致正是
* `WsSendOutcome.Failed` 与 `Queued` 的分水岭。tag 见 `ChatViewModel.SEND_ROUTE_LOG_TAG`。
*/
val hasLiveSocket: Boolean get() = webSocket != null
private val _events = MutableSharedFlow<WsEvent>(
replay = 0,
extraBufferCapacity = EVENT_BUFFER,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/**
* 下行事件流。
*
* **`replay` 恒为 0,这条不是调优而是必需**:重放旧 delta 会让新订阅者重复渲染,会话状态应由
* `history` / REST 重建;而连接**进程内共享**之后,新建的 VM(切到一个没访问过的会话)会**立刻**
* 订阅同一份流——replay > 0 等于把历史帧投给它,轻则重复渲染,重则会话串台。
*
* `extraBufferCapacity = 512 + DROP_OLDEST`:发送方是 OkHttp 读线程,不能阻塞;溢出时丢最旧的
* 增量帧(服务端发送缓冲 32 本就会丢帧,`stream_end` / `history` 仍可兜底)。
*/
val events: SharedFlow<WsEvent> = _events.asSharedFlow()
private val wsClient: OkHttpClient = (client ?: OkHttpClient()).newBuilder()
.pingInterval(PING_INTERVAL_SECONDS, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
@Volatile
private var wantConnection: Boolean = false
@Volatile
private var webSocket: WebSocket? = null
@Volatile
private var attempt: Int = 0
@Volatile
private var sessionJob: Job? = null
/** 本次 [connect] 是否已经用续期后的令牌重连过一次——避免握手 401 打成死循环。 */
@Volatile
private var authRetried: Boolean = false
private var scope: CoroutineScope? = null
/**
* 开始连接。**幂等**:已经在连接 / 重连中([wantConnection] 已置位)时直接返回。
*
* 因为幂等,任何一个 VM 都可以在需要时调它做兜底;但**决定它该不该在**的只有登录态那条路径
* (见类注释的硬约束 1),本方法只负责「接上」。
*/
fun connect() {
if (wantConnection) return
wantConnection = true
attempt = 0
authRetried = false
sessionJob = ensureScope().launch { runConnectionLoop() }
}
/**
* 主动断开并停掉重连;之后可再次 [connect]。
*
* ⚠️ 本实例是**进程级共享连接**,不是某个屏幕的私有连接:**只有应用级的登出 / 会话失效路径可以
* 调它**(目前唯一调用点:`SessionsViewModel` 的登录态订阅)。ViewModel 的 `onCleared`、
* 页面退出、切会话都**不许**调——那会把别的屏幕正在用的连接一起掐断。理由见类注释
* 「它不是某个屏幕的私有连接:谁都不许关它」。
*/
fun close() {
wantConnection = false
val socket = webSocket
webSocket = null
socket?.close(CLOSE_CODE_NORMAL, CLOSE_REASON_CLIENT)
sessionJob?.cancel()
sessionJob = null
_state.value = WsState.Idle
scope?.cancel()
scope = null
}
/**
* 发送 `chat`(api-reference §6.3)。
* @param clientMsgId 幂等键(契约 v0.56 §3.13);重复到达服务端回 `chat_duplicate`,不落库。
* @param parallel 要不要声明 `parallel:true`(忙时并行规范 §2.2)——**默认 `false` = 一个字节
* 都不带该键**,线上帧与加入本参数之前**逐字节相同**。调用方只有一处:`ChatViewModel.deliver`,
* 它按 App 偏好(`InterruptMode`)与「会话是否正忙」算出来(判据是纯函数 `parallelForSend`)。
* @return 未连接或写失败返回 `false`(本层不做离线队列;上层按**这一刻的连接状态**分档处置:
* 未就绪 ⇒ 记住这条、连接恢复后按序补发,连接就绪却仍发不出去 ⇒ 回滚 + 诚实文案——
* 判据是 `ChatViewModel.deliver` 里的纯函数 `wsSendOutcome`)。
*/
fun sendChat(
sessionId: String?,
text: String,
deviceId: String?,
clientMsgId: String? = null,
parallel: Boolean = false,
): Boolean = send(
WsOutbound.Chat(
text = text,
sessionId = sessionId,
deviceId = deviceId,
clientMsgId = clientMsgId,
// 直接传 `false` 会序列化成 `"parallel":false`(`WsOutbound.Chat` 只在 `null` 时省略该键),
// 那不是「未声明」而是**显式否认**——默认两档必须逐字节不变 ⇒ 只有 true 才带上该键。
parallel = parallelWireValue(parallel),
),
)
/**
* 发送 `interrupt`(api-client §3.1;契约 v0.56/A2 起支持 `scope`)。
*
* @param scope 打断范围:`null` = **省略该键**(服务端视同 `"all"`:取消在跑回合 + 清空待处理队列,
* 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。
* 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。
*
* **调用方只有一处**:`ChatViewModel.interruptRunningTurn()`——它在一条消息真的要走上去**之前**
* 按 App 偏好(`InterruptMode`)决定要不要先打断,且**只在会话正忙时**发(判据是纯函数
* `interruptScopeForSend`)。默认档「追加队列」与「并行」这一档都恒不发(后者的 `wireScope`
* 就是 `null`:并行与打断互斥),所以本方法只服务「打断」这一档。
* 返回 false 只有一种原因(WS 未连接):调用方**静默**并不阻断本次发送,理由见那里的 KDoc。
*/
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
private fun send(outbound: WsOutbound): Boolean {
if (_state.value != WsState.Connected) return false
val socket = webSocket ?: return false
return runCatching { socket.send(outbound.toJson()) }.getOrDefault(false)
}
private suspend fun runConnectionLoop() {
try {
while (wantConnection) {
when (val end = runSession()) {
SessionEnd.ConfigMissing -> {
wantConnection = false
_state.value = WsState.Offline
}
SessionEnd.AuthFailed -> {
if (!recoverAuth()) break
}
SessionEnd.Closed -> {
if (!wantConnection) break
_state.value = WsState.Reconnecting
delay(backoffDelayMs(attempt))
attempt += 1
}
}
}
} catch (_: CancellationException) {
// close() 主动取消,正常退出。
}
}
/**
* 握手被拒(HTTP 401)后的恢复(B2):
* - [AuthRecovery.Renewed]:已换到新令牌,**立刻重连一次**(`tokenProvider` 每次重连都会重新读);
* - [AuthRecovery.Unrecoverable]:令牌被服务端拒绝且无法续期 → 终态,触发 [onAuthFailed];
* - [AuthRecovery.Transient]:暂时续不上(网络 / 5xx),会话保留,走普通退避重连。
*
* @return 连接循环是否继续。
*/
private suspend fun recoverAuth(): Boolean {
if (authRetried) return endWithAuthFailure()
return when (onAuthRecover()) {
is AuthRecovery.Renewed -> {
authRetried = true
attempt = 0
_state.value = WsState.Reconnecting
true
}
AuthRecovery.Transient -> {
_state.value = WsState.Reconnecting
delay(backoffDelayMs(attempt))
attempt += 1
true
}
AuthRecovery.Unrecoverable -> endWithAuthFailure()
}
}
private fun endWithAuthFailure(): Boolean {
wantConnection = false
_state.value = WsState.Offline
onAuthFailed()
return false
}
/** 建立一次连接并挂起,直到它关闭 / 失败 / 被取消。 */
private suspend fun runSession(): SessionEnd {
val url = buildUrl() ?: return SessionEnd.ConfigMissing
_state.value = if (attempt == 0) WsState.Connecting else WsState.Reconnecting
val finished = CompletableDeferred<SessionEnd>()
val socket = wsClient.newWebSocket(
Request.Builder().url(url).build(),
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
attempt = 0
_state.value = WsState.Connected
}
override fun onMessage(webSocket: WebSocket, text: String) {
// 未知 type / 坏帧由 parse 返回 null,静默丢弃。
WsEvent.parse(text)?.let { _events.tryEmit(it) }
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
finished.complete(SessionEnd.Closed)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
val end = if (isAuthFailure(response, t)) SessionEnd.AuthFailed else SessionEnd.Closed
finished.complete(end)
}
},
)
webSocket = socket
return try {
finished.await()
} finally {
if (webSocket === socket) webSocket = null
socket.cancel()
}
}
/**
* 握手鉴权失败是**握手前**的 HTTP 401(不是 WS close),OkHttp 会把它随 [onFailure] 的
* `response` 一起回调;个别路径拿不到 response 时再退化为消息文本匹配。
*/
private fun isAuthFailure(response: Response?, t: Throwable): Boolean {
if (response?.code == 401) return true
return t.message?.contains("401") == true
}
private fun buildUrl(): HttpUrl? {
val token = tokenProvider()?.takeIf { it.isNotBlank() } ?: return null
val base = baseUrlProvider()?.takeIf { it.isNotBlank() } ?: return null
val httpUrl = ApiClientFactory.normalizeBaseUrl(base).toHttpUrlOrNull() ?: return null
return httpUrl.newBuilder()
.addPathSegments("ws/chat")
.addQueryParameter("token", token)
.build()
}
/** 1s/2s/4s/8s/16s/30s,上限 30s;加 0~25% 抖动避免同时重连。 */
private fun backoffDelayMs(attempt: Int): Long {
val shift = attempt.coerceIn(0, MAX_BACKOFF_SHIFT)
val base = (INITIAL_BACKOFF_MS shl shift).coerceAtMost(MAX_BACKOFF_MS)
val jitter = Random.nextLong(base / JITTER_DIVISOR + 1)
return (base + jitter).coerceAtMost(MAX_BACKOFF_MS)
}
private fun ensureScope(): CoroutineScope =
scope ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
private enum class SessionEnd { Closed, AuthFailed, ConfigMissing }
private companion object {
const val PING_INTERVAL_SECONDS = 30L
const val CONNECT_TIMEOUT_SECONDS = 10L
const val INITIAL_BACKOFF_MS = 1_000L
const val MAX_BACKOFF_MS = 30_000L
const val MAX_BACKOFF_SHIFT = 5
const val JITTER_DIVISOR = 4L
const val EVENT_BUFFER = 512
const val CLOSE_CODE_NORMAL = 1000
const val CLOSE_REASON_CLIENT = "client closed"
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,163 @@
package top.yeij.cyrene.data.ws
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* 上行事件(客户端 → 服务端)。
*
* 帧壳:`{type, reqId?, data?}`(api-reference §6.2)。字段名逐字对齐 §6.3:
* `chat` / `interrupt` / `voice_input` / `mode_switch` / `subagent_ask` / `task_output_query`。
*
* 序列化用 [buildJsonObject] 显式拼装,而不是 `@Serializable` 数据类,原因:
* 上行帧是「信封 + 变长 data」,这样能保证 `type` 与 `data` 的嵌套结构不随类字段变化,
* 且可选字段为 `null` 时整键省略(与后端 `omitempty` 语义一致)。
*/
sealed interface WsOutbound {
/** 事件类型,即帧壳的 `type`。 */
val type: String
/** 请求关联 id;服务端当前不回填(api-reference §6.3),预留。 */
val reqId: String? get() = null
/** `data` 负载;返回 `null` 时省略该键。 */
fun data(): JsonElement?
/** 编码成一条文本帧。 */
fun toJson(): String = buildJsonObject {
put("type", type)
reqId?.let { put("reqId", it) }
data()?.let { put("data", it) }
}.toString()
/** `chat`:`{sessionId?, text, image?, deviceId?, clientMsgId?, parallel?}`;text+image 至少一项。 */
data class Chat(
val text: String,
val sessionId: String? = null,
val deviceId: String? = null,
val image: String? = null,
/**
* 客户端幂等键(契约 v0.56 §3.13):同一会话内同一个非空值只承认第一条,
* 重复到达服务端回下行帧 `chat_duplicate`、**不落库、不起回合**。省略 = 老行为逐字不变。
*/
val clientMsgId: String? = null,
/**
* **请求把本条作为独立一路处理**(忙时并行规范 §2.2,**纯增量**)。
*
* `null`(默认)= **整键省略**:行为与加入该字段之前**逐字相同**(忙时落回排队 + 回合内 steer)。
* 用户把「她忙时发消息」那栏选成「并行」**且会话正忙**时才给 `true`(判据是纯函数
* `parallelForSend`;另外两档与空闲时都不带这个键——线上取值由 `parallelWireValue` 翻)。
*
* 服务端**照准也不报错**(额度满 ⇒ 落回排队并回 `pending_ack{reason:"parallel_limit"}`;
* 已被在飞路 steer 吃进 ⇒ 回 `steer_ack`),所以这个字段永远不该引来一个错误帧。
*/
val parallel: Boolean? = null,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "chat"
override fun data(): JsonElement = buildJsonObject {
sessionId?.let { put("sessionId", it) }
put("text", text)
image?.let { put("image", it) }
deviceId?.let { put("deviceId", it) }
clientMsgId?.let { put("clientMsgId", it) }
// 布尔字面量走 JsonPrimitive:`put(key, Boolean)` 的重载在不同 kotlinx 版本里形态不一,
// 显式构造不会有歧义,也保证 `null` 时**整键省略**(与后端 `omitempty` 同义)。
parallel?.let { put("parallel", JsonPrimitive(it)) }
}
}
/** `interrupt`:`{sessionId, scope?, turnId?}`。命中空闲会话为 no-op。 */
data class Interrupt(
val sessionId: String,
/**
* 打断范围(契约 v0.56/A2,api-client §3.1):取值域 `{"", "all", "current"}`。
* `null` = **整键省略**——服务端视同 `"all"`(取消在跑回合 + 清空待处理队列),
* 也就是加入该字段之前那条帧**逐字节相同**;`"current"` = 只取消在跑回合、**保留**待处理队列。
* 其它取值服务端定向回 `error{bad_request}`(不打断任何会话)——故本层**原样透传**,
* 不做取值校验、不静默回退成默认(服务端的开放取值域由服务端裁决)。
*/
val scope: String? = null,
/**
* 只取消**这一路**(忙时并行规范 §2.3,**纯增量**)。
*
* 含义按 [scope] 分(规范的表):`"current"` + 给了 `turnId` ⇒ **只停该路**,其余路与 pending
* 不受影响;`"current"` + 省略 ⇒ 取消**全部在途路**、保留 pending(现状语义的自然推广);
* 省略 / `""` / `"all"` ⇒ 忽略本字段(取消全部 + 清 pending)。
*
* `null` = **整键省略** ⇒ 与加入该字段之前**逐字节相同**。本端今天同样一个字节都不发它:
* 唯一的调用点(`ChatViewModel` 的「她正忙时按发送」偏好)走的正是「取消全部在途路」那一档,
* 不需要按路精确停(规范未决问题 12 的建议默认:默认目标 = 全部在途路)。
*/
val turnId: String? = null,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "interrupt"
override fun data(): JsonElement = buildJsonObject {
put("sessionId", sessionId)
scope?.let { put("scope", it) }
turnId?.let { put("turnId", it) }
}
}
/** `voice_input`:`{audio?, format?, text?, sessionId?}`。 */
data class VoiceInput(
val audio: String? = null,
val format: String? = null,
val text: String? = null,
val sessionId: String? = null,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "voice_input"
override fun data(): JsonElement = buildJsonObject {
audio?.let { put("audio", it) }
format?.let { put("format", it) }
text?.let { put("text", it) }
sessionId?.let { put("sessionId", it) }
}
}
/** `mode_switch`:服务端原样广播回显,负载形状不限,这里发 `{mode}`。 */
data class ModeSwitch(
val mode: String,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "mode_switch"
override fun data(): JsonElement = buildJsonObject { put("mode", mode) }
}
/** `subagent_ask`:`{id, mode?, n?, query?}`。`mode` ∈ `""/full/tail/keyword`。 */
data class SubagentAsk(
val id: String,
val mode: String? = null,
val n: Int? = null,
val query: String? = null,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "subagent_ask"
override fun data(): JsonElement = buildJsonObject {
put("id", id)
mode?.let { put("mode", it) }
n?.let { put("n", it) }
query?.let { put("query", it) }
}
}
/** `task_output_query`:`{taskId}`。 */
data class TaskOutputQuery(
val taskId: String,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "task_output_query"
override fun data(): JsonElement = buildJsonObject { put("taskId", taskId) }
}
}
@@ -0,0 +1,35 @@
package top.yeij.cyrene.data.ws
/**
* 实时通道连接状态。
*
* 迁移:`Idle → Connecting → Connected → Reconnecting(退避) → Offline`。
* `Offline` 只在**不可恢复**的情况下出现(握手 401 / 缺少 token 或服务器地址),
* 需要上层重新登录或配置服务器后再调 [ChatSocket.connect]。
*/
enum class WsState {
/** 未启动:初始态,或主动 [ChatSocket.close] 之后。 */
Idle,
/** 首次握手进行中。 */
Connecting,
/** 已连接,可收发帧。 */
Connected,
/** 连接断开,正在按退避策略等待重连。 */
Reconnecting,
/**
* 不可恢复(握手 401 / 缺少 token 或地址),不会自动重连。
*
* ⚠️ **握手拿到「非 101 且非 401」的响应不属于这一档**:那种失败(例如中间设备/代理直接回
* `503`、`502`,或握手被打断)在 [ChatSocket.isAuthFailure] 里判不出鉴权失败 ⇒ 走
* [Reconnecting] 按退避**继续重连**。判据只认 `response.code == 401`(拿不到 response 时再退化
* 成消息文本里含 `401`),所以「代理回 503」时 socket 的真值是 [Reconnecting],不是 [Offline]。
* 这一条在发送阶梯上很要命:它决定了「WS 未就绪」时**要不要**去试一次 REST
* ([top.yeij.cyrene.ui.chat.sendRouteFor] 只在真 [Offline] 上判进队列、不试;W41 起如此,
* W45 起同一条判据仍然成立,只是那一步从「探一次健康端点」换成了「直接试 REST」)。
*/
Offline,
}
@@ -0,0 +1,91 @@
package top.yeij.cyrene.domain.admin
/**
* 管理侧权限矩阵(api-client §2.0)的**客户端镜像**。
*
* 后端契约明确要求客户端自行降级(`Docs/api-client.md`:「客户端需按本矩阵自行降级」):
* 不该读的页**不发请求**,而不是发出去等 403。这里的判断是纯函数,便于单测。
*/
/**
* 管理页可见性分档,按权限递增。
*
* - [Monitor]:api-client §2.0 的①档,`owner`/`admin`/`viewer` 均可读(只读非敏感投影)。
* - [OwnerAdmin]:②档,仅 `owner`/`admin`(含他人数据 / 隐私正文),`viewer` 一律 403。
*/
enum class AdminTier(val rank: Int) {
Monitor(0),
OwnerAdmin(1),
}
/** 已接线角色,`rank` 与后端 `owner>admin>viewer` 递增一致。 */
enum class AdminRole(val rank: Int) {
Viewer(0),
Admin(1),
Owner(2),
;
companion object {
/**
* 角色名解析。大小写 / 首尾空白不敏感;**未知或空值按 [Viewer] 处理**——
* 与服务端一样 fail-closed:宁可少给可见面,也不去撞预期中的 403。
*/
fun of(raw: String?): AdminRole = when (raw?.trim()?.lowercase()) {
"owner" -> Owner
"admin" -> Admin
else -> Viewer
}
}
}
/**
* 管理页清单:只声明「有哪些页 + 需要哪一档权限」。
* 入口页、ViewModel 与各页标题都按本清单驱动,新增管理模块只需在此追加一项。
*/
enum class AdminPage(
val title: String,
val summary: String,
val required: AdminTier,
) {
Overview("概览", "请求指标与最近 trace", AdminTier.Monitor),
Personas("人格", "角色目录与思考旋钮", AdminTier.Monitor),
Memories("记忆", "记忆正文(scope / 内容)", AdminTier.OwnerAdmin),
Backthink("后台思考", "后台思考与主动推送记录", AdminTier.OwnerAdmin),
}
/** 当前角色能否使用该页;不能则 UI 禁用入口、ViewModel 直接给「无权限」。 */
fun canAccess(role: AdminRole, page: AdminPage): Boolean = role.rank >= page.required.rank
/**
* 能否接收可观测实时推送帧(`observability_metrics` / `observability_trace` /
* `observability_log`,契约 §3.12)。
*
* 这三帧**不在**那 7 个「不含内容的状态机信号」白名单里,服务端按 fail-closed 只发
* **owner + admin**。注意这与 [AdminPage.Overview] 的可见性**是两回事**:概览页本身是
* [AdminTier.Monitor],`viewer` 也能进、也能读它背后的三个 REST 端点(v0.40 只读监控档),
* 但它**一帧推送都收不到**(服务端不推,客户端也就不该开连接去等)。
*
* 客户端按同一档自行降级:不够档就**不开连接、不合并帧**,而不是等「怎么一直没帧」再猜。
* 注意概览页背后的三个 REST 读取(metrics / trace / logs)都落在同一档只读监控里、`viewer` 全读得到:
* 也就是 `viewer` 看得到**进页面那一刻的快照**、收不到任何增量。这条不对称对三块都成立,
* 日志那块之所以另写成 [canReadObservabilityLogHistory],是因为它还要决定「区块渲不渲染」。
*/
fun canReceiveObservabilityFrames(role: AdminRole): Boolean =
role.rank >= AdminTier.OwnerAdmin.rank
/**
* 能否读取「最近日志」的**历史**(`GET /api/v1/admin/observability/logs`)。
*
* 该端点在 v0.40 的 12 条 `viewerAuth` 只读监控档里,与 [AdminPage.Overview] 同档 ⇒
* `viewer` 也读得到。它与 [canReceiveObservabilityFrames] **刻意不对称**,这是契约的安排、
* 不是缺口:
*
* - `viewer`:读得到进页面那一刻的历史,**收不到**之后的增量;
* - `owner` / `admin`:历史与增量都有。
*
* ⇒ 「最近日志」区块的**内容**只由这次历史读取决定;收不到增量既不是错误也不是空态,
* UI 不得据此给出任何失败 / 空提示,也不该为它开一条连接去干等(连接只在
* [canReceiveObservabilityFrames] 够档时才建立)。写成纯函数是为了让这条口径只有一个落点,
* 而不是散在各处 `if` 里靠人记。
*/
fun canReadObservabilityLogHistory(role: AdminRole): Boolean = canAccess(role, AdminPage.Overview)
@@ -0,0 +1,26 @@
package top.yeij.cyrene.domain.admin
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
/**
* 管理页取数失败。**「无权限」必须与其它失败分开**:UI 要能给出「无权限」状态,
* 而不是把 403 当成空列表渲染成「暂无数据」。
*/
sealed interface AdminFailure {
/** HTTP 403(角色不够)或 401(会话失效)。 */
data object Forbidden : AdminFailure
data class Other(val message: String) : AdminFailure
}
/** 把仓库层的 `Result` 失败收敛成 [AdminFailure];成功时返回 null。 */
fun Throwable.toAdminFailure(): AdminFailure {
val error = (this as? CyreneException)?.error
return when {
error is CyreneError.Unauthorized -> AdminFailure.Forbidden
error is CyreneError.Server && error.httpStatus == 403 -> AdminFailure.Forbidden
error != null -> AdminFailure.Other(error.userMessage)
else -> AdminFailure.Other("操作失败,请稍后重试")
}
}
@@ -0,0 +1,148 @@
package top.yeij.cyrene.domain.admin
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import top.yeij.cyrene.domain.enums.EnumCatalog
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.parse
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
private val LocalTime = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
/**
* RFC3339(trace 的 `startedAt` / `endedAt`、trace event 的 `time`)→ 本地时间文本。
* 秒精度串与纳秒精度串都要吃得下;解析不了就**原样返回**,绝不静默清空。
*/
fun formatRfc3339(raw: String?): String? {
val text = raw?.takeIf { it.isNotBlank() } ?: return null
return runCatching {
OffsetDateTime.parse(text).atZoneSameInstant(ZoneId.systemDefault()).format(LocalTime)
}.getOrDefault(text)
}
/**
* RFC3339 → epoch 秒,供按时间排序用(日志 `time` 只有秒精度,同秒是很常见的并列)。
*
* 解析不了返回 null——**绝不**在这里退回「当成 Unix 毫秒」:日志 / trace 的 `time` 是 RFC3339,
* backthink 的 `at` 才是 Unix 毫秒([formatEpochMillis]),两种形状刻意不共用解析路径
* (api-client §1)。调用方对 null(时间判不出来)需自行给一个保守的次序,但**不得丢弃**条目。
*/
fun rfc3339EpochSecond(raw: String?): Long? = raw
?.takeIf { it.isNotBlank() }
?.let { runCatching { OffsetDateTime.parse(it).toEpochSecond() }.getOrNull() }
/**
* Unix 毫秒(backthink 的 `at`)→ 本地时间文本。
* 与 [formatRfc3339] 是**两套输入**,刻意不共用解析路径(api-client §1 的两种时间形状)。
*/
fun formatEpochMillis(millis: Long): String =
Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).format(LocalTime)
/** 纳秒时长 → 按量级挑单位。 */
fun formatNanos(nanos: Long): String = when {
nanos < 1_000 -> "$nanos ns"
nanos < 1_000_000 -> String.format(Locale.ROOT, "%.1f µs", nanos / 1_000.0)
nanos < 1_000_000_000 -> String.format(Locale.ROOT, "%.1f ms", nanos / 1_000_000.0)
else -> String.format(Locale.ROOT, "%.2f s", nanos / 1_000_000_000.0)
}
/**
* 计数表(`perMethod` / `perPath` / `statusCounts`)→ 一行文本,按次数倒序。
* 键名一律原样保留(开放集合,不做白名单过滤,也不截断条目)。
*/
fun formatCounts(counts: Map<String, Long>): String =
counts.entries
.sortedWith(compareByDescending<Map.Entry<String, Long>> { it.value }.thenBy { it.key })
.joinToString(" · ") { (key, value) -> "$key $value" }
/** 后端用 float64 表达计数 / 毫秒,整数时抹掉无意义的小数位。 */
fun formatDouble(value: Double, decimals: Int = 2): String =
BigDecimal(value).setScale(decimals, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
/** 任意 `props` 对象 → 紧凑 JSON 文本,原样展示(不裁剪、不丢键)。 */
fun formatProps(props: Map<String, JsonElement>?): String? =
props?.takeIf { it.isNotEmpty() }?.let { JsonObject(it).toString() }
private val traceKindLabels = mapOf(
"llm_call" to "LLM 调用",
"tool_call" to "工具调用",
"orchestration" to "编排",
"state_transition" to "状态迁移",
"subagent" to "子代理",
"memory" to "记忆",
"guardrail" to "护栏",
)
private val traceStatusLabels = mapOf(
"ok" to "成功",
"error" to "失败",
)
private val memoryScopeLabels = mapOf(
"user" to "用户",
"persona" to "人格",
"project" to "项目",
)
/**
* 未知取值回落约定(api-client §1 红线):不在表中的取值**原样返回**——
* 不丢弃、不报错、不替换占位符。服务端新增取值属非破坏性变更。
*/
fun traceKindLabel(raw: String): String = traceKindLabels[raw] ?: raw
fun traceStatusLabel(raw: String): String = traceStatusLabels[raw] ?: raw
/**
* `mode` / `mood` 的标签来自 `/meta/enums` 的权威表([catalog]),客户端不再硬编码——
* 表里没有的取值同样原样返回([EnumCatalog] 负责该回落)。
* `trace` / `memory` 那几类**不在**该端点的表内,各自保留本地映射(见上)。
*/
fun personaModeLabel(raw: String, catalog: EnumCatalog): String = catalog.modeLabel(raw)
fun memoryScopeLabel(raw: String): String = memoryScopeLabels[raw] ?: raw
fun moodLabel(raw: String, catalog: EnumCatalog): String = catalog.moodLabel(raw)
/** 三态展示:null(未配置)与 true/false 是**三种不同**状态,不能合并。 */
fun triStateLabel(value: Boolean?): String = when (value) {
null -> "未配置"
true -> "显示"
false -> "不显示"
}
/** 空白字段的展示兜底:只处理「没有值」,不参与未知取值的回落。 */
fun orDash(raw: String?): String = raw?.takeIf { it.isNotBlank() } ?: "-"
/**
* 后台思考 `conclusion` 的一段:段内按渲染顺序排列的片段(正文 / 动作 / 旁白)。
* 段与段之间的间距由 UI 决定;段内片段顺序即原文顺序。
*/
data class ConclusionParagraph(val parts: List<ContentPart>)
/**
* `conclusion` → 有序渲染模型,供 UI 逐段渲染。
*
* 该字段承载的是**与聊天消息同一套 Web 端 markup**(真实样本里有连写的空 `<p></p>`、
* `<action>` 等),所以这里**不新增任何 markup 规则**,一律委托 [parse]:
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签按普通文本回落。
* 空段(`<p></p>` 连写产生的空白)不产出模型项——否则界面上会出现成片空行。
* 段内片段顺序取**渲染顺序**:`head` → 正文 → `tail`(与聊天页气泡上/下方旁白的排布一致)。
* `null` / 纯空白输入返回空列表。
*/
fun conclusionParagraphs(raw: String?): List<ConclusionParagraph> {
val text = raw?.takeIf { it.isNotBlank() } ?: return emptyList()
return parse(text).mapNotNull { bubble ->
val parts = buildList {
addAll(bubble.head)
if (bubble.body.isNotEmpty()) add(ContentPart.Text(bubble.body))
addAll(bubble.tail)
}
parts.takeIf { list -> list.any { it.text.isNotBlank() } }?.let(::ConclusionParagraph)
}
}
@@ -0,0 +1,156 @@
package top.yeij.cyrene.domain.admin
/**
* `perPath` 的一项:路径原文 + 命中次数 + 展示文本。
*
* [path] **不做任何改写**(便于与网关日志 / 后端逐字对齐、复制粘贴),
* 手机上要读的那一份在 [display](见 [PathStats.displayPath])。
*/
data class PathHit(
val path: String,
val count: Long,
val display: String,
)
/**
* 同一个前缀下的一组路径,组内已按命中次数倒序(同数按路径升序)。
* [hits] / [pathCount] 是组头要显示的两个数:**条数与次数不是一回事**
* (真实响应里 `/admin` 一组 = 15 条路径、283 次命中)。
*/
data class PathGroup(
val prefix: String,
val paths: List<PathHit>,
) {
val hits: Long get() = paths.sumOf { it.count }
val pathCount: Int get() = paths.size
/**
* 默认展开还是收起:条数多的大组默认**收起**,首屏因此只留一行组头,
* 展开是用户的一次明确动作(见 [PathStats.COLLAPSE_THRESHOLD])。
*/
val expandedByDefault: Boolean get() = pathCount <= PathStats.COLLAPSE_THRESHOLD
}
/**
* 分组列表里的一行。[Header] 可点、可折叠;[Leaf] 是一条路径。
* 只有一条路径的组**不产生组头**(组头与内容完全重复),直接给一条 [Leaf]。
*/
sealed interface PathRow {
data class Header(val group: PathGroup) : PathRow
/** [nested] = 该行在一个已展开的组里,UI 据此缩进;组外散条不缩进。 */
data class Leaf(val hit: PathHit, val nested: Boolean) : PathRow
}
/**
* 「按路径」统计的分组与展示模型(纯函数,不含任何 Compose / Android 依赖)。
*
* ## 为什么要客户端分组
* 契约(api-client §2「可观测性契约」)里 `perPath` 是**扁平**的 `路径 → 次数` 映射——
* 真实响应 26 条,后端不提供层级信息。之前它被塞进一行右对齐文本([formatCounts]),
* 手机上 26 条挤成一团、不可读,所以这里定义一套**可解释的分组规则**:
*
* **分组键 = 剥掉 [API_BASE_PREFIX] 后的首段**(`/api/v1/admin/llm/catalog` → `/admin`,
* `/assets/index-x.js` → `/assets`,`/sw.js` → `/sw.js`,`/` → `/`)。
*
* - **没有任何路径清单**:规则是纯字符串推导,服务端新增路由、新增路径形状都不需要改客户端
* (未知前缀自动成组,见单测里的 `/api/v1/quantum/entangle`)。
* - 剥掉 `/api/v1` 是因为它对 21/26 条路径完全相同,占满宽度却零区分度;
* 剥离**只影响分组键**,行里显示的仍是完整路径。
* - 比「按前两段分组」好:那样 `/admin/automations`、`/admin/personas` … 会切出十几个
* 只有一条路径的组,组头与内容重复,比不分组还难读。
* - 深层路径(段数 > [MAX_DISPLAY_SEGMENTS])在展示时**折叠中间段、保留尾部**:
* 尾部才是把同类路径区分开的部分(`…/admin/observability/metrics`),而整串在手机上
* 会被省略号吃掉尾部、丢掉区分度。
*
* 排序:组按 [PathGroup.hits] 倒序(热路径在前),组内按次数倒序——两处同数都按路径升序,
* 保证刷新前后顺序稳定(指标页每次刷新都重排会让读者跟丢)。
*/
object PathStats {
/** 所有 REST 端点共有的基址段,只在**分组键**里剥掉。 */
const val API_BASE_PREFIX = "/api/v1"
/** 组内路径条数超过它就默认收起。4 条以内的小组直接展开,省一次点击。 */
const val COLLAPSE_THRESHOLD = 4
/** 段数超过它的路径在展示时折叠中间段。 */
const val MAX_DISPLAY_SEGMENTS = 4
/** 折叠后保留的尾部段数。 */
const val RETAINED_TAIL_SEGMENTS = 3
/** 根路径(`/`)与异常空串的组名。 */
private const val ROOT_PREFIX = "/"
private val API_BASE_SEGMENTS = segments(API_BASE_PREFIX)
/**
* `perPath` → 分组列表:组按命中次数倒序,组内路径同样倒序。
* 空输入给空列表(UI 据此渲染「暂无路径统计」,**它是成功后的空**,与失败态无关)。
*/
fun groupPaths(perPath: Map<String, Long>): List<PathGroup> =
perPath.entries
.groupBy(
keySelector = { groupPrefix(it.key) },
valueTransform = { PathHit(path = it.key, count = it.value, display = displayPath(it.key)) },
)
.map { (prefix, paths) ->
PathGroup(
prefix = prefix,
paths = paths.sortedWith(compareByDescending<PathHit> { it.count }.thenBy { it.path }),
)
}
.sortedWith(compareByDescending<PathGroup> { it.hits }.thenBy { it.prefix })
/**
* 展开状态:**只记用户动过的组**([toggled] = 组前缀 → 是否展开),
* 其余按 [PathGroup.expandedByDefault]。这样刷新拿到新数据后,默认收起的大组不会被
* 上一轮的点按状态锁死,新出现的分组也自然落到默认档。
*/
fun expanded(group: PathGroup, toggled: Map<String, Boolean> = emptyMap()): Boolean =
toggled[group.prefix] ?: group.expandedByDefault
/** 分组列表 + 展开状态 → 一维行序列,UI 直接顺序渲染(组头之后紧跟它的子行)。 */
fun rows(groups: List<PathGroup>, toggled: Map<String, Boolean> = emptyMap()): List<PathRow> =
groups.flatMap { group ->
// 两个分支都写明 [PathRow]:否则 `flatMap` 会拿第一个分支的元素类型
// (`PathRow.Leaf`)去约束第二个分支,组头就加不进去了。
if (group.pathCount == 1) {
listOf<PathRow>(PathRow.Leaf(group.paths.single(), nested = false))
} else {
buildList<PathRow> {
add(PathRow.Header(group))
if (expanded(group, toggled)) {
group.paths.forEach { add(PathRow.Leaf(it, nested = true)) }
}
}
}
}
/**
* 展示文本:段数不超过 [MAX_DISPLAY_SEGMENTS] 时**原样返回**(不做任何归一化,
* 尾斜杠之类都保留);超过则折叠中间段、保留尾部 [RETAINED_TAIL_SEGMENTS] 段,
* 前缀 `…/` 明确表示左边还有内容被省掉。
*/
fun displayPath(path: String): String {
val parts = segments(path)
if (parts.size <= MAX_DISPLAY_SEGMENTS) return path
return "…/" + parts.takeLast(RETAINED_TAIL_SEGMENTS).joinToString("/")
}
/** 分组键:剥掉 [API_BASE_PREFIX] 后的首段,逐段比较(`/api/v1x/a` 不算基址下的路径)。 */
private fun groupPrefix(path: String): String {
val parts = segments(path)
if (parts.isEmpty()) return ROOT_PREFIX
val rest = if (parts.take(API_BASE_SEGMENTS.size) == API_BASE_SEGMENTS) {
parts.drop(API_BASE_SEGMENTS.size)
} else {
parts
}
return rest.firstOrNull()?.let { "/$it" } ?: API_BASE_PREFIX
}
private fun segments(path: String): List<String> = path.split('/').filter { it.isNotEmpty() }
}
@@ -0,0 +1,76 @@
package top.yeij.cyrene.domain.chat
/** 等比缩放后的目标尺寸(px)。 */
data class ScaledSize(val width: Int, val height: Int)
/**
* 发送前图片的**体积预算**与尺寸规划(纯函数,编码器与测试共用;不含任何 Android 依赖)。
*
* ## 为什么必须缩放 / 重编码
* 服务端把 `image` 字符串**原样落库**(api-client §1 行 3 + 协同文档 G3),于是
* `GET /sessions/{id}/messages` 的**每一页**、WS 握手 `history` 的**每一次回放**
* 都要把整段 base64 再传一遍。所以「发一张 8 MB 的原图」= 以后每次翻历史都重传 11 MB。
* 客户端预算是这个成本的唯一闸门,**不是优化**。
*
* 两层上限:
* - [MAX_DECODED_BYTES] = 1 MiB:**本端的**目标(缩放 + 重编码后必须 ≤ 它)——
* 服务端 5 MiB 只管「收不收」,管不了「历史有多重」;
* - [SERVER_MAX_DECODED_BYTES] = 5 MiB:服务端硬顶(解码后),超限 `400 image_too_large`。
* 它只作为**最后一道兜底检查**:真要走上传(`POST /api/v1/upload`)时再放宽。
*
* 校验按**字符串长度**([SERVER_MAX_CHARS])而不是解码字节数——与服务端入口层同一口径
* (`chatImageCharsFor`),这样两端不可能漂移。
*/
object ChatImageBudget {
/** 服务端 `image` 解码后上限:5 MiB(与 `POST /api/v1/upload` 同口径)。 */
const val SERVER_MAX_DECODED_BYTES = 5 * 1024 * 1024
/** `data:` URI 前缀(`data:image/jpeg;base64,`)的字符余量,与服务端同一取值。 */
const val PREFIX_ALLOWANCE_CHARS = 64
/** [byteCount] 字节 base64 编码后的字符数(每 3 字节 → 4 字符,末组补满)。 */
fun base64Chars(byteCount: Int): Int = ((byteCount + 2) / 3) * 4
/**
* 服务端 `image` 字符串长度上限(= [base64Chars] 的 4/3 膨胀 + 前缀余量)。
*
* 刻意复用 [base64Chars] 而不是把公式再内联写一遍:公式只留一处。代价是它只能是 `val`
* ——`const val` 不允许调用函数;取值在对象初始化时算出,与内联写法一致。
*/
val SERVER_MAX_CHARS: Int = base64Chars(SERVER_MAX_DECODED_BYTES) + PREFIX_ALLOWANCE_CHARS
/** 本端目标:重编码后的**字节**上限,远低于服务端硬顶(见类注释)。 */
const val MAX_DECODED_BYTES = 1 * 1024 * 1024
/**
* 最长边的目标像素。1280 与 provider 自己入模型前的缩放口径(约 1300×1300)同量级,
* 所以再放大不会有任何可见收益——省的是库与历史的字节,不是 token。
*/
const val MAX_DIMENSION_PX = 1280
/** 字符串长度是否在服务端上限内(入口层同一判据:`<=` 才算通过)。 */
fun withinServerLimit(dataUriChars: Int): Boolean = dataUriChars <= SERVER_MAX_CHARS
/** 编码后字节数是否已落到本端目标内。 */
fun withinTarget(byteCount: Int): Boolean = byteCount <= MAX_DECODED_BYTES
/**
* 等比缩放到最长边不超过 [maxDimension];**只缩不放**(小图保持原尺寸),
* 边长至少 1 px(极端细长图缩放后可能算成 0)。
*/
fun scaledSize(width: Int, height: Int, maxDimension: Int = MAX_DIMENSION_PX): ScaledSize {
val w = width.coerceAtLeast(1)
val h = height.coerceAtLeast(1)
val longest = maxOf(w, h)
if (longest <= maxDimension) return ScaledSize(w, h)
val ratio = maxDimension.toDouble() / longest
return ScaledSize(
width = (w * ratio).toInt().coerceAtLeast(1),
height = (h * ratio).toInt().coerceAtLeast(1),
)
}
/** 拼 `data:` URI([mime] 形如 `image/jpeg`,[base64] 为单行 base64)。 */
fun dataUri(mime: String, base64: String): String = "data:$mime;base64,$base64"
}
@@ -0,0 +1,51 @@
package top.yeij.cyrene.domain.chat
/** 一张**已重编码**的图片该怎么送上去。 */
enum class ImageDelivery {
/** 直接把 `data:` URI 塞进 `chat.image`:一次请求,但字节永久留在历史里。 */
Inline,
/** 先 `POST /api/v1/upload` 换 URL,再把 URL 当 `chat.image`:多一次往返,历史里只留几十字节。 */
Upload,
}
/**
* 内联(base64)与上传(URL)的分界(纯函数,VM 与测试共用)。
*
* ## 这是一笔什么账
* 服务端把 `chat.image` 字符串**原样落库**,所以两边的成本不是「发一次」而是「发多少次」:
*
* | | 发出时 | 之后每一次历史取数 |
* |---|---|---|
* | 内联 | 请求体里带 base64(4/3 × B 字符) | **再带一遍**(REST 每页、WS `history` 每次回放、每次上拉) |
* | 上传 | 多**一次** HTTP 往返;字节同样只传一遍 | 只剩那个 URL(几十字节) |
*
* 也就是说:上传不是「省一次流量」,是**用一次往返买断此后所有的历史重量**。
*
* ## 阈值:64 KiB([INLINE_MAX_BYTES],含等于)
* 重编码后的字节数 `B`,内联的代价是每次历史取数再多 `4/3 × B`:
* - `B = 64 KiB` → 每次取数 +85 KB。一个会话里 20 张这种图,每次冷启动 / 重连回放就要多传
* ≥1.7 MB(1 Mbps 下约 14 秒纯图片流量),而那次上传往返在局域网是毫秒、在移动网也就是
* 一两百毫秒——**从第二次取数起就稳赚**。
* - `B ≈ 32 KiB`(缩略图、小截图)→ 每次取数 +43 KB,省下的量与一次往返本身的量级相当,
* 而多出来的一次请求**必然**多一个失败面(上传失败就得让用户重发,内联不会)。
* 这种量级下「一次请求发完」更划算,所以留在内联。
*
* 阈值取整到 64 KiB 而不是更细的数,是因为它本来就是个成本量级的判断,不是服务端限制:
* 服务端的两个 5 MiB 上限(`chat.image` 与 `/upload`)离这里都还差两个数量级,
* **本决策与「装不装得下」无关**(重编码出口已保证 ≤ [ChatImageBudget.MAX_DECODED_BYTES])。
*/
object ChatImageDelivery {
/**
* 内联与上传的分界:**重编码后**的字节数(含等于 → 内联)。
*
* 必须严格小于 [ChatImageBudget.MAX_DECODED_BYTES],否则上传分支永远走不到(死代码);
* 这一条由 `ChatImageDeliveryTest` 钉住。
*/
const val INLINE_MAX_BYTES = 64 * 1024
/** @param preparedBytes [top.yeij.cyrene.data.image.ChatImagePreparer] 产出的字节数。 */
fun forPreparedBytes(preparedBytes: Int): ImageDelivery =
if (preparedBytes <= INLINE_MAX_BYTES) ImageDelivery.Inline else ImageDelivery.Upload
}
@@ -0,0 +1,64 @@
package top.yeij.cyrene.domain.chat
/** 一条消息该走哪条上行通道。 */
enum class SendChannel {
/** 短文本走 WS `chat` 帧:直达、绕过有损总线,且今天就能用。 */
Ws,
/** 长文本 / 任何图片走 REST `POST /api/v1/chat`(`stream:true`)。 */
Rest,
}
/**
* 上行路由规则(纯函数,VM 与测试共用)。
*
* ## 为什么必须有这条规则
* WS 帧的读上限是**服务端** `wsReadLimit = 4096` 字节/帧,且**超限由传输层直接断连**——
* 不是回一个错误,而是把这条连接踢掉(api-client §1 行 3 G3 的诚实边界)。所以
* 「长文发不出去」在今天不是「服务端拒绝」,是**发送帧把连接打断**。REST 没有这个上限,
* 且 `stream:true` 的回合同样在**已连接的 WS** 上流式回来 ⇒ 「REST 发、WS 收」不牺牲流式体验。
*
* 图片一律走 REST:真实图片的 base64 远超 4096 字节,在 WS 上**必然**断连。
* 文档附件同理一律走 REST(上传换引用这件事整个压在同一条通道上,见 [DocumentDelivery])。
*
* ## 阈值
* 判据是**文本的 UTF-8 字节数**(不是字符数——中文一个字 3 字节,按字符数算会在
* 333 个汉字时就撞上 4096)。上限取 [WS_TEXT_LIMIT_BYTES] = 4096 − 512 字节预留:
* 预留覆盖 `{"type","data":{"sessionId","text","deviceId","clientMsgId","image"}}` 的
* JSON 外壳(键名 + 值,UTF-8 最坏情况约 150 字节),并留出余量。留得保守是因为
* **超限的代价是断连**,而多走一次 REST 的代价只是几十毫秒——风险不对称,宁可早退。
*
* 边界:`utf8Length(text) <= WS_TEXT_LIMIT_BYTES` → WS;多 1 字节即 REST。
*/
object ChatSendRouting {
/** 服务端 WS 读上限(`wsReadLimit`,契约 v0.56 明确记载)。 */
const val WS_FRAME_LIMIT_BYTES = 4096
/** WS `chat` 帧 JSON 外壳的预留:键名 + `sessionId` / `deviceId` / `clientMsgId` 等值的字节数。 */
const val WS_ENVELOPE_RESERVE_BYTES = 512
/** 允许走 WS 的**文本**字节上限(不含图片:有图片一律走 REST)。 */
const val WS_TEXT_LIMIT_BYTES = WS_FRAME_LIMIT_BYTES - WS_ENVELOPE_RESERVE_BYTES
/** 文本的 UTF-8 字节数;`text` 为空返回 0。 */
fun utf8Length(text: String): Int = text.toByteArray(Charsets.UTF_8).size
/**
* 这一条该走哪条通道。
* @param hasImage 本次是否带图片(带图**永远**走 REST)
* @param hasDocuments 本次是否带**文档附件**(带附件**永远**走 REST):文档本体要先经
* `POST /api/v1/documents` 上传,引用只随 `POST /api/v1/chat` 发出——
* 「文档一律走 REST」见 [DocumentDelivery] 第 2 条。默认 false ⇒ 既有调用与旧行为逐字不变。
*/
fun channelFor(
text: String,
hasImage: Boolean,
hasDocuments: Boolean = false,
): SendChannel = when {
hasImage -> SendChannel.Rest
hasDocuments -> SendChannel.Rest
utf8Length(text) <= WS_TEXT_LIMIT_BYTES -> SendChannel.Ws
else -> SendChannel.Rest
}
}
@@ -0,0 +1,189 @@
package top.yeij.cyrene.domain.chat
import java.io.ByteArrayOutputStream
import java.io.InputStream
/**
* 一份**已选好、还没发出去**的待发附件(与 `ChatUiState.draftImages` 平行的概念)。
*
* 与 [DocumentFile] 的分工:[DocumentFile] 是「发送那一刻的字节」——上传需要它;
* 本类型是「还没发送的引用」——输入栏上方的芯片要显示它,而**绝不能**把最多 5×20 MiB
* 的字节一直摆在 UI 状态里(`ByteArray` 一进 `StateFlow` 就等于每次重组都摸一遍它)。
* 字节只在真的要上传时按 [uri] 现读(见 `ChatViewModel` 的发送路径)。
*
* 不用 `displayName` 去拼本地路径、也不改它的大小写:它**只用来给上传部件命名**
* (服务端要的是扩展名,其余部分会被内容寻址丢掉),以及给用户看。
*/
data class DraftDocument(
/** 本地可读 URI(SAF 的 `content://` 或分享落地后的 `file://`)。 */
val uri: String,
/** 给用户看的名字,含扩展名;空 = 让仓库层兜 `document.txt`。 */
val displayName: String,
/** 元数据里报的字节数;[UNKNOWN_SIZE] = 拿不到(此时不做本地体积预检,交给服务端裁决)。 */
val sizeBytes: Long = UNKNOWN_SIZE,
) {
companion object {
/** `ContentResolver` 拿不到 `SIZE` 时的取值:**不猜**,也不因此拒绝这个文件。 */
const val UNKNOWN_SIZE: Long = -1L
}
}
/**
* 一次「选文件」的结果:接受了哪些、又有哪些**没能接受**。
*
* 三个计数分开记,因为它们该说的话不一样,而且**一个都不许静默消失**——
* 「用户以为附件都挂上了、其实少了两个」正是本模块要消灭的东西。
*/
data class AttachmentPickResult(
val accepted: List<DraftDocument> = emptyList(),
/** 超过 [DocumentAttachments.MAX_FILE_BYTES] 而未被接受的显示名。 */
val tooLarge: List<String> = emptyList(),
/** 本回合附件已满([DocumentAttachments.MAX_PER_TURN])而未被接受的份数。 */
val overLimit: Int = 0,
/** 与已选附件重复、因此没有重复加入的份数。 */
val duplicate: Int = 0,
) {
/** 如实告知的那句话;没有任何东西被挡下时回 `null`(正常选中一个字都不说)。 */
val notice: String?
get() {
val parts = buildList {
if (tooLarge.isNotEmpty()) {
add(
"有 ${tooLarge.size} 个文件超过 " +
"${DocumentAttachments.maxFileSizeLabel}," +
"没有加入(${tooLarge.joinToString("、")})",
)
}
if (overLimit > 0) {
add("一个回合最多 ${DocumentAttachments.MAX_PER_TURN} 个附件,另有 $overLimit 个没有加入")
}
if (duplicate > 0) add("有 $duplicate 个和已选附件重复,没有重复加入")
}
return parts.takeIf { it.isNotEmpty() }?.joinToString(";")
}
}
/**
* 待发附件的准入规则(纯函数,VM 与测试共用)。
*
* ## 它为什么在客户端也要有一份
* 服务端才是裁决者(上限是 `CHAT_DOCUMENT_MAX_BYTES`,可部署配置;类型裁决只有字节嗅探一条),
* 所以这里的预检**只做一件服务端做不到的事**:**在把字节传上去之前**就说话。
* 一份 30 MiB 的文件照传一遍再吃 `413`,用户等的是上传的那几分钟,而结论本端现在就能给。
* 代价是这份上限可能与部署实际不同——因此一旦两边不一致,**服务端的话更权威**:
* 本端放过去的,服务端照旧能用 `413` / `415` 拦下(那两条文案会原样显示给用户)。
*
* ## 为什么不裁剪
* [admit] 只**不收**,从不**少收**:多出来的份数与超限的文件都进 [AttachmentPickResult],
* 由调用方如实告知。服务端对 >5 个回 `400 too_many_documents`、对非法名字回 `404`,
* 两边都不做静默裁剪,客户端也就不该在本地悄悄只发前 5 个。
*/
object DocumentAttachments {
/**
* 单份附件的本端预检上限。
*
* 取服务端文档上限的**默认值**(`CHAT_DOCUMENT_MAX_BYTES`,契约 v0.59 §1.2 = 20 MiB)。
* 刻意与图片上限(5 MiB,[top.yeij.cyrene.data.repository.UploadRepository] 那条)分开:
* 服务端把两者解耦,客户端合成一个数就会先自己把文档卡在图片的量级上。
*/
const val MAX_FILE_BYTES: Long = 20L * 1024 * 1024
/** 上限的可读说法(给提示文案用,避免两处各写一个数字)。 */
const val maxFileSizeLabel: String = "20 MB"
/** 一个回合的附件数上限,与判定矩阵同源([DocumentDelivery.MAX_ATTACHMENTS_PER_TURN])。 */
const val MAX_PER_TURN: Int = DocumentDelivery.MAX_ATTACHMENTS_PER_TURN
/**
* 把这次选中的文件并进已选列表。
*
* 顺序:**去重 → 体积 → 份数**。去重放在最前,是因为重复选同一份文件既不该被算成
* 「超限」也不该被算成「超体积」——它压根不是一个新附件。
*
* @param existing 已选附件(输入栏上方那些芯片)。
* @param picked 本次选中的文件(顺序即用户看到的顺序)。
*/
fun admit(existing: List<DraftDocument>, picked: List<DraftDocument>): AttachmentPickResult {
val seen = existing.mapTo(mutableSetOf()) { it.uri }
val tooLarge = mutableListOf<String>()
val fresh = mutableListOf<DraftDocument>()
var duplicate = 0
picked.forEach { document ->
if (!seen.add(document.uri)) {
duplicate++
return@forEach
}
// 体积拿不到(-1)时不判:本端猜不准的东西,留给服务端的 413 说。
if (document.sizeBytes > MAX_FILE_BYTES) {
tooLarge += document.displayName.ifBlank { document.uri }
return@forEach
}
fresh += document
}
val room = (MAX_PER_TURN - existing.size).coerceAtLeast(0)
val accepted = fresh.take(room)
return AttachmentPickResult(
accepted = accepted,
tooLarge = tooLarge,
overLimit = fresh.size - accepted.size,
duplicate = duplicate,
)
}
/** 从已选列表里去掉第 [index] 个(越界即原样返回,UI 的移除按钮不必自己判界)。 */
fun removeAt(existing: List<DraftDocument>, index: Int): List<DraftDocument> =
if (index !in existing.indices) existing else existing.filterIndexed { i, _ -> i != index }
/**
* URI 里能看出来的名字(`ContentResolver` 报不出 `DISPLAY_NAME` 时的兜底)。
*
* 取最后一段、去掉查询串;拿不到就回空串——**不猜**扩展名(扩展名真的参与服务端裁决,
* 猜错等于替用户把一个合规文件变成 `415`)。
*/
fun fallbackDocumentName(uri: String): String =
uri.substringBefore('?').substringAfterLast('/').takeIf { it.isNotBlank() }.orEmpty()
}
/**
* 读一份附件的字节时会遇到的三种结果。
*
* 「太大」与「读不到」必须分开:前者的正确动作是换文件,后者是把这份内容**再读一次**
* (授权过期、文件被删、provider 抽风)——折成一句「附件失败」会让用户做错事。
*/
sealed interface DocumentRead {
data class Ok(val bytes: ByteArray) : DocumentRead
/** 实际字节超过 [DocumentAttachments.MAX_FILE_BYTES]。 */
data object TooLarge : DocumentRead
/** 打不开 / 读不动(授权失效、文件已不在、provider 报错)。 */
data object Unavailable : DocumentRead
}
/**
* 从 [input] 读至多 [limit] 字节。
*
* **返回值语义**:`null` = 这份内容**超过了** [limit];IO 异常**原样抛出**(调用方要能把它
* 与「超限」分开说)。这样定是因为调用方必须给出不同的提示,而「读到一半发现超了」不能
* 靠 `available()` 猜——`available()` 在 `content://` 流上不可靠。
*
* 读满 [limit] 就停:一份被换成 500 MiB 的文件(选完之后、发送之前被替换)不会先在
* 内存里炸掉进程再报错。
*/
fun readBounded(input: InputStream, limit: Long): ByteArray? {
val buffer = ByteArrayOutputStream()
val chunk = ByteArray(DEFAULT_BUFFER_SIZE)
var total = 0L
while (true) {
val read = input.read(chunk)
if (read < 0) break
total += read
if (total > limit) return null
buffer.write(chunk, 0, read)
}
return buffer.toByteArray()
}
@@ -0,0 +1,127 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.data.api.dto.DocumentRefDto
import java.util.Locale
/**
* 一枚附件芯片要显示什么(纯数据,UI 直接照着渲染)。
*
* 字段刻意是**已格式化好的字符串**而不是原始数值:气泡里没有第二处地方能解释这些数,
* 而「12.0 KB」「8,000 字」这种换算一旦写进 Composable 就没法单测了。
*/
data class DocumentChipModel(
/** 服务端存储名(`<32hex>.<ext>`)或待发附件的本地显示名。 */
val name: String,
/** 类型标签(扩展名大写);判不出类型时是「文件」。 */
val typeLabel: String,
/** 体积标签;读不出来时是空串。 */
val sizeLabel: String,
/** 本回合注入的字符数;服务端没报就是 `null`。 */
val charsLabel: String?,
/** 抽取状态;服务端没报就回 `null`,未知取值**原样显示**。 */
val statusLabel: String?,
/**
* `truncated` 的标签:只表示**注入被预算截断**(正文照样进了上下文),
* 与 [unreadable] 是两件事,文案也必须分开写。
*/
val truncatedLabel: String?,
/** `status == "unreadable"`:这是唯一一个「昔涟读不到」的信号,UI 据此给灰态。 */
val unreadable: Boolean,
/**
* 服务端「读不到」的**原话**(`documents[].reason`,契约 v0.63;只在 `unreadable` 时下发)。
* 已去掉首尾空白,空白串与缺席一律归一成 `null`。
*
* ⚠️ **刻意不进 [detail]**:这句话有 40+ 字,塞进芯片那一行只会被 `Ellipsis` 切掉半句,
* 比不显示更糟。它要出现的地方是**用户点开附件后的预览弹层**,见
* [DocumentTarget.previewPlan]。
*/
val reason: String? = null,
) {
/** 芯片副行:`MD · 12 KB · 8,000 字 · 已读入`(缺哪项少哪项,全缺就是空串)。 */
val detail: String
get() = listOfNotNull(typeLabel, sizeLabel, charsLabel, statusLabel, truncatedLabel)
.filter { it.isNotEmpty() }
.joinToString(" · ")
}
/** 附件芯片的展示换算(纯函数,与 UI 无关)。 */
object DocumentChips {
/**
* 一条**已落库**的文档引用 → 芯片模型。
*
* 名字只有服务端存储名可用:服务端按内容寻址落盘(`sha256[:32] + ext`),**原始文件名
* 永不参与路径**,也就不会回到客户端。所以这里**不编**一个「用户当初叫它什么」的名字——
* 那会是本端凭空捏造的信息(历史消息里根本没有它)。长名字由 UI 侧
* `TextOverflow.MiddleEllipsis` 处理,保住扩展名。
*/
fun of(ref: DocumentRefDto): DocumentChipModel = DocumentChipModel(
name = ref.name,
typeLabel = typeLabel(ref.name),
sizeLabel = sizeLabel(ref.size),
charsLabel = charsLabel(ref.chars),
statusLabel = statusLabel(ref.status),
truncatedLabel = if (ref.truncated) TRUNCATED else null,
unreadable = DocumentStatus.isUnreadable(ref.status),
// 首尾空白去掉;只剩空白(或本就缺席)时归一成 null——「有一句空话」比没有更坏:
// 弹层会拿它顶掉那句通用说明,于是用户看到一块什么都没有的正文区。
reason = ref.reason?.trim()?.takeIf { it.isNotEmpty() },
)
/** 一份**待发**附件 → 芯片模型:还没有服务端结论,所以状态与字数为空。 */
fun of(draft: DraftDocument): DocumentChipModel = DocumentChipModel(
name = draft.displayName.ifBlank { draft.uri },
typeLabel = typeLabel(draft.displayName),
sizeLabel = sizeLabel(draft.sizeBytes),
charsLabel = null,
statusLabel = null,
truncatedLabel = null,
unreadable = false,
// 待发附件还没有任何服务端结论,也就没有「读不到的原因」可言。
reason = null,
)
/** 扩展名大写;没有扩展名或扩展名不像扩展名时回「文件」(不猜类型)。 */
fun typeLabel(name: String): String {
val extension = name.substringAfterLast('.', "")
val usable = extension.length in 1..8 && extension.all { it.isLetterOrDigit() }
return if (usable) extension.uppercase(Locale.ROOT) else NO_TYPE
}
/** 体积;[DraftDocument.UNKNOWN_SIZE](拿不到元数据)回空串,**不显示一个假的 0 B**。 */
fun sizeLabel(bytes: Long): String = when {
bytes < 0 -> ""
bytes < KIB -> "$bytes B"
bytes < MIB -> oneDecimal(bytes / KIB.toDouble()) + " KB"
else -> oneDecimal(bytes / MIB.toDouble()) + " MB"
}
/** 注入字符数;`null`(服务端没报这一项)回 `null`。 */
fun charsLabel(chars: Int?): String? =
chars?.let { String.format(Locale.ROOT, "%,d", it) + " 字" }
/**
* 抽取状态 → 文案。`""`(服务端未报)回 `null`,**未知取值原样显示**——
* 与项目里 `mode` / `platform` / `mood` 的开放集合口径一致,不丢弃、不占位符。
*
* 三句分开写是刻意的:[LISTED] 只说明「正文没进上下文」,**不是**读不到;
* [UNREADABLE] 才是读不到。
*/
fun statusLabel(status: String): String? = when (status) {
"" -> null
DocumentStatus.EXTRACTED -> "已读入"
DocumentStatus.LISTED -> "只看得到清单"
DocumentStatus.UNREADABLE -> "读不到"
else -> status
}
private fun oneDecimal(value: Double): String = String.format(Locale.ROOT, "%.1f", value)
private const val NO_TYPE = "文件"
private const val TRUNCATED = "注入已截断"
private const val KIB = 1024L
private const val MIB = 1024L * 1024L
}
@@ -0,0 +1,227 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.data.api.dto.DocumentRefDto
/**
* 一份待上传的本地附件。
*
* 只带「服务端裁决需要的两样」:[fileName](**扩展名真的参与裁决**——服务端把白名单扩展名与
* 字节嗅探**合取**,两者都过才放行;其余部分会被丢进内容寻址的存储名里)与 [bytes]。
*
* 不用 `data class`:`ByteArray` 的 `equals` 是引用比较,写成 `data class` 会给出一个
* 「看着像值语义、其实永远不等」的陷阱(测试里第一眼就会撞上)。
*/
class DocumentFile(
val fileName: String,
val bytes: ByteArray,
) {
val sizeBytes: Long get() = bytes.size.toLong()
}
/** 正文该内联发出,还是该先落成文档。 */
sealed interface DocumentPlan {
/** 照既有路由([ChatSendRouting])内联发出:没有附件,正文也没到「文件级」。 */
data object Inline : DocumentPlan
/**
* 先 `POST /api/v1/documents` 换成存储名,再用 `chat.documents` 引用发出。
* 调用方应把这一路整个交给 REST(见类型 KDoc 第 2 条的策略说明),不要在 WS 上重开一条。
*
* @param files 本地附件(≤ [DocumentDelivery.MAX_ATTACHMENTS_PER_TURN] 份)。
* @param textAsDocument 正文是否也要落成一份 `.txt` 一起当附件发。
*/
data class AsDocuments(
val files: List<DocumentFile> = emptyList(),
val textAsDocument: Boolean = false,
) : DocumentPlan
/**
* 明确拒绝:调用方**必须**给用户一个说法,**绝不静默丢**。
* 「静默丢」正是本模块要消灭的东西——一方以为附件发上去了、另一方一无所知。
*/
data class Rejected(val reason: DocumentRejection) : DocumentPlan
}
/** 本地就能判定的拒绝理由(服务端的同类拒绝见 [DocumentFailure])。 */
enum class DocumentRejection {
/** 一个回合的附件数超过上限:不做「只发前 5 个」这种裁剪,多出来的必须让用户看见。 */
TooManyAttachments,
}
/**
* 「**从别的 App 分享进来**的长正文」该怎么送(纯函数,见 [DocumentDelivery.planSharedText])。
*
* 只用于分享那条路。用户自己在输入框里打的字**永远**是 [Inline]——「不做长正文自动转文件」是
* 已拍板的产品定案(`devdocs/05` 的「三条待拍板项的定案」第 3 条),本枚举不改变它一个字。
*/
enum class SharedTextPlan {
/** 正文照正文发(没到 [DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT],或附件位不够,见下一档)。 */
Inline,
/**
* 正文落成一份 `.txt` 附件:上传 `POST /api/v1/documents` 之后随 `chat.documents` 发,
* **正文那一栏留空**。
*/
AsDocument,
/**
* 正文长到该走文件,但**附件位已经被分享进来的文件占满** ⇒ 只能按正文发。
*
* 调用方**必须**如实告知(分享那条路从不静默改形态)。取这一档而不是「把 .txt 塞进去、
* 挤掉一个用户真的分享进来的文件」:那些文件是用户**明确**要发的东西,正文换形态是**为了
* 少占篇幅**的便利——为了便利牺牲用户指名要发的内容,是不划算的交换。
*/
InlineAttachmentsFull,
}
/**
* 「该不该走文件」的判定与 `chat.documents` 载荷构造(纯函数,VM 与测试共用)。
*
* ## 为什么这条规则值得单独存在
* 三条互不相同的约束在客户端汇成一个决定,任何一条被忘掉都会出线上事故:
*
* 1. **WS 帧 4096 字节硬上限,且超限是直接断连**(不是回错误)⇒ 正文超过它必须走 REST
* —— 这一步**已经在** [ChatSendRouting] 里,本对象**不另立阈值**,只用 [textNeedsRest]
* 把它显式暴露出来,免得接线时把「超帧限」误当成「该转文件」;
* 2. **文档一律走 REST**(回执五 §一 S3-B 的明文规则):文档本体经
* `POST /api/v1/documents` 上传、引用随 `POST /api/v1/chat` 发出。实测后端 WS `chat` 帧
* **确实**能收 `documents`(`ws_hub_turn.go` 的入站结构与 REST 同形),所以这一条是
* **客户端策略**而非后端限制——理由是把「上传 → 引用 → 起回合」压在**一条**通道上,
* 不让两条入口在错误面(413 / 415 / 404 / too_many)上分叉。所以
* [DocumentPlan.AsDocuments] 的调用方不该再把这条消息交给 WS;
* 3. **长正文按文件送,比内联更划算**:内联的正文会进**每一页历史**、每一次 `history` 回放、
* 以及这一轮的完整上下文;落成文档后历史里只留一条引用(名 / MIME / 大小 / 字符数 /
* 状态),正文按预算注入、还能被 `document-read` 按游标**继续读**。
*
* ## 阈值与它们各自的分量(**别把三个数混成一个**)
* | 界线 | 值 | 谁定的 | 越过的后果 |
* |---|---|---|---|
* | WS 帧上限 | 4096 字节 | 服务端 `wsReadLimit` | **直接断连**(不是报错)⇒ 走 REST |
* | 走 REST 的正文上限 | 3584 字节 | 本端预留 512 字节 JSON 外壳 | 就是「换条通道」,无惩罚 |
* | **转文件的正文界线** | 4000 **rune** | **本端自定**(见下) | 换形态:正文变附件 |
*
* ⚠️ **第三行是客户端的产品判断,不是服务端拒绝线。** 契约里唯一的 4000 rune 上限是
* `PATCH /api/v1/messages/{id}` 的**编辑正文**上限(服务端 `maxMessageContentRunes`),
* **`POST /api/v1/chat` 根本没有正文长度上限**(已核 `api-client.md` §1 行 3 与后端
* `rest.go` / `ws_hub_turn.go` 的请求结构)。这里借它当「正文量级已经不像一条消息」的
* 参照——按 Unicode 码点计,与服务端 Go 的 rune 口径一致(代理对算一个)。
* 于是 [textNeedsFile] 只是**建议**:[plan] 会据此给出 [DocumentPlan.AsDocuments],
* 而**接线时要不要真的自动转换,是产品取舍**(建议先问用户:「这段内容较长,
* 作为文件发送?」),纯函数不替产品做决定、也不静默改变既有行为。
*
* 反过来,**正文只是超过 4096 字节(走 REST 就够)不该被当成文件**:那会平白改变一条
* 已经验证过的路径(长文本 → REST 内联)的语义,而 REST 本来装得下。
*/
object DocumentDelivery {
/** 一个回合的附件数上限:服务端 `maxChatDocuments = 5`(回执五 §一 S3-B),含等于。 */
const val MAX_ATTACHMENTS_PER_TURN: Int = 5
/**
* 正文「已经不像一条消息」的**客户端**界线(rune,含等于 → 仍算一条消息)。
*
* 取值参照契约里唯一的 rune 计数上限——`PATCH /api/v1/messages/{id}` 的 4000 rune
* (见类型 KDoc 的诚实说明:`/chat` 没有这条上限)。
*/
const val TEXT_AS_DOCUMENT_RUNE_LIMIT: Int = 4000
/** 长正文落成附件时的扩展名:服务端白名单内的 `.txt`(名字其余部分会被内容寻址丢弃)。 */
const val TEXT_DOCUMENT_FILE_NAME: String = "message.txt"
/** 按 Unicode 码点计数,与服务端 Go 的 rune 口径一致(代理对算一个)。 */
fun runeCount(text: String): Int = text.codePointCount(0, text.length)
/** 正文是否已经超过「一条消息的正文量级」⇒ 建议按文件发送。见类型 KDoc 的界线说明。 */
fun textNeedsFile(text: String): Boolean = runeCount(text) > TEXT_AS_DOCUMENT_RUNE_LIMIT
/**
* 正文是否连既有的 WS 通道都放不下 ⇒ 必须走 REST。
*
* 直接复用 [ChatSendRouting] 的判据(**不另立阈值**):这里只是把
* 「超帧限 = 换通道」与「超 rune 界线 = 换形态」两件事在名字上分开。
*/
fun textNeedsRest(text: String): Boolean =
ChatSendRouting.channelFor(text, hasImage = false) == SendChannel.Rest
/**
* 这一轮该怎么发。
*
* 判定顺序(先拒绝、再看附件、最后看正文):
* 1. 附件 > [MAX_ATTACHMENTS_PER_TURN] → [DocumentPlan.Rejected](**不裁剪**);
* 2. 有附件 → [DocumentPlan.AsDocuments](附件一律先上传换存储名);
* 3. 没附件、正文超 [TEXT_AS_DOCUMENT_RUNE_LIMIT] → [DocumentPlan.AsDocuments](正文落成 `.txt`);
* 4. 其余 → [DocumentPlan.Inline]。
*
* @param text 本轮的正文(调用方已 trim;空串 = 纯附件回合,服务端**支持**纯附件回合)。
* @param files 本轮要挂的本地附件。
*/
fun plan(text: String, files: List<DocumentFile>): DocumentPlan =
when (val shape = planForCount(text, files.size)) {
is DocumentPlan.AsDocuments -> shape.copy(files = files)
else -> shape
}
/**
* 同 [plan] 的判定矩阵,但**只看附件份数**。
*
* 为什么需要它:调用方(`ChatViewModel`)手上还没有字节——最多 5×20 MiB 一次性读进内存
* 是不可接受的,而判定矩阵里**唯一**与文件内容有关的就是份数(正文那一项看 [textNeedsFile])。
* 有了这一条,调用方就能在读字节之前先答出「要不要拒绝」「要不要走上传这条链」,
* 而不是先读一堆字节再发现自己根本不该发。
*
* @param attachmentCount 本回合要挂的附件份数。
*/
fun planForCount(text: String, attachmentCount: Int): DocumentPlan = when {
attachmentCount > MAX_ATTACHMENTS_PER_TURN -> DocumentPlan.Rejected(DocumentRejection.TooManyAttachments)
attachmentCount > 0 -> DocumentPlan.AsDocuments(files = emptyList(), textAsDocument = textNeedsFile(text))
textNeedsFile(text) -> DocumentPlan.AsDocuments(textAsDocument = true)
else -> DocumentPlan.Inline
}
/**
* 把长正文落成一份待上传的 `.txt`(服务端只看扩展名,名字其余部分会被丢弃)。
* 字节就是 UTF-8 编码——服务端嗅探得到的基类型因此是 `text/plain`。
*/
fun textAsFile(text: String, fileName: String = TEXT_DOCUMENT_FILE_NAME): DocumentFile =
DocumentFile(fileName = fileName, bytes = text.toByteArray(Charsets.UTF_8))
/**
* **分享路径**专用:这一轮的正文该怎么送([SharedTextPlan])。
*
* 为什么分享这条路要转、而输入框那条不转(两件事**不是**同一件):
* 分享进来的长正文往往是**别的平台把卡片/文章摊平之后的一整段字**(主人原话:「有的时候部分平台
* 发送到非官方支持卡片的平台会将卡片内容转化为比较长的字符」)——它不是用户抱着键盘敲出来的
* 一句话,而是一份**搬运进来的内容**,以文本消息发出去会把会话拉得很长。用户自己打的字则相反:
* 那是他要说的话,悄悄换成附件只会让它在历史里搜不到、气泡变成一枚芯片(`devdocs/05` 的定案)。
*
* 判据两步,都只有纯值输入:
* 1. 正文没到 [textNeedsFile] ⇒ [SharedTextPlan.Inline];
* 2. 到了、但附件位已经满([MAX_ATTACHMENTS_PER_TURN])⇒ [SharedTextPlan.InlineAttachmentsFull]
* (**如实告知**由调用方负责);否则 ⇒ [SharedTextPlan.AsDocument]。
*
* @param text 分享进来的正文(调用方已 trim)。
* @param attachmentCount 这一轮**已经**要挂的附件份数(分享进来的文件,不含 .txt 本身)。
*/
fun planSharedText(text: String, attachmentCount: Int): SharedTextPlan = when {
!textNeedsFile(text) -> SharedTextPlan.Inline
attachmentCount >= MAX_ATTACHMENTS_PER_TURN -> SharedTextPlan.InlineAttachmentsFull
else -> SharedTextPlan.AsDocument
}
/**
* 由**已上传**的服务端存储名构造 `chat.documents` 载荷。
*
* 只带 `name`(见 [DocumentRefDto]:服务端不采信其它字段并会覆盖),且**≤5 条**。
*
* @return `null` = 这份载荷**不合法**(超过 5 条,或有空名字),调用方**必须**给用户提示,
* **不得**截断到 5 条、也**不得**丢掉空名字那条——服务端对>5 回 `400 too_many_documents`、
* 对非法/不存在的名字回 `404 document_not_found`,两边都不做静默裁剪。
*/
fun payload(storageNames: List<String>): List<DocumentRefDto>? = when {
storageNames.size > MAX_ATTACHMENTS_PER_TURN -> null
storageNames.any { it.isBlank() } -> null
else -> storageNames.map { DocumentRefDto(name = it) }
}
}
@@ -0,0 +1,72 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
/**
* 文档操作失败(上传 / 取值)。契约 **v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」)**,错误码依据见
* `02-前后端协同事项.md` 回执五 §一 S1 与 §五「v0.59 会登记什么」。
*
* ## 为什么要把错误码收成类型
* 四个码在**用户能做的事**上各不相同,折进一个「操作失败」会让 UI 只能靠字符串猜:
*
* | 码 | HTTP | 用户能做什么 |
* |---|---|---|
* | [UnsupportedType] `unsupported_media_type` | 415 | **换文件**:这个格式不在白名单里(deny-by-default 的文本/代码扩展名,含 `.js` / `.py` / `.sh` 一律拒),改名 / 改声明都没有用——**字节嗅探是唯一裁决** |
* | [TooLarge] `document_too_large` | 413 | **换文件**:超过单文件上限(默认 20 MiB,可部署配置) |
* | [TooMany] `too_many_documents` | 400 | **少选几个**:一个回合最多 5 个附件 |
* | [NotFound] `document_not_found` | 404 | 重传:存储名形态非法或文件已不在(非法与不存在**刻意同码**,不给探测面) |
* | [Unreadable] `document_unreadable` | 400 | **只有显式索取正文时才会出现**(如 `document-read`),不是「上传失败」 |
*
* 每个分支都带 [message]:四种拒绝的文案由**服务端产出**(含真实上限),客户端原样展示、
* 不自己编数字(与 `image_too_large` 同一口径)。未知码与本地网络错误一律落 [Other],
* **原样带上服务端消息**,绝不因为「不认识这个码」就把失败吞掉。
*/
sealed interface DocumentFailure {
/** 可直接展示给用户的文案(服务端有话说时就是它说的那句)。 */
val message: String
/** `415 unsupported_media_type`:扩展名白名单外,或字节嗅探判出非文本。 */
data class UnsupportedType(override val message: String) : DocumentFailure
/** `413 document_too_large`:超过单文件字节上限(`CHAT_DOCUMENT_MAX_BYTES`,默认 20 MiB)。 */
data class TooLarge(override val message: String) : DocumentFailure
/** `400 too_many_documents`:一个回合挂的附件数超过 5。 */
data class TooMany(override val message: String) : DocumentFailure
/** `404 document_not_found`:存储名非法或文件不存在(两者同码)。 */
data class NotFound(override val message: String) : DocumentFailure
/** `400 document_unreadable`:正文取不回来(**仅显式索取正文时**)。 */
data class Unreadable(override val message: String) : DocumentFailure
/** 401 / 403:会话失效或角色不够(文档端点要求 `owner|admin`)。 */
data object Forbidden : DocumentFailure {
override val message: String get() = "登录状态已失效,请重新登录"
}
/** 其它失败:网络 / 超时 / 未知码 / 服务端 5xx——消息照原样带出来。 */
data class Other(override val message: String) : DocumentFailure
}
/** 把仓库层的 `Result` 失败收敛成 [DocumentFailure]。 */
fun Throwable.toDocumentFailure(): DocumentFailure {
val error = (this as? CyreneException)?.error
return when {
error is CyreneError.Unauthorized -> DocumentFailure.Forbidden
error is CyreneError.Server && error.httpStatus == 403 -> DocumentFailure.Forbidden
error is CyreneError.Server -> when (error.code) {
"unsupported_media_type" -> DocumentFailure.UnsupportedType(error.userMessage)
"document_too_large" -> DocumentFailure.TooLarge(error.userMessage)
"too_many_documents" -> DocumentFailure.TooMany(error.userMessage)
"document_not_found" -> DocumentFailure.NotFound(error.userMessage)
"document_unreadable" -> DocumentFailure.Unreadable(error.userMessage)
else -> DocumentFailure.Other(error.userMessage)
}
error != null -> DocumentFailure.Other(error.userMessage)
else -> DocumentFailure.Other("文档操作失败,请稍后重试")
}
}
@@ -0,0 +1,486 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.data.api.dto.DocumentRefDto
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
import top.yeij.cyrene.data.repository.LocalDocumentTooLargeException
import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
import java.util.Locale
/**
* 预览 / 打开 / 保存要处理的那一份附件**从哪来**。
*
* 判定只发生在一处([DocumentTarget.of]),预览取字节、交给别的 App、保存到下载目录三条路
* 都从这里取来源——散成两处就会出现「预览读的是服务端、打开读的是本地」这种半截行为。
*
* 两种来源是**真的事实差异**,不是实现细节:已落库的附件只有服务端存储名(消息 DTO 刻意不带
* 正文,见契约 v0.59 §1.2 ④),还没发出去的待发附件**只有**本地 URI(那时还没有存储名)。
*/
sealed interface DocumentSource {
/**
* 已落库消息里的附件:字节要按服务端存储名现拉(`GET /api/v1/documents/{name}`,要 Bearer)。
*
* [mime] 是 DTO 里那个**字节嗅探**所得的基类型——「用其它应用打开」就用它,
* 不从 [name] 的扩展名猜(见 [DocumentOpenTypes.forServer])。
*/
data class Server(val name: String, val mime: String) : DocumentSource
/** 待发附件:字节在本地 URI 上(SAF 的 `content://` 或分享落地后的 `file://`)。 */
data class Local(val uri: String, val displayName: String) : DocumentSource
}
/**
* 被点开的那一枚附件芯片(纯数据,UI 照着它渲染预览弹层)。
*
* [chips] 就是气泡里那枚芯片的模型([DocumentChips]),**刻意复用**:弹层再写一套
* 「类型 · 体积 · 字数 · 状态」的标签,两边迟早会有一处漏改状态语义。
*/
data class DocumentTarget(
val source: DocumentSource,
val chips: DocumentChipModel,
) {
/**
* 「用其它应用打开」要声明的类型。
*
* 服务端来源用 DTO 里嗅探所得的 [DocumentSource.Server.mime];本地来源只能来自平台声明
* (`ContentResolver.getType`,由调用方传进来)。**两条路都不从扩展名猜**:服务端的
* `data.json` 嗅探回的是 `text/plain`,按扩展名猜成 `application/json` 等于替服务端
* 说一个它刻意不承认的类型。
*/
fun openType(declaredLocalType: String? = null): String = when (val origin = source) {
is DocumentSource.Server -> DocumentOpenTypes.forServer(origin.mime)
is DocumentSource.Local -> DocumentOpenTypes.forLocal(declaredLocalType)
}
/**
* App 内**正文区**该怎么渲染([DocumentPreviewGate] 的裁决,纯函数)。
*
* 与 [openType] 一起把「哪个字段才是真源」的知识留在这一个类里:服务端来源用 DTO 的
* `mime`,本地待发附件**没有**服务端 `mime`(那时还没上传过),只能按显示名兜底。
* 本地那条**刻意不问** `ContentResolver.getType`——平台给 `.rs` 这类文件常报
* `application/octet-stream` 或空,拿它当判据会把一批纯文本文件挡在预览之外。
*
* 服务端下发的「读不到的原因」([DocumentChipModel.reason],契约 v0.63)在这里**顺路带进
* 裁决**:它是这句话唯一的去处(芯片那一行放不下 40+ 字),详见 [DocumentPreviewGate.plan]。
*/
fun previewPlan(): DocumentPreviewPlan = when (val origin = source) {
is DocumentSource.Server -> DocumentPreviewGate.plan(origin.mime, origin.name, chips.reason)
is DocumentSource.Local -> DocumentPreviewGate.plan("", origin.displayName, chips.reason)
}
/**
* 保存到下载目录时建议的文件名。
*
* ⚠️ **服务端来源只能用存储名**:落盘名是 `sha256(bytes)[:32] + ext`(内容寻址),
* 原始文件名**永不参与路径**、也不回客户端——DTO 里根本没有它。所以这里如实给
* `<32hex>.<ext>`,**绝不**替用户编一个「用户当初叫它什么」的假名字(那会是本端凭空的
* 信息)。本地待发附件倒是还有用户当初选的那份显示名。
*/
fun suggestedFileName(): String = when (val origin = source) {
is DocumentSource.Server -> origin.name
is DocumentSource.Local -> origin.displayName.ifBlank { FALLBACK_FILE_NAME }
}
companion object {
/** 已落库消息的附件引用 → 目标(来源恒为服务端)。 */
fun of(ref: DocumentRefDto): DocumentTarget =
DocumentTarget(DocumentSource.Server(ref.name, ref.mime), DocumentChips.of(ref))
/** 待发附件 → 目标(来源恒为本地)。 */
fun of(draft: DraftDocument): DocumentTarget =
DocumentTarget(DocumentSource.Local(draft.uri, draft.displayName), DocumentChips.of(draft))
}
}
private const val FALLBACK_FILE_NAME = "document.txt"
/**
* 「用其它应用打开」时 Intent 上要声明的 MIME。
*
* 与 `ChatScreen` 选文件时那份 [DocumentPickerMimeTypes] 相反:那是「让用户选得到」的宽口径,
* 这里是「交给别的 App 时声明它是什么」——**只有服务端嗅探(或平台声明)这一个真源**,
* 判不出来宁可用 `application/octet-stream`,也不按扩展名猜。
*/
object DocumentOpenTypes {
const val OCTET_STREAM: String = "application/octet-stream"
/** 服务端文档:DTO 的 `mime` 是嗅探所得基类型(唯一裁决);服务端嗅不出类型时也下发 `octet-stream`。 */
fun forServer(mime: String): String = mime.trim().ifBlank { OCTET_STREAM }
/** 本地待发文件:类型只可能来自平台声明;拿不到就回 `octet-stream`(不猜扩展名)。 */
fun forLocal(declared: String?): String = declared?.trim()?.takeIf { it.isNotEmpty() } ?: OCTET_STREAM
}
/** 「用其它应用打开」这一步的裁决结果。 */
sealed interface DocumentOpenPlan {
/** 系统里有 App 能处理这个类型:把 Intent 交出去。 */
data object HandOff : DocumentOpenPlan
/**
* 没有任何 App 能打开这个类型:**必须如实告知**,并指路「保存到下载目录」。
*
* 静默什么也不发生是最坏的结果——用户按了按钮,界面纹丝不动,他既不知道失败在哪,
* 也不知道还有别的出路。这条分支因此带一句现成的话,而不是一个光秃秃的布尔。
*/
data class NoHandler(val message: String) : DocumentOpenPlan
}
/** 「用其它应用打开」的裁决(纯函数:`PackageManager` 只回答 [plan] 的 `hasHandler` 那一问)。 */
object DocumentOpen {
/**
* @param type 交给 Intent 的 MIME([DocumentOpenTypes] 的产物)。
* @param hasHandler 系统里是否有 App 能处理 `ACTION_VIEW + type`(UI 层查 `PackageManager`)。
*/
fun plan(type: String, hasHandler: Boolean): DocumentOpenPlan =
if (hasHandler) DocumentOpenPlan.HandOff else DocumentOpenPlan.NoHandler(noHandlerMessage(type))
/**
* 没有任何 App 能处理 [type] 时的那句话。
*
* 单独暴露一个入口是因为「解析得到 handler」与「真的启动起来」之间还有一段窗口(App 被
* 卸载 / 被停用)——那条 `ActivityNotFoundException` 分支要说的是**同一句话**,不能在那里
* 另写一份文案(两份迟早会分叉)。
*/
fun noHandlerMessage(type: String): String =
"没有应用能打开「$type」格式的文件,可以「保存到下载目录」后再处理"
}
/**
* App 内**正文区**能不能按文本渲染。
*
* 弹层的三条出口与「正文区」是两件事:判成 [NotText] 时弹层**照常打开**(文件名 · 体积 ·
* 字数 · 状态与两条出口都在),只是**正文区留空** + 一句人话。
*/
sealed interface DocumentPreviewPlan {
/** 文本类:正文按 [DocumentPreview.slice] 的结果渲染。 */
data object Text : DocumentPreviewPlan
/**
* 非文本类(PDF 这类二进制内容):**绝不把字节当 UTF-8 渲染**(那会是一整屏乱码),
* 也**不假装加载失败**(这不是失败,字节取回来了,只是本端不预览它)。
*
* [message] 有两种来源,都是**原样显示**:本端按类型写的那句([DocumentPreviewGate.refusalMessage]),
* 或服务端下发的「读不到的原因」原话(契约 v0.63 的 `documents[].reason`)。
*/
data class NotText(val message: String) : DocumentPreviewPlan
}
/**
* 「App 内文本预览」这道闸(纯函数,可单测)。
*
* ## 为什么要有这道闸
* W11 的预览是把字节当 UTF-8 文本渲染的——那时白名单里的附件**除了 `.pdf` 之外全是文本类**
* (`.pdf` 正是 `v0.61` 才新增的第 23 项),所以「取回字节 → 解码 → 显示」在那些附件上是成立的。
* 纳入 `.pdf` 之后,同一段代码会把一份 PDF 渲染成一整屏二进制乱码:看着像文件坏了,
* 实际是本端拿错了渲染方式。
*
* ## 判据(优先服务端,退而扩展名)
* 0. `reason` 非空白 ⇒ **直接就是正文区那句说明,原样显示**(契约 v0.63:服务端说读不到时
* 下发的原话,比本端按类型编的那句准确)。它压过下面三条,代价见 [plan] 的 KDoc;
* 1. `mime` 非空 ⇒ **只看服务端那一次字节嗅探**(契约 §1.2:嗅探是唯一裁决权):
* 以 `text/` 开头的判为文本,其余(`application/pdf` / `application/octet-stream` / 图片型 …)
* 一律判为**非文本**;
* 2. `mime` 缺失 / 空白 ⇒ 按**扩展名**兜底:白名单 23 项里除 `.pdf` 外都是文本类,所以只有
* `.pdf` 判为非文本(这条兜底是给**待发附件**用的——它只有本地 URI 与显示名,没有服务端结论);
* 3. 未知 `mime`(既不是 `text/` 那一族、也不是认识的类型)⇒ **判为非文本**。两侧的代价不对称:
* 误判成非文本只是少一次预览(两条出口照旧完整可用),误判成文本却正是这道闸要消灭的乱码。
*
* 大小写不敏感,且**剥掉参数**(`text/plain; charset=utf-8` 的基类型是 `text/plain`)——
* 带参数的 Content-Type 是常态,不该因为多了一截 `; charset=…` 就整份附件不给预览。
*/
object DocumentPreviewGate {
/** `.pdf` 的类型标签:写进那句人话里(用户认得的是「PDF」而不是 `application/pdf`)。 */
const val PDF_LABEL: String = "PDF"
/**
* @param mime 服务端 DTO 里的 `mime`(嗅探所得基类型);待发附件传空串。
* @param name 服务端存储名或待发附件的显示名——**只在 [mime] 为空时才看它**。
* @param reason 服务端下发的「读不到的原因」原话(`documents[].reason`,契约 v0.63;
* 已由 [top.yeij.cyrene.domain.chat.DocumentChips] 去掉首尾空白并在空白时归一成 `null`),
* 缺席 = 服务端没给,或这条引用本来就不是 `unreadable`。
*
* [reason] 非空白时**它直接就是正文区那句说明,原样显示**,不再走本端按 [mime] / 扩展名的
* 裁决——理由是这句话已经断言了「无法抽取正文」:
* - 它比本端那句通用说明([refusalMessage]:只知道类型,不知道到底为什么)**准确**,
* 替它改写或归纳就是丢掉服务端唯一一次说清原因的机会;
* - 它也不该与正文并存:服务端说正文抽不出来,本端再把字节当 UTF-8 渲染,产出的正是这道
* 闸要消灭的「看着像文件坏了」。
*
* 代价说清楚:这条会让**服务端判为读不到、但字节其实下得动**的附件失去 App 内预览
* (例如超限的纯文本)。两条出口(用其它应用打开 / 保存到下载目录)照旧完整可用,
* 与下面「未知 mime 判为非文本」是同一笔不对称账。
*/
fun plan(mime: String, name: String, reason: String? = null): DocumentPreviewPlan {
if (!reason.isNullOrBlank()) return DocumentPreviewPlan.NotText(reason)
val base = baseType(mime)
if (base.isEmpty()) return extensionPlan(name)
return if (base.startsWith(TEXT_PREFIX)) DocumentPreviewPlan.Text else refuse(base)
}
/** 非文本那句人话。**说清「不是失败」**(两下都在弹层里摆着)并指路能拿到完整内容的两条出口。 */
fun refusalMessage(label: String): String =
"这是 $label,App 内不预览这类内容,请用「用其它应用打开」或「保存到下载目录」"
/** 去掉参数、去空白、转小写:`"text/plain; charset=utf-8"` → `"text/plain"`。 */
fun baseType(mime: String): String =
mime.substringBefore(';').trim().lowercase(Locale.ROOT)
/** 扩展名(小写、无点);没有扩展名时回空串。 */
private fun extensionOf(name: String): String =
name.substringAfterLast('.', "").trim().lowercase(Locale.ROOT)
private fun extensionPlan(name: String): DocumentPreviewPlan =
if (extensionOf(name) == PDF_EXTENSION) refuse(PDF_LABEL) else DocumentPreviewPlan.Text
private fun refuse(baseType: String): DocumentPreviewPlan {
// 已知是 PDF 就报「PDF」(用户认得这个名字),未知类型**原样报 mime**——绝不替服务端
// 编一个更好听的类型名。
val label = if (baseType == PDF_MIME) PDF_LABEL else baseType
return DocumentPreviewPlan.NotText(refusalMessage(label))
}
private const val TEXT_PREFIX = "text/"
private const val PDF_MIME = "application/pdf"
private const val PDF_EXTENSION = "pdf"
}
/** 预览的截断阈值(两个上限,**先到者为准**)。 */
object DocumentPreviewLimits {
/**
* 单次预览最多取前 256 KB 字节。
*
* 取值理由:附件上限是 20 MiB(`CHAT_DOCUMENT_MAX_BYTES`),一份 20 MiB 的文本全塞进单个
* `Text` 会让测量 / 布局在打开弹层那一刻卡住。256 KB 足够看清开头、翻不到底的都在下一条
* 出口(「用其它应用打开」/「保存」)——那两条给的是**完整**内容。
*/
const val MAX_BYTES: Int = 256 * 1024
/** 行数上限:与字节上限先到者为准(19 万行的短行文件在 256 KB 内也能有 10 万行)。 */
const val MAX_LINES: Int = 5_000
/**
* 正文分块的粒度(行)。
*
* [MAX_LINES] 5000 行如果是一个 `Text`,一次测量就要算整段排版;切成 200 行一块交给
* `LazyColumn`,只测当前可见的几块(最多 25 块)。
*/
const val LINES_PER_CHUNK: Int = 200
}
/** 预览为什么只显示了一段([None] = 完整显示)。 */
enum class PreviewTruncation {
None,
/** 撞上 [DocumentPreviewLimits.MAX_BYTES]。 */
Bytes,
/** 撞上 [DocumentPreviewLimits.MAX_LINES]。 */
Lines,
}
/**
* 预览正文的一段切片。
*
* [shownBytes] / [shownLines] 是**实际显示了多少**,[totalBytes] / [totalLines] 是全文多少。
* 截断说明([notice])里报的是**阈值本身**(256 KB / 5,000 行):实际显示量最多比阈值少 3 字节
* (UTF-8 回退那一刀),把它精确写进一句话只会得到「只显示前 262,143 字节」这种没人想看的东西。
*/
data class PreviewSlice(
val text: String,
val truncatedBy: PreviewTruncation,
val shownBytes: Int,
val shownLines: Int,
val totalBytes: Int,
val totalLines: Int,
) {
val truncated: Boolean get() = truncatedBy != PreviewTruncation.None
/**
* 截断时那句**必须明写**的话;完整显示时回 `null`(一个字都不说)。
*
* 静默截断是这一块的红线:用户看到一段像是「文件就这么长」的正文,会得出错误结论,
* 而真相(后面还有)**只有服务端知道**——所以这里既说「只显示前 N」,也指路两个能给
* 完整内容的出口。
*/
val notice: String?
get() {
val shown = when (truncatedBy) {
PreviewTruncation.None -> return null
PreviewTruncation.Bytes -> "${DocumentPreviewLimits.MAX_BYTES / 1024} KB"
PreviewTruncation.Lines -> "${grouped(DocumentPreviewLimits.MAX_LINES)} 行"
}
return "只显示前 $shown,完整内容请用「用其它应用打开」或「保存到下载目录」查看"
}
}
/** 文档正文的取段与分块(纯函数,UI 与测试共用)。 */
object DocumentPreview {
/**
* 取前 [DocumentPreviewLimits.MAX_BYTES] 字节 / [DocumentPreviewLimits.MAX_LINES] 行,
* 先到者为准。
*
* 顺序是「先字节、后行数」:字节上限决定能解码出多长的文本,行数上限在这一段里再切一次。
* 于是两者都超时,[PreviewSlice.truncatedBy] 报 [PreviewTruncation.Bytes]——那才是真正
* 先切断它的那一刀。
*/
fun slice(bytes: ByteArray): PreviewSlice {
val totalBytes = bytes.size
val totalLines = lineCount(bytes)
val byteLimited = totalBytes > DocumentPreviewLimits.MAX_BYTES
val prefix = if (byteLimited) {
utf8SafePrefixLength(bytes, DocumentPreviewLimits.MAX_BYTES)
} else {
totalBytes
}
var text = String(bytes, 0, prefix, Charsets.UTF_8)
val lineLimited = lineCount(text) > DocumentPreviewLimits.MAX_LINES
if (lineLimited) text = takeLines(text, DocumentPreviewLimits.MAX_LINES)
return PreviewSlice(
text = text,
truncatedBy = when {
byteLimited -> PreviewTruncation.Bytes
lineLimited -> PreviewTruncation.Lines
else -> PreviewTruncation.None
},
shownBytes = text.toByteArray(Charsets.UTF_8).size,
shownLines = lineCount(text),
totalBytes = totalBytes,
totalLines = totalLines,
)
}
/**
* 按行数分块([DocumentPreviewLimits.LINES_PER_CHUNK] 行一块)。
*
* 每块**保留自己的换行符**,因此把各块顺序拼回来等于原文——`LazyColumn` 逐块渲染时行距
* 不会因为切点而错位。空文本回空列表(UI 不必为它画一个空块)。
*/
fun chunks(text: String, linesPerChunk: Int = DocumentPreviewLimits.LINES_PER_CHUNK): List<String> {
if (text.isEmpty()) return emptyList()
if (linesPerChunk <= 0) return listOf(text)
val chunks = mutableListOf<String>()
var start = 0
var lines = 0
for (index in text.indices) {
if (text[index] != '\n') continue
lines++
if (lines == linesPerChunk) {
chunks += text.substring(start, index + 1)
start = index + 1
lines = 0
}
}
if (start < text.length) chunks += text.substring(start)
return chunks
}
/** 文本行数:`\n` 计数 + 末尾那行(末尾换行符不算额外一行);空文本是 0 行。 */
fun lineCount(text: String): Int = when {
text.isEmpty() -> 0
text.endsWith('\n') -> text.count { it == '\n' }
else -> text.count { it == '\n' } + 1
}
/**
* 字节层面的行数——**不解码整份文件**就能算出行数,因此 [slice] 报「共多少行」时
* 不必把 20 MiB 全文变成一个 `String`。
*
* 直接数 `0x0A`:UTF-8 的续字节(`0x80..0xBF`)与各多字节序列里都不可能出现 `0x0A`,
* 所以字节计数与字符计数一致。
*/
fun lineCount(bytes: ByteArray, length: Int = bytes.size): Int {
if (length <= 0) return 0
var newlines = 0
for (index in 0 until length) {
if ((bytes[index].toInt() and 0xFF) == NEWLINE) newlines++
}
return if ((bytes[length - 1].toInt() and 0xFF) == NEWLINE) newlines else newlines + 1
}
/**
* 在 [limit] 处取前截断时,把末尾一个**被切成两半的多字节 UTF-8 字符**退回。
*
* 不退的后果是正文末尾多出一个 `U+FFFD`(`String(bytes, UTF_8)` 的替换字符)——看着像
* 文件本身就是坏的,而实际上是本端自己切坏的。退回最多 3 字节即可(UTF-8 最长 4 字节)。
*
* @return 可以安全解码的字节数(≤ [limit])。输入本身不合法(整段都是续字节)时原样返回
* [limit]——不替一个坏文件找补。
*/
fun utf8SafePrefixLength(bytes: ByteArray, limit: Int): Int {
if (limit >= bytes.size) return bytes.size
if (limit <= 0) return 0
var lead = limit
var continuation = 0
while (
lead > 0 &&
continuation < UTF8_MAX_BYTES - 1 &&
(bytes[lead - 1].toInt() and 0xC0) == UTF8_CONTINUATION
) {
lead--
continuation++
}
if (lead == 0) return limit
val expected = when (bytes[lead - 1].toInt() and 0xFF) {
in 0x00..0x7F -> 1
in 0xC0..0xDF -> 2
in 0xE0..0xEF -> 3
in 0xF0..0xF7 -> 4
else -> 1
}
val available = limit - lead + 1
return if (available < expected) lead - 1 else limit
}
/** 取前 [count] 行(含第 [count] 行的换行符);不足 [count] 行时原样返回。 */
private fun takeLines(text: String, count: Int): String {
if (count <= 0) return ""
var seen = 0
for (index in text.indices) {
if (text[index] != '\n') continue
seen++
if (seen == count) return text.substring(0, index + 1)
}
return text
}
private const val NEWLINE = 0x0A
private const val UTF8_MAX_BYTES = 4
private const val UTF8_CONTINUATION = 0x80
}
/**
* 预览取字节失败时的文案(纯函数,可单测)。
*
* 与 [top.yeij.cyrene.ui.chat.documentUploadFailureMessage] 同一纪律:服务端能说清的就用它
* 的原话(含真实原因),本地能分类的就分类说,**绝不把失败渲染成「空文件」**。
*
* 三条本地失败必须分开,因为它们要用户做的事不一样:读不到 = 重新选一份;太大 = 换一份;
* 缓存写不进去 = 这是本端的问题,重试多半还是同样的结果。
*/
fun documentLoadFailureMessage(e: Throwable): String = when (e) {
is LocalDocumentUnavailableException -> "读不到这份文件,请重新选择"
is LocalDocumentTooLargeException -> "这份文件超过 ${DocumentAttachments.maxFileSizeLabel},无法预览"
is DocumentCacheUnavailableException -> "本地缓存不可用,暂时无法预览这份附件"
else -> when (val failure = e.toDocumentFailure()) {
// 服务端对「名字非法」与「文件不存在」刻意同码(不泄露差异)——本端只说能确定的那半句。
is DocumentFailure.NotFound -> "服务端上已经没有这份文件了"
is DocumentFailure.Other -> "附件加载失败," + failure.message
else -> failure.message
}
}
/** 千分位分组(只用于行数这类计数,纯展示)。 */
private fun grouped(value: Int): String = String.format(Locale.ROOT, "%,d", value)
@@ -0,0 +1,31 @@
package top.yeij.cyrene.domain.chat
/**
* 文档抽取状态(契约 **v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」)**,形状依据见 `02-前后端协同事项.md` 回执五 §一 S3-A/S3-B)。
*
* **开放集合,不用 enum**:后端的 `status` 是自由字符串,客户端映射只认这三个已知值,
* **未知值原样保留**(不丢弃、不报错、不占位符)——与 `mode` / `platform` / `mood` 同一口径。
*
* 三个值的语义(**别互相顶替**):
* - [EXTRACTED]:正文已抽取并进了这一轮的上下文;
* - [LISTED]:只给了模型一份附件清单(名 / MIME / 大小 / 结论),**正文没进上下文**;
* 它不是错误态——大文件、超预算、或本期不支持的格式都可能停在这里;
* - [UNREADABLE]:**读不到这个文件**。这是唯一一个「昔涟看不到内容」的可观测信号,
* 与「内容被截断」(`truncated`)是两件事:截断者正文照样进了上下文,只是尾巴被预算切掉。
*/
object DocumentStatus {
const val EXTRACTED: String = "extracted"
const val LISTED: String = "listed"
const val UNREADABLE: String = "unreadable"
/**
* 这条引用是否报告「读不到」。
*
* 只比 [UNREADABLE],**不猜**:服务端是本信号的唯一生产者(回合结束后回写该用户消息的
* document 列——用户消息是回合前落的、结论是回合中才知道的),客户端不做二次推断。
*/
fun isUnreadable(status: String): Boolean = status == UNREADABLE
}
@@ -0,0 +1,113 @@
package top.yeij.cyrene.domain.chat
/**
* 「保留 path、替换 host」:把历史图片里**绑死在上传那一刻的 host 上**的绝对地址,换到当前 baseUrl。
*
* ## 它修的是什么(`devdocs/02` 的 `F6①` + 回执十三 §2)
* `POST /api/v1/upload` 的 `url` 由服务端按**请求时**的 `r.Host`(+ `X-Forwarded-Proto` / TLS)
* 拼成绝对地址,并且**原样落库**(读侧不重写)。于是换一个服务器地址(自部署换机器、`adb reverse`
* 换个端口、域名从 IP 换成域名)之后,旧消息里的图片地址指向的还是**当时那个 host**,本端也就取不回
* 那些图。
*
* 后端确认「读取侧三条路」里**只有主模型直收图**(`multimodal=true`)会真的去 GET 那串绝对 URL,
* 并明确认可本端自己做这一层:「你可以先在客户端做『保留 path、替换 host』」。本对象的形态就是
* 那句原话的逐字落地:**只换 host,不碰 path**。
*
* ## 保守到什么程度(不认识的一律原样返回)
* 只认这一种形态,其余**一个字符都不改**:
*
* ```
* http(s)://<任意 host[:port]>/api/v1/files/<name>
* ```
*
* 具体判据(全部满足才替换,否则原样返回):
* - 以 `http://` 或 `https://` 开头(大小写不敏感);**其它一切**都不是这一层的事——`data:` 内联图、
* `content://` / `file://` 本地引用、相对路径、裸文件名,全都原样返回,不做任何猜测
* (与 [ImageSource.of] 的同一条纪律:形态不认识的,交给它原来那条路);
* - 路径**恰好**是 `/api/v1/files/` 再加**一段**名字:名字非空、不含 `/`、不含 `%`、不含 `?` / `#`,
* 也不是 `.` / `..`。多一段、带查询串或片段、带百分号编码的,都判为「不是我们认识的那个地址」
* ——第三方 CDN 的图与别人签过名的 URL 因此一个都不会被改;
* - [baseUrl] 本身能解出 `scheme://authority`(空串、没有 host 的都解不出,于是原样返回)。
*
* 替换后用的是 **baseUrl 的 scheme + authority**(所以 `http` → `https`、端口变更都跟着走),
* **不用** baseUrl 里的路径:服务端自己拼这条地址时只取 `r.Host`,读侧 `GET /api/v1/files/{name}`
* 也挂在根路径上,所以部署形态本来就没有路径前缀这一说。
*
* ## 逆作用:指向不同数据时会 404,那是**诚实的失败**
* 如果两个 host **指向的不是同一份存储**(换的是另一台机器、数据没迁过去),替换之后取到的就是
* `404`。这仍然比现状好:不替换的话,那个地址**永远**取不回(而且是「连不上/超时」这种更含糊的
* 失败),替换之后至少是一个说得清的结果,而图片那条路本来就会把失败如实报出来(见
* [imageLoadFailureMessage])。**绝不**因此退回「不替换」——那等于把「换地址后旧图还能看」这件事
* 彻底放弃。这条取舍是刻意的:宁可诚实地失败,也不要安静地永远失败。
*
* ## 不做的事
* 不改落库数据、不改上传、不碰 `documents`,也**不**在这一层做任何 IO:它只在渲染前过一遍字符串。
* 纯函数,可单测(`ImageHostRebaseTest`)。
*/
object ImageHostRebase {
/** 服务端上传产物的读侧路径(`api-client` §1 行 3:`GET /api/v1/files/{name}`)。 */
private const val FILES_PREFIX = "/api/v1/files/"
private const val HTTP_SCHEME = "http://"
private const val HTTPS_SCHEME = "https://"
/**
* 把 [raw] 里同源的图片地址换到 [baseUrl] 的 host 上;不认识的形态原样返回(判据见类型 KDoc)。
*
* 幂等:已经指向当前 host 的地址走完也逐字节不变。
*
* @param raw 历史消息里那份 `image` 字符串(或任何要交给渲染的图片地址)。
* @param baseUrl 当前服务器地址(`AuthManager.currentBaseUrl`);空串 = 不替换。
*/
fun apply(raw: String, baseUrl: String): String {
// 1. raw 必须是 http(s) 绝对地址,且能切出 authority 与 path。
val rest = raw.absoluteRest() ?: return raw
val slash = rest.indexOf('/')
if (slash <= 0) return raw
// 2. 路径必须是「恰好一段文件名的 /api/v1/files/…」。
if (!isSameOriginFilePath(rest.substring(slash))) return raw
// 3. baseUrl 必须能给出 scheme://authority。
val origin = originOf(baseUrl) ?: return raw
return origin + rest.substring(slash)
}
/**
* `http(s)://` 之后的部分(`<authority>/<path>`);不是 http(s) 绝对地址就回 `null`。
*
* 大小写不敏感,但**原样保留**后面那段:本函数只判断、不重写。
*/
private fun String.absoluteRest(): String? = when {
startsWith(HTTP_SCHEME, ignoreCase = true) -> substring(HTTP_SCHEME.length)
startsWith(HTTPS_SCHEME, ignoreCase = true) -> substring(HTTPS_SCHEME.length)
else -> null
}
/**
* `path` 是不是「恰好 `/api/v1/files/<一段名字>`」。
*
* 判据取最小容忍度,理由与 [ImageNames.fromServerUrl] 的同一条:这个字符串来自**别人给的**历史
* 数据,凡是看不懂的形态就不该被我们改写(改写它等于替用户的图片猜一个新地址)。
*/
private fun isSameOriginFilePath(path: String): Boolean {
if (!path.startsWith(FILES_PREFIX)) return false
val name = path.removePrefix(FILES_PREFIX)
if (name.isEmpty() || name == "." || name == "..") return false
if (name.any { it == '/' || it == '\\' || it == '%' || it == '?' || it == '#' || it == '\u0000' }) {
return false
}
return name.isNotBlank()
}
/** `scheme://authority`;解不出来回 `null`(空串 / 没有 host / 不是 http(s) 都是解不出来)。 */
private fun originOf(baseUrl: String): String? {
val rest = baseUrl.absoluteRest() ?: return null
// authority 在第一个 `/`、`?`、`#` 之前结束(正常 baseUrl 只有尾部那个 `/`;
// 多出来的那两样不该被当成 host 的一部分)。
val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it }
val authority = rest.substring(0, end)
if (authority.isBlank()) return null
val scheme = baseUrl.take(baseUrl.length - rest.length)
return scheme + authority
}
}
@@ -0,0 +1,269 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.repository.DocumentCache
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
import top.yeij.cyrene.data.repository.ImageTooLargeException
import top.yeij.cyrene.data.repository.LocalDocumentTooLargeException
import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
import java.util.Base64
import java.util.Locale
/**
* 被点开的这张图**从哪来**。
*
* 判定只发生在一处([of]),渲染、取字节、交给别的 App 三条路都从这里取来源——散成两处就会
* 出现「预览看的是这张、打开给的却是另一张」这种半截行为。
*
* 三种来源是**真的事实差异**,不是实现细节:
* - [Server]:上传产物的绝对地址(`POST /api/v1/upload` 的 `url`)。读侧 `GET /api/v1/files/{name}`
* 是**免鉴权**的 128-bit 能力 URL,所以这条路的字节可以直接 GET(与文档读侧刻意相反)。
* ⚠️ 已知缺口(`02/F6①`):`url` 由服务端按上传时的 `r.Host` 拼成绝对地址并原样落库
* ⇒ **换服务器地址后旧图取不回**。本模块不修它,但也不掩盖:取不到就如实报错。
* - [Inline]:小图内联进历史的那份 `data:` URI([ChatImageDelivery] 的 Inline 档)。字节就在
* 这个字符串里,**不联网**。
* - [Local]:本地引用——相册选择器的 `content://`、分享落地的 `file://`。字节要现读。
*/
sealed interface ImageSource {
/**
* 交给渲染与取字节用的**原始**字符串:气泡里 `message.image` 的那一份,或待发图片的本地 URI。
*
* 刻意不在这里归一化成「某种标准地址」:渲染交给 Coil(它自己认 `data:` / `content://` /
* `http(s)`),取字节由 [ImageCacheStore] 按来源分支。多一层转换只会多一处漂移的余地。
*/
val raw: String
/** 上传产物的绝对地址(`http(s)://<host>/api/v1/files/<name>`);读侧免鉴权。 */
data class Server(override val raw: String) : ImageSource
/** 内联进历史的 `data:` URI(`data:image/png;base64,…`)。 */
data class Inline(override val raw: String) : ImageSource
/** 本地引用:`content://`(相册)/ `file://`(分享落地)都走它;读不到会如实报错。 */
data class Local(override val raw: String) : ImageSource
companion object {
/**
* 分类(纯字符串判断,不做任何 IO)。
*
* 空串回 `null`——那种图本来也不会被渲染。**其余一律落到 [Local]**:一个既不是
* `data:` 也不是 `http(s)://` 的字符串只可能是本地引用(`content://` / `file://` /
* 裸路径),按本地读、读不到就报「读不到这张图片」。这样做的理由是**不许静默什么都不
* 发生**:形态陌生时点一下没反应,比给出一句说得清的失败糟得多。
*/
fun of(raw: String): ImageSource? = when {
raw.isBlank() -> null
raw.startsWith("data:", ignoreCase = true) -> Inline(raw)
raw.startsWith("http://", ignoreCase = true) -> Server(raw)
raw.startsWith("https://", ignoreCase = true) -> Server(raw)
else -> Local(raw)
}
}
}
/**
* 图片的三种形态互推:**字节 ↔ mime ↔ 扩展名**。
*
* 放行的集合与服务端 `POST /api/v1/upload` 的白名单**同源**(png / jpeg / gif / webp / bmp):
* 那正是服务端会接受、并且会给它起 `<32hex>.<ext>` 名字的一批。刻意**不**把 HEIC / AVIF 列进来
* ——它们不在服务端白名单里,本端也拿不到可比对的裁决;但本地相册里的 HEIC 照样能看(渲染交给
* Coil,解码是系统的事),只是本端说不出它的精确类型,那时「用其它应用打开」用的是**平台声明**
* 的类型(见 [ImageOpenTypes.openType])。
*/
object ImageFormats {
const val MIME_PNG: String = "image/png"
const val MIME_JPEG: String = "image/jpeg"
const val MIME_GIF: String = "image/gif"
const val MIME_WEBP: String = "image/webp"
const val MIME_BMP: String = "image/bmp"
/**
* 字节嗅探 → mime;认不出回 `null`。
*
* 只看文件头,不解码整张图:这一层要能在取字节之后立刻给答案,而不必把位图读进内存。
* 判据与服务端 upload 的白名单同一套(服务端用 `http.DetectContentType`,本端按同样的签名
* 手判)——认不出就说认不出,**不猜**(图片侧猜错的代价是让一个错的类型进 Intent)。
*/
fun sniff(bytes: ByteArray): String? = when {
bytes.startsWith(PNG_MAGIC) -> MIME_PNG
bytes.startsWith(JPEG_MAGIC) -> MIME_JPEG
bytes.startsWith(GIF_MAGIC) -> MIME_GIF
bytes.isWebp() -> MIME_WEBP
bytes.isBmp() -> MIME_BMP
else -> null
}
/** 名字/地址里的扩展名 → mime;不在图片白名单里回 `null`(不猜)。 */
fun mimeForExtension(name: String?): String? =
when (name?.substringAfterLast('.', "")?.lowercase(Locale.ROOT).orEmpty()) {
"png" -> MIME_PNG
"jpg", "jpeg" -> MIME_JPEG
"gif" -> MIME_GIF
"webp" -> MIME_WEBP
"bmp" -> MIME_BMP
else -> null
}
/** mime → 带点的扩展名(内容寻址的缓存名要用);认不出回空串。 */
fun extensionFor(mime: String?): String = when (mime) {
MIME_PNG -> ".png"
MIME_JPEG -> ".jpg"
MIME_GIF -> ".gif"
MIME_WEBP -> ".webp"
MIME_BMP -> ".bmp"
else -> ""
}
private fun ByteArray.startsWith(magic: ByteArray): Boolean =
size >= magic.size && magic.indices.all { this[it] == magic[it] }
/** WEBP 是 RIFF 容器:前 4 字节 `RIFF`、第 8…11 字节 `WEBP`,只看开头是不够的。 */
private fun ByteArray.isWebp(): Boolean =
size >= WEBP_MARKER_OFFSET + WEBP_MARKER.size &&
startsWith(RIFF_MAGIC) &&
WEBP_MARKER.indices.all { this[WEBP_MARKER_OFFSET + it] == WEBP_MARKER[it] }
/** BMP 是 `BM` 开头的固定 14 字节文件头;只看那两字节太容易撞上以「BM」开头的文本。 */
private fun ByteArray.isBmp(): Boolean = size >= BMP_HEADER_BYTES && startsWith(BMP_MAGIC)
private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
private val JPEG_MAGIC = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte())
private val GIF_MAGIC = "GIF8".toByteArray(Charsets.US_ASCII)
private val RIFF_MAGIC = "RIFF".toByteArray(Charsets.US_ASCII)
private val WEBP_MARKER = "WEBP".toByteArray(Charsets.US_ASCII)
private val BMP_MAGIC = "BM".toByteArray(Charsets.US_ASCII)
private const val WEBP_MARKER_OFFSET = 8
private const val BMP_HEADER_BYTES = 14
}
/**
* 服务端图片地址 → 缓存名。
*
* ## 为什么可以拿 URL 的最后一段当文件名
* `POST /api/v1/upload` 的产物名是服务端生成的 `<32hex>.<ext>`,落进 `<dataDir>/uploads/`,
* 读侧只认「32 位小写 hex + 白名单扩展名」。同一个名字的字节**永不改变**(上传产物没有覆盖
* 写入口),所以**名字就是缓存键、命中即永不失效**——与文档那条内容寻址的结论一致。
*
* ## 为什么容忍度取最小
* 这个名字会变成 `cacheDir/documents/` 下的一个文件名,而那个目录正是 FileProvider 唯一暴露出去
* 的根(见 [DocumentCache.isSafeName])。所以只接受「最后一段、单段、非空、不是 `.` / `..`、
* 不含分隔符与 NUL、不含 `%`、扩展名在图片白名单里」的名字;任何一条不满足就回 `null`,
* 由调用方**回落到按字节内容寻址**——宁可多落一份文件,也绝不用一个不可信的名字拼路径。
*
* 对 `%` 格外苛刻是有意的:本端**从不**解释 URL 编码,所以一个带 `%` 的段说明它压根不是我们
* 认识的那个服务端名字(服务端的读侧也明确拒绝 `%2f` / `%2e%2e` 这些形态)。
*/
object ImageNames {
/** 从图片地址里推出缓存名;推不出来 / 不可信时回 `null`。 */
fun fromServerUrl(url: String): String? {
// 先切掉 fragment 与查询串:缓存名只认路径那一段(`?v=2` 这种不该进文件名)。
val path = url.substringBefore('#').substringBefore('?')
val name = path.substringAfterLast('/')
if (name.isBlank() || name == "." || name == "..") return null
if (name.any { it == '\\' || it == '\u0000' || it == '%' }) return null
if (!DocumentCache.isSafeName(name)) return null
return name.takeIf { ImageFormats.mimeForExtension(it) != null }
}
}
/**
* 内联图片(`data:image/png;base64,…`)的解析。
*
* 用 JDK 的 `java.util.Base64` 而**不是** `android.util.Base64`:这一层是纯逻辑,要能在 JVM
* 单测里直接跑——`android.util` 在单测里是 stub,一碰就抛。
*/
object ImageDataUri {
/** 头部声明的 mime(`data:image/png;base64,…` → `image/png`);没有就回 `null`。 */
fun declaredMime(dataUri: String): String? = dataUri
.substringAfter("data:", "")
.substringBefore(',')
.substringBefore(';')
.trim()
.takeIf { it.isNotEmpty() }
/**
* 解出内联的字节;不是 base64 的 `data:` URI / 解不出来 / 解出来是空的都回 `null`。
*
* 用 MIME 解码器(忽略行内换行等非字母表字符)而不是严格解码器:`data:` URI 里的 base64
* 允许折行,严格解码器会把一整张能看的图判成坏数据。代价是「一整串垃圾」也可能解出一段垃圾
* 字节——那一段走到渲染那一步会解不开,由调用方如实报「解不开」,**不会**画成一张空白图。
*/
fun bytes(dataUri: String): ByteArray? {
if (!dataUri.startsWith("data:", ignoreCase = true)) return null
val comma = dataUri.indexOf(',')
if (comma < 0) return null
val head = dataUri.substring("data:".length, comma)
if (!head.contains(";base64", ignoreCase = true)) return null
return runCatching {
Base64.getMimeDecoder().decode(dataUri.substring(comma + 1))
}.getOrNull()?.takeIf { it.isNotEmpty() }
}
}
/**
* 「用其它应用打开」时 Intent 上要声明的 MIME,以及「保存到下载目录」时给 SAF 的类型。
*
* 与文档那条路同一纪律,但**两条路的类型选择刻意不同**(W11 实机抓到的真 bug):
* - **打开**要的正是「能读懂这段内容的类型」⇒ 尽可能精确(嗅探 > 平台声明 > 扩展名 > 图片通配);
* - **保存**恒为「通配」类型([ImageOpenTypes.SAVE_MIME])⇒ `CreateDocument` 传具体 mime 时,
* SAF 的 Downloads provider 会按它给文件名**补扩展名**(2026-10-05 实测:存的 `…e.md` 变成
* `…e.md.txt`)。图片这边同样危险:服务端名字的扩展名与**字节嗅探**所得的基类型不保证一致,
* 补一次就成了 `x.jpg.png`。
*/
object ImageOpenTypes {
/** 说不清是什么图时的兜底:让图片类应用都能接(不是 `application/octet-stream`)。 */
const val ANY_IMAGE: String = "image/*"
/** 保存到下载目录时给 SAF 的类型:**恒为通配**(为什么不能用具体 mime 见本对象的 KDoc)。 */
const val SAVE_MIME: String = "*/*"
private const val IMAGE_PREFIX = "image/"
/**
* 交给别的 App 时声明的类型,四级回落。
*
* 顺序是**证据强度**的顺序,不是「谁先拿到」的顺序:
* 1. [sniffed] —— 字节嗅探。服务端对图片也是这个口径(`DetectContentType` 是唯一裁决),
* 本端拿到的是同一份字节,所以它比任何名字和声明都硬;
* 2. [declared] —— 平台声明(本地来源的 `ContentResolver.getType` / 内联 `data:` URI 的头)。
* **只认 `image/` 开头的那种**:有些 provider 对图片报 `application/octet-stream`,
* 拿它去开图等于告诉对方「这是一坨字节」,不如退到扩展名;
* 3. [name] 的扩展名 —— 服务端名字里的扩展名来自它自己那一次嗅探,是可信的第二手证据;
* 4. [ANY_IMAGE]。
*/
fun openType(sniffed: String?, declared: String?, name: String?): String {
sniffed?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
declared?.trim()?.takeIf { it.startsWith(IMAGE_PREFIX) }?.let { return it }
ImageFormats.mimeForExtension(name)?.let { return it }
return ANY_IMAGE
}
}
/**
* 取这张图的字节失败时的文案(纯函数,可单测)。
*
* 与 [documentLoadFailureMessage] 同一纪律:服务端能说清的就用它的原话,本地能分类的就分类说,
* **绝不把失败渲染成一张空白图**。三条本地失败分开写,因为它们要用户做的事不一样:读不到 =
* 再点一次或重新发一张;太大 = 这个本端处理不了;缓存写不进去 = 本端的问题,重试多半还是这样。
*
* 三个异常类型与文档共用(它们描述的是**缓存层**的三种处境,不是文档专属);两边的文案各自成句。
*/
fun imageLoadFailureMessage(e: Throwable): String = when (e) {
is LocalDocumentUnavailableException -> "读不到这张图片"
is LocalDocumentTooLargeException -> "这张图片超过 ${DocumentAttachments.maxFileSizeLabel},无法预览"
is ImageTooLargeException -> "这张图片超过 ${DocumentAttachments.maxFileSizeLabel},无法预览"
is DocumentCacheUnavailableException -> "本地缓存不可用,暂时无法预览这张图片"
is CyreneException -> when (e.toDocumentFailure()) {
// 服务端对「名字非法」与「文件不存在」刻意同码(不泄露差异)——本端只说能确定的那半句。
is DocumentFailure.NotFound -> "服务端上已经没有这张图片了"
else -> "图片加载失败," + e.error.userMessage
}
else -> "图片加载失败,请稍后重试"
}
@@ -0,0 +1,162 @@
package top.yeij.cyrene.domain.chat
/**
* 一个矩形尺寸(像素)。
*
* 刻意**不用** Compose 的 `Size` / `IntSize`:这一层是纯手势数学,要能在 JVM 单测里直接跑,
* 也不想让 domain 依赖 UI 框架。UI 那边把 `IntSize` 换算成它,只有两行。
*/
data class ImageSize(val width: Float, val height: Float)
/** 全屏查看的缩放 / 平移:喂给 `graphicsLayer` 的三个数。 */
data class ZoomPan(
val scale: Float = ImageZoom.MIN_SCALE,
val offsetX: Float = 0f,
val offsetY: Float = 0f,
)
/**
* 全屏查看的缩放 / 平移数学(纯函数,UI 只负责把触摸事件喂进来、把结果画出去)。
*
* ## 坐标约定
* `offsetX` / `offsetY` 是**相对容器中心**的位移;[ImageSize] 给出两个尺寸:
* - `container`:手势区的大小;
* - `image`:图片的原始像素尺寸(只要宽高比正确即可,缩略图尺寸也行)。
*
* 于是「scale = 1」就是 **fit-center**:[fittedSize] 算出的内容尺寸(等比、贴住容器的一边)。
* 任何偏移都在 [clampOffset] 里被钳到「内容超出容器的那一半」以内——内容比容器小的那一轴恒为 0,
* 因此 fit 态下**根本拖不动**,图片永远不可能被拖到看不见的地方。
*
* ## 为什么下界是 1x 而不是任务书里那句「不低于 0.5x」
* 1x 就是「整张图刚好放得下」。再往下缩只会让图比屏幕还小、四周留白,没有任何能看的东西,
* 所以这里取一个**更紧**的下界(0.5x 满足「不低于 0.5x」,但它是个不需要的状态)。
* 上界 3x:比这更大就只能看几个像素,而双指继续外扩还有系统的手势冲突风险。
*/
object ImageZoom {
/** 下界 = fit-center(见 KDoc)。 */
const val MIN_SCALE: Float = 1f
/** 上界。 */
const val MAX_SCALE: Float = 3f
/** 双击放到的倍数(双击是 1x ↔ 2x 的开关,不是「放大一点」)。 */
const val DOUBLE_TAP_SCALE: Float = 2f
/** 判定「还在 fit 态」的容差:`clampScale` 的产物就是字面量 1f,但浮点乘法会留下尾巴。 */
private const val EPSILON = 0.01f
/** 缩放钳制:非有限值(NaN / Infinity,来自 `zoom` 因子的极端输入)一律回 [MIN_SCALE]。 */
fun clampScale(scale: Float): Float =
if (scale.isFinite()) scale.coerceIn(MIN_SCALE, MAX_SCALE) else MIN_SCALE
/** 是否处在放大态(双击据此决定「放大到 2x」还是「收回 1x」)。 */
fun isZoomed(scale: Float): Boolean = scale > MIN_SCALE + EPSILON
/** 双击的目标倍数:fit 态 → 2x;已放大 → 回 fit。 */
fun doubleTapScale(current: Float): Float =
if (isZoomed(current)) MIN_SCALE else DOUBLE_TAP_SCALE
/**
* fit-center 之后、scale = 1 时的内容尺寸(等比缩到贴住容器的一边)。
*
* 任一侧非正(还没测量出来 / 图片尺寸未知)时回 `(0, 0)`:调用方据此跳过钳制,
* 而不是拿 `NaN` 去算位移。
*/
fun fittedSize(container: ImageSize, image: ImageSize): ImageSize {
if (container.width <= 0f || container.height <= 0f) return ImageSize(0f, 0f)
if (image.width <= 0f || image.height <= 0f) return ImageSize(0f, 0f)
val fit = minOf(container.width / image.width, container.height / image.height)
return ImageSize(image.width * fit, image.height * fit)
}
/**
* 单轴的位移上限:内容放大后**超出容器的那一半**(内容比容器小时为 0)。
*
* 取一半是因为偏移以容器中心为原点:往右最多能移「超出量 / 2」,再移就会露出空白。
*/
fun maxOffset(containerSide: Float, contentSide: Float, scale: Float): Float {
if (!containerSide.isFinite() || !contentSide.isFinite() || !scale.isFinite()) return 0f
return ((contentSide * scale - containerSide) / 2f).coerceAtLeast(0f)
}
/** 单轴钳制(越界即贴边;fit 态下恒为 0,所以拖不动)。 */
fun clampOffset(offset: Float, containerSide: Float, contentSide: Float, scale: Float): Float {
if (!offset.isFinite()) return 0f
val limit = maxOffset(containerSide, contentSide, scale)
return offset.coerceIn(-limit, limit)
}
/** 拖动平移:先加上手指位移,再按两轴各自钳制。 */
fun pan(
current: ZoomPan,
deltaX: Float,
deltaY: Float,
container: ImageSize,
image: ImageSize,
): ZoomPan = clamped(
scale = current.scale,
offsetX = current.offsetX + deltaX,
offsetY = current.offsetY + deltaY,
container = container,
image = image,
)
/**
* 以 [focusX] / [focusY](**相对容器中心**的坐标)为不动点缩放到 [targetScale]。
*
* 推导:屏幕上被手指按住的那个点在内容坐标里是 `c = (focus - offset) / scale`;缩放后要让它
* 落在同一处,于是 `offset' = focus - c * scale'`,即 `offset' = focus - (focus - offset) * (scale'/scale)`。
* 缩回 fit 时这个式子算出来的位移会被 [clampOffset] 归零(内容不再超出容器),正是要的结果。
*/
fun zoomAround(
current: ZoomPan,
targetScale: Float,
focusX: Float,
focusY: Float,
container: ImageSize,
image: ImageSize,
): ZoomPan {
val scale = clampScale(targetScale)
val base = if (current.scale.isFinite() && current.scale > 0f) current.scale else MIN_SCALE
val ratio = scale / base
return clamped(
scale = scale,
offsetX = focusX - (focusX - current.offsetX) * ratio,
offsetY = focusY - (focusY - current.offsetY) * ratio,
container = container,
image = image,
)
}
/** 双击:以双击点为不动点在 fit 与 [DOUBLE_TAP_SCALE] 之间切换(回 fit 时位移自然归零)。 */
fun doubleTap(
current: ZoomPan,
focusX: Float,
focusY: Float,
container: ImageSize,
image: ImageSize,
): ZoomPan = zoomAround(
current = current,
targetScale = doubleTapScale(current.scale),
focusX = focusX,
focusY = focusY,
container = container,
image = image,
)
private fun clamped(
scale: Float,
offsetX: Float,
offsetY: Float,
container: ImageSize,
image: ImageSize,
): ZoomPan {
val fitted = fittedSize(container, image)
return ZoomPan(
scale = scale,
offsetX = clampOffset(offsetX, container.width, fitted.width, scale),
offsetY = clampOffset(offsetY, container.height, fitted.height, scale),
)
}
}
@@ -0,0 +1,76 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.core.prefs.InterruptMode
/**
* 「她忙时发消息」这一档偏好在上行的**两个声明**(纯函数,VM 与测试共用):
* 协同项 A2 的 `interrupt.scope`([interruptScopeForSend])与忙时并行规范 §2.2 的
* `chat.parallel`([parallelForSend] / [parallelWireValue])。
*
* 文件名沿用了先有的那个(`InterruptScope`):并行那一半接上来时,两者是**同一个判据的两个方向**
* (打断 vs 不打断),放在同一处比拆成两个文件更容易看出它们互斥。
*/
/**
* 发这条消息**之前**要不要先发一条 `interrupt`:返回要发的作用域,`null` = 一个字节都不发
* (纯函数,VM 与测试共用)。
*
* ## 四条判据(协同 A2 的客户端半边,契约 v0.56 §3.1)
*
* 1. [InterruptMode.AppendQueue](**默认档**)⇒ 恒为 `null`,线上请求与接入本函数之前**逐字节相同**。
* 这是最重要的一条:默认档不许有任何行为变化。
* 2. [InterruptMode.Interrupt](`wireScope = "current"`)**且会话正忙** ⇒ `"current"`:
* 只取消在跑回合、**保留待处理队列**(队列里的消息按编排器正常续跑,一条都不丢)。
* 3. [InterruptMode.Interrupt] 但会话**空闲** ⇒ `null`:命中空闲会话在服务端是 no-op,白发一条帧
* 只是多一次往返,还会在 logcat 里制造「这里打断过」的假象。
* 4. [InterruptMode.Parallel] ⇒ **恒为 `null`**(它的 `wireScope` 就是 `null`):并行与打断互斥,
* 并行档先打断就恰好砍掉它要并行的那一路。这一条不是「这一档没得发」,而是**结构性保证**——
* 谓词只读 `wireScope`,并行档不可能发出打断帧。
*
* 判据里**没有**「队列有多深」这一项,见下。
*
* ## 「正忙」由谁判
* 只认**服务端给的** `session_busy{mode}`(调用方传 `ChatUiState.busyMode != null`)。
* **不用 `queueDepth`**:队列深度非零不等于有回合在跑(`pending_ack` / `pending_drain` 这类帧也会动它),
* 拿它当判据就会在空闲会话上乱发打断帧。
*
* @param busy 这条消息要发出去的那一刻,该会话是否有回合在跑。
*/
fun interruptScopeForSend(mode: InterruptMode, busy: Boolean): String? =
if (busy) mode.wireScope else null
/**
* 发这条消息时要不要声明 `parallel:true`(忙时并行规范 §2.2,[interruptScopeForSend] 的兄弟)。
*
* ## 三条判据
*
* 1. [InterruptMode.Parallel] **且会话正忙** ⇒ `true`:不打断在跑的那一路,让本条**另起一路**
* 同时处理(服务端额度满时不报错,这一条落回排队)。
* 2. 另外两档 ⇒ **恒为 `false`**:`parallel` 是纯增量键,省略它线上请求与接入本判据之前**逐字节相同**
* ([InterruptMode.AppendQueue] 与 [InterruptMode.Interrupt] 的 `wireParallel` 都是 `false`)。
* 3. 并行的同时**绝不打断**:两件事是一对互斥语义(打断 = 先砍掉她正在说的这一句,并行 = 不打断、
* 另起一路),所以 [InterruptMode.Parallel] 的 `wireScope` 是 `null` ⇒ 它在
* [interruptScopeForSend] 里恒为「不发」。反过来,另外两档的 `wireParallel` 是 `false` ⇒
* 它们永远不带 `parallel` 键。「绝不既打断又并行」不是约定,是这两处属性取值本身
* (`ParallelForSendTest` 把它钉成穷举断言)。
*
* ## 为什么空闲时不声明
* 规范 §2.2 只说 `parallel:true` 是「**请求**把本条作为独立一路处理」,**没写**空闲时要不要照发。
* 空闲时这一条**本来就是它自己的回合**,声明并行与不声明在服务端是同一件事;而多带一个键就多一处
* 线上形状差异。于是本端取**与 [interruptScopeForSend] 同一个判据**(忙才声明)——最小惊讶:
* 空闲时三档**逐字节**完全相同,切档不会改变一条普通发送的任何字节。
*
* @param busy 这条消息要发出去的那一刻,该会话是否有回合在跑(与 [interruptScopeForSend] 同一个来源)。
*/
fun parallelForSend(mode: InterruptMode, busy: Boolean): Boolean = busy && mode.wireParallel
/**
* 把 [parallelForSend] 的结论翻成帧 / DTO 那个可选字段的取值:`false` ⇒ `null`(**整键省略**)。
*
* 为什么必须翻一道:`WsOutbound.Chat.parallel` 与 `ChatRequest.parallel` 的默认值都是 `null`,
* 而「省略」与「显式 `false`」在服务端**不是一回事**(前者 = 未声明,后者 = 明确不要并行);
* kotlinx 的 `encodeDefaults = false` 只省略「等于默认值」的字段 ⇒ 把 `false` 直接传下去,会在
* **每一条**请求上多出一个 `"parallel":false` 键,默认档就不再逐字节不变了。两个发送层共用这一个
* 转换点,免得哪天有人在其中一处「顺手」把布尔原样传下去。
*/
fun parallelWireValue(declare: Boolean): Boolean? = if (declare) true else null
@@ -0,0 +1,97 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.domain.parse.parse
/*
* MessageEditText —— 「一条消息在编辑框里长什么样」的纯逻辑(无 Android / Compose 依赖)。
*
* 口径:编辑框只显示**纯文本**,但绝不静默丢东西。两件事必须同时成立:
* 1. 原文本来就是纯文本 ⇒ 显示与保存都**逐字节恒等**(`hasMarkup == false`);
* 2. 原文真的带标记 ⇒ 显示剥掉标记的可见文本,并由弹层**明说**保存会把那些标记换成用户写的这段字
* (`hasMarkup == true`,见 `ui/chat/MessageEditDialog.kt`)。
* 所以「剥标记」这条路只对**真有标记**的内容生效,对今天真实的主会话数据(user 消息全是纯文本)
* 是一条恒等变换——它是防御,不是修一个正在发生的 bug。这也正是不能顺手把纯文本 trim /
* 折叠空行 / 统一换行符的原因:那会把恒等变成「悄悄地改」。
*
* 剥标记**只用** `domain/parse` 这一个解析器([parse]),不另写正则:两套口径必然分叉,
* 屏幕上读到的与她实际改的就会是两段不同的文本。拼纯文本的做法与 `ui/chat/SessionSearchModel.kt`
* 的 `plainSearchText` 同源(同一份 `parse` 结果、同一句「按片段顺序取文本」),
* 只是彼此的用途不同:搜索片段折成一行、这里保持段落,所以两者的拼接方式各自写在自己的文件里。
*
* 为什么保存时**不**把标记装回去:任何回填都要先猜「旁白/动作该插回哪一句之间」,
* 而这不是本端有权替用户决定的事——猜错就是静默移动她说过的话的位置,且用户看不出发生了什么。
* 提交的就是编辑框里那串纯文本本身,标记的归宿由弹层那一行小字**提前说清**。
*/
/**
* 一条消息在编辑框里的样子(纯数据)。
*
* 两个字段是一体的:`text` 是用户看得到、也真的要动手改的那串字,`hasMarkup` 说的是
* 「保存会不会因此丢掉别的东西」——弹层靠它决定要不要讲那一句话。
*/
data class EditableMessage(
/** 编辑框显示、也就是用户实际编辑的**纯文本**。 */
val text: String,
/**
* 原文里**确实带标记**(`<p>` / `<action>` / `<narration>`)⇒ 保存会把那些标记换成 `text`。
* false = 原文就是纯文本,显示与保存都是**恒等**的。
*/
val hasMarkup: Boolean,
)
/**
* 已知标签的容差,与 [parse] 逐字对齐(只认解析器真正认识的那几个标签):
* `<p>`(可带属性)/ `</p>` / `<action>` / `</action>` / `<narration>` / `</narration>`。
*
* 绝不能「见到一个 `<` 就当标记」:用户打的 `1 < 2`、`<div>`、`<3`、`a<b` 都是普通文本,
* 判成标记就会顺手把它们当标签吃掉——那才是真正的静默丢东西。大小写同样照解析器来:
* `parse` 只认小写的 `<p>` / `<action>` / `<narration>`,所以 `<P>` 在这里也是普通文本。
*/
private val KNOWN_TAG_RE = Regex(
"<p(\\s[^>]*)?>|</p>|<\\s*(action|narration)\\s*>|</\\s*(action|narration)\\s*>",
)
/** 气泡之间的分隔:一个空行——屏幕上读到的两段之间就是这么空着的。 */
private const val BUBBLE_SEPARATOR = "\n\n"
/**
* 算出这条消息在编辑框里的样子(纯函数,无副作用)。
*
* 三分支,覆盖「标记只在真的有标记时才动手」这条红线:
* - **原文不含任何已知标签** ⇒ 原样返回,且**一个字符都不动**(不 trim、不折叠连续空行、不换换行符);
* - **含标签且解析出了气泡** ⇒ `text` = 可见纯文本(见下);
* - **含标签但一个气泡都解析不出**(例如整条就是一个没闭合的畸形标签)⇒ `text` 回落成原文:
* 宁可原样留着,也不赌一把去删掉屏幕上说不清是什么的东西。
*
* `hasMarkup` 只在「解析**真的**换掉了什么」时为 true,也就是 `text != content`:
* 它承诺的是「保存后这条消息和你现在看到的不一样」,而这个承诺必须句句属实——
* 畸形标签、`<p> </p>` 这类解析器按普通文本处理的输入走的是恒等路径,
* 那就该如实说「什么都没被替换」,而不是先吓用户一下。
*/
fun editableMessageOf(content: String): EditableMessage {
if (!KNOWN_TAG_RE.containsMatchIn(content)) return EditableMessage(content, hasMarkup = false)
val text = visibleTextOf(content)
return EditableMessage(text, hasMarkup = text != content)
}
/**
* 标记 → 可见纯文本:每个气泡按**原始片段顺序**取文本(正文与旁白/动作一视同仁,都不丢),
* 气泡之间一个空行,最后整体 trim。
*
* 按 `parts` 顺序拼而不是按 head/body/tail 拼:`parts` 就是原文顺序,[parse] 的 head 只是
* 「该把这块画在气泡上方」的渲染提示,用它重排会改掉用户读到的次序。
*
* 每个气泡**自己也 trim**(然后才是气泡之间的空行):气泡在屏幕上本就是独立的一块,
* 块内的首尾空白渲染上不显示;不 trim 的话 `</p>\n\n<p>` 这种 Web 形态的块间空行会与
* 分隔符叠加成四个换行,读起来就不是屏幕上那两段了。这里去掉的只可能是空白,
* 一个可见字符都不会掉。
*
* 一个气泡都解析不出时**原样返回 `content`**:那是「宁可不剥」的落点,不是错误。
*/
private fun visibleTextOf(content: String): String {
val bubbles = parse(content)
if (bubbles.isEmpty()) return content
return bubbles
.joinToString(BUBBLE_SEPARATOR) { bubble -> bubble.parts.joinToString("") { it.text }.trim() }
.trim()
}
@@ -0,0 +1,40 @@
package top.yeij.cyrene.domain.chat
/**
* 一条待发消息的**内容指纹**:正文 + 图片的本地引用 + 附件的本地引用(纯值,可比较、可单测)。
*
* ## 它回答的问题
* 「用户这次按发送,是不是在**重试**刚才那条没发出去的消息?」——只有答「是」,本端才敢把
* 那一次用过的**幂等键**(`clientMsgId`)接着用;答「不是」就必须铸新键,否则服务端会把用户
* **改过的那条**当成重复到达(`chat_duplicate` / `{duplicate:true}`)静默丢掉——他的编辑一个字
* 都发不出去。
*
* ## 为什么是「值相等」而不是哈希 / 摘要
* 指纹相等 = 本端认定「同一条消息」 = 复用幂等键。摘要一旦碰撞,两条**不同**的消息就会共用
* 一个键,后一条被服务端判成重复而丢掉;而摘要在这里买不到任何东西(比较只发生在进程内,
* 没有要跨网络传的指纹)。所以直接拿可比较的值本身当指纹,碰撞面为零。
*
* ## 为什么正文不做任何归一
* 一个空白字符的差别也算「改过字」:trim / 折叠空白都可能把用户真实打算发的两条消息并成一条,
* 而那正是本类型要防的事。发送路径传进来的已经是发送那一刻的正文(`ChatViewModel.send` 先
* `trim`),所以「重试时输入栏里原样躺着的还是同一段字」天然相等。
*
* ## 附件为什么只取 uri
* `DraftDocument` 的 `displayName` / `sizeBytes` 是**元数据**:同一份本地文件在两次
* `ContentResolver` 查询之间完全可能一次报得出、一次报不出(`UNKNOWN_SIZE`),那不影响
* 「这是同一份文件」。决定内容身份的是**本地引用本身**,所以指纹只取它。
* 顺序照旧有意义(附件的先后就是用户在芯片区看到的先后),因此是列表而不是集合。
*/
data class MessageFingerprint(
val text: String,
/** 图片的**本地可读** URI;null = 这条不带图(与待发消息的字段一一对应)。 */
val imageUri: String? = null,
/** 附件的本地 URI,顺序即用户在芯片区看到的顺序。 */
val documentUris: List<String> = emptyList(),
) {
companion object {
/** 从发送那一刻的三件套算指纹(正文 / 图片 / 待发附件)。 */
fun of(text: String, imageUri: String?, documents: List<DraftDocument>): MessageFingerprint =
MessageFingerprint(text = text, imageUri = imageUri, documentUris = documents.map { it.uri })
}
}
@@ -0,0 +1,331 @@
package top.yeij.cyrene.domain.chat
/**
* 忙时并行回合(多路)的客户端模型:**分桶、六态、灰态收口**三件纯逻辑。
*
* 规格来源:`Cyrene_v2/DevDocs/_specs/忙时并行-规范草案.md`(§1 行为规范 / §2 接口规范 / §4 主人裁决)。
* 本文件**只有纯数据与纯函数**:不碰协程、不碰 Android、不碰网络,所以三层逻辑都能直接单测。
* 消费方是 `ChatViewModel`(接线)与 `ChatScreen` / `MessageBubble`(渲染)。
*
* ## 为什么要分桶
* 两路并行时,同一 `sessionId` 下**两个不同 `turnId`** 的 `message.delta` / `thinking.delta` /
* `tool_call.*` 会**交错到达**。若照老代码那样「一律累加到当前那一个流式气泡」,两条回复会被糊成
* 一条(规范 §1.4 明写这一点)。所以落桶键是 `(sessionId, turnId)` 两级(规范 §1.7 第 1 条)。
*
* ## 字段缺失必须能降级(本文件的头号纪律)
* `turnId` 在真后端 v0.36 起就打在**每个结构化流帧**的帧壳上,但本文件**不假设**它一定在:
* 帧壳没给(`null` / 空白)时,帧落回**本会话最近见过的那条路**([turnBucketOf] 的 `sessionTurnId`),
* 一条都还没见过时落进**匿名桶**(`turnId == ""`)。于是「所有帧都不带 `turnId`」时全部帧落进
* **同一个桶**——也就是**今天那一个流式气泡**,行为逐字相同。
* 同理 `turn_begin` / `steer_ack` 不来时,六态里只会出现 ①②③⑤,灰态(④)根本不会进入。
* `kind` 不来时(老服务端)也只是**不挂并行路角标**——[isParallelLegKind] 认不出来就说不并行,
* 绝不拿 `leg` 去替它撑门面。
*/
/** 「路序号未知」:`steer_ack` 只给 `turnId`、不给 `leg`(规范 §2.5 的载荷只有 messageId / atStep)。 */
const val UNKNOWN_LEG: Int = 0
/**
* `turn_begin.kind` 里表示「忙时另起的一路」的取值(规范 §2.4)。
*
* 另一个取值 `"turn"` 表示「新回合 / 续跑」——也就是**普通串行回合**,它不在这里单列常量:
* 本端只认得出 `"parallel"` 这一个,其余一切取值(含 `"turn"`)都按「不是并行」处理
* (见 [isParallelLegKind] 的 KDoc)。
*/
private const val PARALLEL_LEG_KIND = "parallel"
/**
* 流式分桶键:`sessionId` → `turnId` 两级(规范 §1.4 / §1.7 第 1 条)。
*
* [turnId] 为空串 = **匿名桶**:帧壳没给 `turnId`、且本会话还没见过任何 `turnId` 时的落点。
* 它是「老的单路行为」在分桶模型里的等价物——所有匿名帧落在这同一个桶里,与今天逐字相同。
*/
data class TurnBucket(val sessionId: String, val turnId: String) {
/** 是否匿名桶(帧壳没给 `turnId`)。 */
val isAnonymous: Boolean get() = turnId.isEmpty()
override fun toString(): String = if (isAnonymous) "$sessionId/*" else "$sessionId/$turnId"
}
/**
* 帧该落哪个桶(纯函数)。
*
* 优先级:**帧壳给的 `turnId`** > **本会话最近见过的 `turnId`** > **匿名桶**。
*
* 第二条是「字段缺失 ⇒ 行为与今天逐字相同」那条硬要求的落地处:帧壳没给 `turnId` 时,帧跟着
* 本会话当前那一路走——老代码里它本来就会落进那**唯一**一个流式气泡。因此
* `turnBucketOf("web", null, null)` 恒等于匿名桶,全部帧都落在一处,与分桶之前完全一致。
*
* @param frameTurnId 帧壳上的 `turnId`(可能 `null` / 空白)。
* @param sessionTurnId 本会话最近一次见到的**非空** `turnId`(由调用方记住并喂进来)。
*/
fun turnBucketOf(sessionId: String, frameTurnId: String?, sessionTurnId: String?): TurnBucket =
TurnBucket(sessionId, frameTurnId.orNullIfBlank() ?: sessionTurnId.orNullIfBlank() ?: "")
/**
* **收口帧**(`stream_end` / `turn.end` / 落库的助手行)该落哪个桶(纯函数)。
*
* 与 [turnBucketOf] 的区别只有一条、但很要紧:**收口帧不许新开桶**。
*
* 理由是实证的:老代码里 `stream_end` 无论带着哪个 `turnId`,都会把**唯一**那个流式气泡收口
* (`active = false`)。若照 [turnBucketOf] 给一个认不出来的 `turnId` 开新桶,那个新桶收口、
* **真正在跑的那个桶永远收不了口**——光标一直转、`stream_end` 这一帧等于白给。
* 所以这里的顺序是:
*
* 1. 帧壳的 `turnId` 认得出来(在 [existing] 里)⇒ 就是它;
* 2. 否则本会话最近见过的 `turnId` 认得出来 ⇒ 用它;
* 3. 否则 [existing] 里还有桶 ⇒ 用**最近更新的那一个**(单路场景下它就是正确的那一个);
* 4. 一个桶都没有 ⇒ 匿名桶(也不新开,调用方据此可以对「没有在跑的路」整帧跳过)。
*
* @param existing 当前**已经存在**的桶的 `turnId` 列表,**顺序即更新时间**(最后的最近)。
*/
fun closingBucketOf(
sessionId: String,
frameTurnId: String?,
sessionTurnId: String?,
existing: List<String>,
): TurnBucket {
val frame = frameTurnId.orNullIfBlank()
val session = sessionTurnId.orNullIfBlank()
val target = when {
frame != null && existing.contains(frame) -> frame
session != null && existing.contains(session) -> session
existing.isEmpty() -> ""
else -> existing.last()
}
return TurnBucket(sessionId, target)
}
/**
* 一条消息在「忙时并行」语义下可能处的**六态**(规范 §1.1)。
*
* 名字逐字对齐规范表格里的态名,避免两套叫法:
* - [Sending] ① 发送中(本地乐观气泡);
* - [Sent] ② 已送达(正常气泡,还没进任何一路);
* - [Queued] ③ 排队中(气泡角标「排队中」**浅灰**——规范 §1.3 明写:**`queued` 不是灰态**,
* `pending_ack` 不许渲染成灰态,`queued` 只靠 `turn_begin` **直接换态**);
* - [Answering] ④ 已受理·回答中(**灰态「已读、未答」**);
* - [Answered] ⑤ 已答(终态);
* - [Rejected] ⑥ 被拒(终态,用户可手动重发)。
*/
enum class TurnPhase { Sending, Sent, Queued, Answering, Answered, Rejected }
/**
* 六态判定(纯函数,规范 §1.1 的表格逐行对照)。
*
* 判据只有四个,且**顺序即证据强度**(与 `queueFullOutcome` 同一条纪律):
* 1. [rejected] = 收到过明确拒绝(定向 `error{chat_busy}` / 广播 `pending_full`)⇒ ⑥。
* 它压过一切:这条消息**根本没进去**,不许再拿「有没有被接手」去描述它;
* 2. [binding] 非空且**未收口** ⇒ ④ 灰态(`turn_begin` 的 `messageIds` 含它,或 `steer_ack` 绑上它);
* 3. [binding] 非空且**已收口** ⇒ ⑤;
* 4. [binding] 为空且是本地乐观气泡 ⇒ [queued] ? ③ : ①;
* 5. [binding] 为空且已落库 ⇒ [queued] ? ③ : ②。
*
* 第 4/5 条里 [queued] 与非 [queued] 分开,是因为服务端的 `pending_ack` **不带消息身份**
* (规范 §1.1 ③ 的进入条件明写「现状帧,形状不变」)——客户端只能按「这条我发出去之后收到了入队
* 回执」来认,认不出来时为 [Sent] / [Sending](今天的样子),**绝不瞎猜成排队**。
*
* @param local 这条是不是**本地乐观气泡**(还没落库)。
* @param binding 这条消息被哪一路接手(`null` = 没被任何一路接手)。
* @param queued 收到过「它进队列」的回执(`pending_ack` / REST `202 queued`)。
* @param rejected 收到过明确的拒绝。
*/
fun turnPhaseOf(
local: Boolean,
binding: TurnBinding?,
queued: Boolean,
rejected: Boolean,
): TurnPhase = when {
rejected -> TurnPhase.Rejected
binding != null && !binding.closed -> TurnPhase.Answering
binding != null -> TurnPhase.Answered
local -> if (queued) TurnPhase.Queued else TurnPhase.Sending
queued -> TurnPhase.Queued
else -> TurnPhase.Sent
}
/**
* 灰态(④)**收口判据**(纯函数,规范 §1.2)。
*
* 规范给的是**唯一口径**:收口事件 = 该消息所属回合的 `turn.end`,用 `turn.end` **帧壳的 `turnId`**
* 与这条消息绑定的 `turnId` 比对,**相等即收口**;此外该 `turnId` 的助手 `message_persisted`
* 同样收口(两者顺序不定,任一到即收口)。
*
* 所以这里就一件事:两个 id **都非空且相等**。任一为空 ⇒ `false`(**不许**用「都为空所以相等」
* 收口——那会让「没有 turnId 的老客户端 / 老服务端」上的消息被随便一帧误收口)。
*
* @param boundTurnId 这条消息绑定的 `turnId`。
* @param closingTurnId 收口帧带来的 `turnId`(`turn.end` 帧壳 / 助手落库行的 `turnId`)。
*/
fun closesAnswering(boundTurnId: String?, closingTurnId: String?): Boolean {
val bound = boundTurnId.orNullIfBlank() ?: return false
val closing = closingTurnId.orNullIfBlank() ?: return false
return bound == closing
}
/**
* 消息 ↔ 回合的绑定:**一路**(规范 §2.4 的 `turn_begin` 说的是「编排器每次启动一路发一次」)。
*
* 它由两件事建立:`turn_begin`(一次给全 `messageIds` / `leg` / `activeTurns` / `kind`)与 `steer_ack`
* (只补「某条消息被哪一路吃进」,没有 `leg`)。
* 只在 `turnId` **非空**时才会被建立——`turnId` 是这张表的键,也是收口的唯一凭据(见
* [closesAnswering]);没有它建出来的绑定永远收不了口,那不是绑定,是一个永远灰着的气泡。
*
* ## [leg] 与 [parallel] 是两件事(这张表最容易被读错的一处)
* [leg] 只是**会话内第几条路**:服务端 `beginLegLocked` 对**每一条新路**都自增 `legSeq`,普通串行
* 回合照样占号——会话里第二条普通消息的 `leg` 就是 2。所以 `leg >= 2` **完全不等于**并行,拿它当
* 并行的证据会挂出并不存在的「并行路 N」。
* 真正说明「这一路是忙时另起的一路」的只有 `turn_begin.kind`,落到 [parallel](见 [isParallelLegKind])。
*/
data class TurnBinding(
/** 这一路的权威回合标识(与该路全部流帧的 `turnId` 同值)。 */
val turnId: String,
/** 该路的会话内序号([UNKNOWN_LEG] = `steer_ack` 建的表,服务端没告诉序号)。 */
val leg: Int = UNKNOWN_LEG,
/** 归属这一路的**用户消息 id**(`turn_begin.messageIds` / `steer_ack.messageId`)。 */
val messageIds: List<String> = emptyList(),
/** 服务端最近一次报的该会话在途路数(`turn_begin.activeTurns`;0 = 没报过)。 */
val activeTurns: Int = 0,
/** 这一路是不是**真并行路**(服务端 `turn_begin.kind == "parallel"`,见 [isParallelLegKind])。 */
val parallel: Boolean = false,
/** 该路是否已收口(`turn.end` / 同 `turnId` 的助手落库行)。 */
val closed: Boolean = false,
)
/** 这条消息被哪一路接手;`null` = 没被任何一路接手(也是「按顺序渲染」那档的老行为)。 */
fun List<TurnBinding>.bindingOfMessage(messageId: String): TurnBinding? =
firstOrNull { binding -> messageId.isNotBlank() && binding.messageIds.contains(messageId) }
/**
* `turn_begin.kind` ⇒ 「这一路是不是并行路」(纯函数,规范 §2.4)。
*
* 只有**逐字等于** `"parallel"` 才算并行:`"turn"`(新回合 / 续跑)不算,缺席 / 空串 / 大小写不同 /
* 以后新增的其它取值也一律不算。两侧空白按缺席的写法一并容忍(与 `parallelLimitNoticeFor` 认
* `pending_ack.reason` 同一条口径),大小写**不**容忍——认不出来就是认不出来。
*
* **为什么认不出来就算「不是并行」**:`kind` 是服务端后加的键,本端必须能在**老服务端**(整键不给)
* 上跑,而 [TurnBinding.leg] 又**根本不足以**当判据(它是会话内第几条路,串行回合照样占号)。
* 两件事叠起来只剩一个如实的答案:认不出来就说「不知道」,而「不知道」在界面上等于**不挂角标**
* ——宁可少挂一枚,也不能挂一枚声称存在并不存在的并行的标。
*/
fun isParallelLegKind(kind: String?): Boolean = kind?.trim() == PARALLEL_LEG_KIND
/**
* 收下一条 `turn_begin`(纯函数,返回新表)。
*
* 同 [TurnBinding.turnId] 已存在时**合并**而不是新增:`messageIds` 取并集(保持原顺序在前),
* `leg` / `activeTurns` 用新报的值,**`closed` 保持原值**——一次重复到达的 `turn_begin` 不该把一条
* 已经收口的路「重新打开」(那会让气泡回到灰态并且再也等不到第二次收口)。
* 表里没有它时按**追加**处理:两路的顺序就是它们被启动的顺序。
*
* ## [TurnBinding.parallel] 的合并规则:一个只说真话的**单调**事实
* 新建时它 = [kind] 是不是 `"parallel"`(见 [isParallelLegKind],认不出来 ⇒ `false`);
* 已存在时取**或**(`old.parallel || 本帧是并行`)——**一旦为 true 就永远是 true**。
*
* 三条理由:
* 1. 「这一路是被并行起出来的」是**既成事实**:服务端同一条路的 `kind` 由创建那一刻的标志决定、
* 之后不可变,所以重复帧本不该各说各话;真各说各话时以「说过 parallel」为准——它不会凭一个
* **更弱**(认不出 `kind`)的重复帧把已确立的并行抹掉,而并行只会多挂一枚有据可依的角标;
* 2. 与 [TurnBinding.closed] 同一条纪律:**单调的既成事实不许被后到的帧翻回去**;
* 3. 现实里真有「先弱后强」的顺序:[withSteeredMessage] 可能先按 `steer_ack` 建一条占位表
* (那时没有 `kind`,只能是 `false`),随后那一路自己的 `turn_begin` 才是唯一能把它标成并行的地方。
*
* [kind] 缺席(默认空串)= 老服务端 / 老形状的帧 ⇒ 这一路永远 `false` ⇒ 不挂角标,与今天可解释地
* 一致(少一枚角标,但没有多说一句假话)。
*/
fun List<TurnBinding>.withTurnBegin(
turnId: String,
leg: Int,
messageIds: List<String>,
activeTurns: Int,
kind: String = "",
): List<TurnBinding> {
val key = turnId.orNullIfBlank() ?: return this
val parallel = isParallelLegKind(kind)
val index = indexOfFirst { it.turnId == key }
if (index < 0) {
return this + TurnBinding(
turnId = key,
leg = leg,
messageIds = messageIds.filter(String::isNotBlank),
activeTurns = activeTurns,
parallel = parallel,
)
}
val old = this[index]
val merged = old.copy(
leg = leg.takeIf { it > UNKNOWN_LEG } ?: old.leg,
messageIds = (old.messageIds + messageIds.filter(String::isNotBlank)).distinct(),
activeTurns = activeTurns.coerceAtLeast(0),
parallel = old.parallel || parallel,
)
return toMutableList().also { it[index] = merged }
}
/**
* 收下一条 `steer_ack`(纯函数,返回新表):把 [messageId] 绑到 [turnId] 这一路上——这就是
* 「这条消息被在飞的那一轮吃进去了,它进灰态」的凭据(规范 §2.5)。
*
* 该 `turnId` 还没有表项时**建一个只有这一条消息的表**(`steer_ack` 不给 `leg` / `activeTurns`,
* 所以那两项留 [UNKNOWN_LEG] / 0)。`turnId` 或 `messageId` 空白 ⇒ **原样返回**:
* 绑不出东西来(见 [TurnBinding] 的 KDoc)。
*/
fun List<TurnBinding>.withSteeredMessage(turnId: String, messageId: String): List<TurnBinding> {
val key = turnId.orNullIfBlank() ?: return this
val message = messageId.orNullIfBlank() ?: return this
val index = indexOfFirst { it.turnId == key }
if (index < 0) return this + TurnBinding(key, UNKNOWN_LEG, listOf(message))
val old = this[index]
if (old.messageIds.contains(message)) return this
return toMutableList().also { it[index] = old.copy(messageIds = old.messageIds + message) }
}
/**
* 收口(纯函数,返回新表):把 [closingTurnId] 那一路上所有消息的绑定标记为 `closed`。
*
* 判据就是 [closesAnswering](两个 id 都非空且相等),因此一个空白 / 认不出来的 `closingTurnId`
* **一条都不收口**——这正是老服务端(没有 `turnId`)上的行为:什么都不发生。
* 没有命中任何一条时**原样返回同一个实例**(Compose 的稳定性与测试里的 `assertSame` 靠它)。
*/
fun List<TurnBinding>.closingTurn(closingTurnId: String?): List<TurnBinding> {
var changed = false
val next = map { binding ->
if (!binding.closed && closesAnswering(binding.turnId, closingTurnId)) {
changed = true
binding.copy(closed = true)
} else {
binding
}
}
return if (changed) next else this
}
/**
* 把**全部**在途路收口(纯函数):`session_idle` 的规范语义是「所有在途路都已收尾且 pending 空」
* (规范 §1.5),所以那一帧到达时**不可能**还有没收口的路——本函数就是这条语义的兜底落地。
* 它是兜底而不是主路径:主路径仍是各自 `turnId` 的 `turn.end`(规范 §1.2)。
*/
fun List<TurnBinding>.allClosed(): List<TurnBinding> {
if (none { !it.closed }) return this
return map { if (it.closed) it else it.copy(closed = true) }
}
/**
* 路标文案(规范 §1.4 A 的「小标」):**真的是并行路**且 `leg >= 2` 时才成句,其余返回 `null`。
*
* 两个条件各挡一类错误,缺一不可:
* - [parallel] 为 `false` ⇒ **恒不挂**,哪怕 `leg` 是 2、3、99。`leg` 是**会话内第几条路**
* (服务端对每条新路都自增,普通串行回合照样占号),拿它当并行的证据就是**对用户说谎**:
* 会话里第二条普通消息会凭空冒出一枚「并行路 2」,而那一刻一条并行都没有。是不是并行路
* 只有 `turn_begin.kind`(⇒ [TurnBinding.parallel])说了算;
* - [leg] 小于 2(含 [UNKNOWN_LEG])⇒ 不挂:单路是今天的常态(`parallel:true` 是客户端显式声明
* 才有的能力,规范 §0 的默认行为一条),给唯一一路也挂个「并行路 1」等于给每条回复都加一个新的
* 视觉噪音,却什么都说明不了。所以「有没有小标」本身就精确表达「这里是不是真的有多路」。
* `leg` 未知同理不挂——不能把「不知道第几路」说成「第 0 路」。
*/
fun legBadgeOf(leg: Int, parallel: Boolean): String? =
if (parallel && leg >= 2) "并行路 $leg" else null
/** 空白串等同于缺席(服务端是「整键缺席」,但真给空串时按同一件事处理,与既有各纯函数一致)。 */
private fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
@@ -0,0 +1,177 @@
package top.yeij.cyrene.domain.chat
/**
* 一次「没发出去」留下的记录:**那一条**的内容指纹 + 当时铸的幂等键(`localId`)+ 记下它的时刻。
*
* [atMillis] 只服务于 [RetryKeyMemory.SLOT_TTL_MILLIS] 那道时间闸,与键本身的语义无关:没有它,一条
* 几小时前的失败记录会一直活着,用户**新**打的一句同内容的话就会被当成重试(见 [RetryKeyMemory])。
*/
data class RetryKeySlot(
val fingerprint: MessageFingerprint,
val localId: String,
val atMillis: Long,
)
/**
* [RetryKeyMemory.keyFor] 的判定结果:这一次发送该用哪个幂等键,以及记忆变成什么样。
*
* 两半都是判定的一部分,所以一起回:**命中的那一条**会被消费掉(见 [RetryKeyMemory.keyFor]),
* 把它放进返回值,调用方就没有「用完了忘了清」的机会。
*/
data class RetryKeyDecision(val localId: String, val memory: RetryKeyMemory)
/**
* 「没发出去的那几条」的记忆——**多槽**、**有时间上界**、纯值、可单测。
*
* ## 它存在的理由
* 幂等键要想真的挡住重复,就必须在**同一条消息**的两次发送之间保持不变。而「某次失败」并不
* 等于「服务端没收下」:队列满(REST `409` / 定向 `chat_busy`)是**真的没进去**,但超时、
* 回执丢失、连接被踢完全可能是「服务端已经落库了,只是客户端没看见」。用户手动重试时若铸一个
* 新键,服务端就会把它当成**第二条**消息落库 —— 用户看到重复。这里记下失败那次的指纹与键,
* 让「一模一样的内容再发一次」落回同一个键,由服务端用 `chat_duplicate` /
* `{duplicate:true,messageId}` 替本端裁决。
*
* ## 时间上界:10 分钟,为什么多槽必须配一个时钟
* 记录是**跨很多次发送**存活的(这正是多槽的意义,见下面「为什么是多槽」),于是有了一个单槽时代
* 不存在的窗口:用户**几小时前**失败过一句「你好」,之后**新**打一遍「你好」—— 他心里当的是一条
* **新**消息,指纹却与那条旧记录一字不差 ⇒ [keyFor] 命中 ⇒ 复用旧键 ⇒ 服务端回 `chat_duplicate`
* ⇒ 这条新消息**根本不发出去**,屏幕上看着像「按了没反应」。给每个槽加一道时间闸
* [SLOT_TTL_MILLIS],这个窗口就被钉死在 10 分钟内;而真正需要幂等的那一幕 ——「刚失败完,用户马上
* 又按了一次发送」—— 一秒都没被削掉。
*
* 时钟由调用方喂([remember] 的 `atMillis`、[keyFor] 的 `nowMillis`),纯值类型里**不藏**
* `System.currentTimeMillis()`:判据才可单测,也才不用为了「等 10 分钟」写 sleep。
*
* ## 边界口径:正好等于 TTL **不算**过期
* 判据是 `nowMillis - atMillis > SLOT_TTL_MILLIS`(严格大于),所以 `nowMillis == atMillis + TTL`
* 那一条**仍然命中**,真正过期要再晚一毫秒。选这一边的理由:常量自身就能当「仍在窗口内」的边界值
* 直接断言(测试不必为「还活着」那一侧发明魔法数),过期那一侧才需要显式的 `+1`;而真实时钟几乎
* 不可能精确落在这 1 毫秒上,口径选哪边都没有实害,可测的那边更有价值。测试
* `a slot exactly at the TTL boundary still counts as fresh` 把这条钉住。
*
* ## 三条纪律
* 1. **只在回滚时写**([remember]:发送失败 / 队列满被拒 / 附件与图片上传失败 / 图片处理失败…):
* 被受理的那条没有任何要记的东西。
* 2. **在用户下一次发送时按指纹取用**([keyFor]):指纹相等 ⇒ 复用那个键、**且只消费那一条**;
* 不等 ⇒ 铸新键、**一条槽也不清**(多槽下「不等也清」是错的,见 [keyFor] 的注释)。
* 3. **按 `localId` 清**([forget]),不是无条件清空:几条同时在飞时,一条被受理(它的键与别的槽
* 里记的那条对不上)不该把别条刚记下的重试键抹掉。
*
* ## 为什么是「多槽」:单槽的边界
* 单槽只记得**最近一次**失败那条,而用户的重试顺序不必和失败顺序一致:他重试 A(又失败),再回头
* 去重试更早失败的 B —— B 的槽早被 A 顶掉了,那次重发只能铸新键([keyFor] 未命中),于是
* 「服务端其实已经收下」的那条 B 落成**第二条**消息,正是本机制要消灭的重复。多槽让每条失败各留
* 一个槽,用户重试哪一条就落回哪一条的键,与他的操作顺序无关。
*
* ## 上限与淘汰:条数上限 8 + 时间上界 10 分钟,两条规则同时生效
* 一条记录只在「用户可能还要重试」的窗口里有意义:失败后正文 / 图片 / 附件都还回了输入栏,他要么
* 马上原样重发、要么早就改了内容(那就成了另一条消息,记录永远命中不了)。窗口外留下的只是垃圾,
* 所以有两把尺子:条数上限 [MAX_SLOTS](满了丢**最旧**的,[remember] 里 FIFO)与时间上界
* [SLOT_TTL_MILLIS](比上限还旧的那条,无论排第几都作废)。
*
* **顺序是死的**:[remember] / [keyFor] **先按时间淘汰**过期槽,再做自己那件事(去重追加 / 查命中),
* 写入时**最后**才看条数上限。先按时间清的好处是过期槽不占那份本该给新鲜记录的额度 —— 否则 8 个
* 早已过期的槽会把刚写进来的那条挤出去(或者更糟:让 [keyFor] 在一个条数已满、内容却全过期的记忆里
* 报「满了」)。
*
* 刻意**不**做 LRU:命中并消费是**轮询**而不是访问 —— [keyFor] 把命中的那条整个摘走,与它有多旧
* 无关(只要求没过期),谈「最近使用」没有意义;也正因如此,剩下的槽天然还是写入顺序。
*
* ## 与「绝不自动重试」不矛盾
* 这里一个字节都不自动重发:复用键只发生在**用户自己又按了一次发送**、且内容一字不差的时候。
* 它做的唯一一件事是把那次重发变成幂等的。
*/
data class RetryKeyMemory(val slots: List<RetryKeySlot> = emptyList()) {
/**
* 记下「这一条没发出去」(回滚时调用)。
*
* 三件事按固定顺序做,别调换(理由见类注释「上限与淘汰」):
* 1. **先按 [atMillis] 淘汰过期槽**——[atMillis] 既是这条记录的出生时刻,也是「现在」;
* 2. 同一个指纹**先摘掉旧的、再把新的追加到末尾**:同一条又失败一次是**更新**它的键与时刻,
* 而不是并列两条 —— 两条同指纹时 [keyFor] 只会命中更早那条,用户的重试反而用不上刚铸的键,
* 而且那条更早的还会**先**过期(明明刚失败过,记录却已经超时,重试退回铸新键);
* 3. 超过 [MAX_SLOTS] 就从头部丢最旧的:本函数最多让**末尾**那条变新,淘汰因此绝不会伤到刚写的
* 这条。
*
* @param atMillis 这次失败发生的时刻,由调用方喂(`System.currentTimeMillis()`)。
*/
fun remember(fingerprint: MessageFingerprint, localId: String, atMillis: Long): RetryKeyMemory {
val fresh = purge(atMillis)
val kept = fresh.slots.filterNot { it.fingerprint == fingerprint } +
RetryKeySlot(fingerprint, localId, atMillis)
return RetryKeyMemory(if (kept.size > MAX_SLOTS) kept.takeLast(MAX_SLOTS) else kept)
}
/**
* 忘掉 [localId] 那条——受理成功 / 撞上 `duplicate` / 气泡被收编时调用。
*
* 一条都对不上就**原样返回同一个实例**(一条也不清):槽里躺的多半是别的刚回滚的消息,顺手清掉
* 它们会让那几条的重试退回「铸新键」。同一个 `localId` 万一有多条([remember] 保证不会有),
* 一并清掉:留着一条键已被服务端受理过的记录,只会让将来的某次重发被当成重复吞掉。
*
* **与时间无关**:受理回执说的是「这条键服务端认了」,无论它记于几分钟前。此处也不顺手扫过期槽
* —— 那要一个调用方没给的「现在」,而且过期槽本来就命中不了([keyFor] 入口会清掉它们),留着
* 最多是几个死条目的内存占用。
*/
fun forget(localId: String): RetryKeyMemory {
val kept = slots.filterNot { it.localId == localId }
return if (kept.size == slots.size) this else RetryKeyMemory(kept)
}
/**
* 这一次发送该用哪个幂等键:内容与某条**未过期**的记录一致 ⇒ **复用**它的键,并**只消费那一条**
* (其余槽原样保留 —— 它们对应的是别的失败消息,正等着被各自的重试捡起来);一条都对不上 ⇒
* 铸一个新的([mint] 由调用方给,测试才能喂确定性的键,也才能断言「该不该铸」),**且一条槽也不清**。
*
* 入口**先**按 [nowMillis] 淘汰过期槽,过期的那条**视同不存在**:找不到就铸新键,与「从来没记过」
* 这条完全一样。这正是时间上界要挡住的那一幕(见类注释「时间上界」)—— 几小时前失败过的同内容
* 消息,今天再打一遍就是一条**新**消息,绝不能因为撞上旧键而被服务端当成重复吞掉。
*
* 最后这半句是与单槽时代最本质的差别,**别照着旧注释改回去**:那时记忆只有一条,「未命中就清空」
* 与「保留」是同一件事;多槽下清空会把用户**别的**失败记录一并抹掉(他重试 A 时铸了新键,接着去
* 重试 B,B 的槽已经没了 ⇒ 那次重发退化成非幂等)。未命中只说明「这次的正文没有任何失败记录对得
* 上」,不是「记忆都作废了」。(过期槽被摘掉不算例外:它们本来就再也命中不了。)
*
* @param nowMillis 这次发送的时刻,由调用方喂(`System.currentTimeMillis()`)。
*/
fun keyFor(
fingerprint: MessageFingerprint,
nowMillis: Long,
mint: () -> String,
): RetryKeyDecision {
val fresh = purge(nowMillis)
val hitIndex = fresh.slots.indexOfFirst { it.fingerprint == fingerprint }
if (hitIndex < 0) return RetryKeyDecision(mint(), fresh)
val consumed = RetryKeyMemory(fresh.slots.filterIndexed { index, _ -> index != hitIndex })
return RetryKeyDecision(fresh.slots[hitIndex].localId, consumed)
}
/**
* 摘掉在 [nowMillis] 这一刻已经过期的槽;一条都没过期就**原样返回同一个实例**(不必新建对象)。
*
* 过期判据见类注释「边界口径」——正好等于 [SLOT_TTL_MILLIS] 的那条**留着**。
*/
private fun purge(nowMillis: Long): RetryKeyMemory {
val kept = slots.filterNot { it.isExpiredAt(nowMillis) }
return if (kept.size == slots.size) this else RetryKeyMemory(kept)
}
companion object {
/** 同时保留的失败记录条数上限;淘汰策略见类注释「上限与淘汰」。 */
const val MAX_SLOTS = 8
/**
* 一条失败记录最多活多久(10 分钟):超过它,[keyFor] 就再也认不出这条记录。
*
* 公开是因为**测试拿它做断言**(别在测试里重写一遍 `10 * 60 * 1000L`,那样改口径时测试不会
* 跟着变)。为什么需要这道闸、边界怎么算,见类注释「时间上界」与「边界口径」。
*/
const val SLOT_TTL_MILLIS = 10 * 60 * 1000L
/** 这条记录在 [nowMillis] 这一刻是否已过期;`>` 而不是 `>=`,见类注释「边界口径」。 */
private fun RetryKeySlot.isExpiredAt(nowMillis: Long): Boolean =
nowMillis - atMillis > SLOT_TTL_MILLIS
}
}
@@ -0,0 +1,63 @@
package top.yeij.cyrene.domain.enums
import androidx.compose.runtime.staticCompositionLocalOf
import top.yeij.cyrene.data.api.dto.EnumItemDto
import top.yeij.cyrene.data.api.dto.EnumTableDto
/**
* 枚举标签查找器(api-client §1.1):`/meta/enums` 抓到什么就查什么。
*
* **未知取值回落(红线)**:不在表内的取值一律**原样返回**——不丢弃、不报错、不替换占位符、
* 不猜测;服务端新增取值属非破坏性变更,不得因此清空角标或中断渲染。
*
* 因此这里**没有任何硬编码兜底表**:宁可显示原始 id,也不能让客户端自建的表与服务端漂移。
* 还没拿到表(未登录 / 首次拉取失败且无快照)时用 [Empty],一切原样显示。
*
* 判定只比对 `id`(大小写敏感,契约 id 为小写);`label` 仅作显示默认值。
*/
class EnumCatalog private constructor(
private val platforms: Map<String, String>,
private val moods: Map<String, String>,
private val modes: Map<String, String>,
) {
/** 表内 → 默认标签;不在表内 → 原始值。 */
fun platformLabel(id: String): String = platforms[id] ?: id
fun moodLabel(id: String): String = moods[id] ?: id
fun modeLabel(id: String): String = modes[id] ?: id
/**
* 表内 → 默认标签;不在表内 → `null`。
* 给「认识」与「不认识」需要走**不同**回落的调用方用(如 `deviceId` 前缀:
* 认识的平台名取友好标签,不认识的原样显示整串 `deviceId`,而不是只留前缀)。
*/
fun platformLabelOrNull(id: String): String? = platforms[id]
companion object {
/** 尚无权威表:一切原样显示。 */
val Empty = EnumCatalog(emptyMap(), emptyMap(), emptyMap())
fun of(table: EnumTableDto?): EnumCatalog = if (table == null) {
Empty
} else {
EnumCatalog(
platforms = table.platforms.toLabels(),
moods = table.moods.toLabels(),
modes = table.modes.toLabels(),
)
}
/**
* 空 id 不入表:否则查空串会拿到别人的标签,等于凭空造出一个标签。
* 空 label 同样不入表:查得到却没有标签只会把角标显示成空白,不如原样显示 id。
*/
private fun List<EnumItemDto>.toLabels(): Map<String, String> = mapNotNull { item ->
item.takeIf { it.id.isNotBlank() && it.label.isNotBlank() }?.let { it.id to it.label }
}.toMap()
}
}
/** 全局注入枚举标签查找器,供聊天角标 / 管理页等深层组件读取(与 `LocalAppPrefs` 同套路)。 */
val LocalEnumCatalog = staticCompositionLocalOf { EnumCatalog.Empty }
@@ -0,0 +1,409 @@
package top.yeij.cyrene.domain.parse
import kotlin.math.min
/*
* ChatContentParser —— 纯 Kotlin 的聊天内容解析器(无 Android / Compose 依赖)。
*
* 权威规则:
* - `configs/personas/cyrene.yaml`「分段约定」「表达方式:动作与旁白」;
* - `devdocs/03-客户端细化开发方案.md` §5 渲染管线;
* - Web 端 `frontend/web/src/lib/parseContent.ts` + `components/chat.tsx`(行为对齐基准)。
*
* 语义摘要:
* - `<p>…</p>`:每个完整块 = 一条独立气泡;无完整 `<p>` 时回退按空行 `\n\s*\n` 切分;
* 单个 `\n` 是句内换行,绝不切分。未闭合的 `<p>` 不构成块,按普通文本处理(流式下会先缓存)。
* `<p>` 之外的非空白正文不会被丢弃:并入相邻气泡(若只是旁白/空白则依附之,否则自成一气泡)。
* - `<action>…</action>` / `<narration>…</narration>`:旁白/动作,不独立成气泡,依附气泡:
* 首部连续块 → 挂气泡上方(head);尾部连续块 → 挂下方(tail);夹在正文中间的块 → 上提到 head。
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
* 关键词 旁白/内心 → narration,其余 → action。
* - 未闭合/畸形的标签一律回落为普通文本,绝不抛异常。
*
* `ParsedBubble` 结构(每条气泡渲染一个):
* - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块;
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
* - `tail` :要渲染在气泡下方的旁白/动作块(尾部连续块)。
* 无正文时 `body` 为空串,仅 `head` 承载整条消息(纯动作/旁白气泡)。
* `parse("")` / 纯空白输入返回空列表;纯空白且无旁白的气泡会被丢弃。
*
* 流式边界决策(`StreamingContentParser`):
* - `onDelta` 返回「当前已确定前缀」的解析结果;`finish` 返回全量缓冲的解析结果,
* 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。
* - 未确定片段从以下最早位置开始整体扣留,不产出半截结果:
* 1) 未闭合的 `<action>` / `<narration>` / `<p>` 开标签(从该开标签起扣留);
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
* - 标签残缺判断只影响「何时产出」,不影响最终结果;扣留期间不会抛异常。
* - 本类无副作用、非线程安全,调用方保证单线程喂入即可。
*/
/** 气泡内的一个有序片段。 */
sealed interface ContentPart {
val text: String
data class Text(override val text: String) : ContentPart
data class Action(override val text: String) : ContentPart
data class Narration(override val text: String) : ContentPart
}
/**
* 一条可渲染的气泡:正文 + 有序片段 + 上/下依附的旁白块。
* 字段含义见文件头注释;`body` 为空且 `head` 非空时,表示只有旁白没有正文。
*/
data class ParsedBubble(
val body: String,
val parts: List<ContentPart>,
val head: List<ContentPart>,
val tail: List<ContentPart>,
) {
val isEmpty: Boolean
get() = body.isEmpty() && head.isEmpty() && tail.isEmpty()
}
/** 完整 `<p>…</p>` 块(允许 `class` 等属性,但要求标签名后为空白或直接 `>`)。 */
private val P_BLOCK_RE = Regex("<p(\\s[^>]*)?>([\\s\\S]*?)</p>")
/**
* 一次匹配旁白的三种等价写法;左侧优先(最靠左的匹配获胜),
* 三种 body 均非贪婪,可跨换行;未闭合/畸形则整段回落为文本。
* 捕获组:1 = 标签名,2 = 标签 body,3 = 全角星 body,4 = 半角星 body。
*/
private val ROLE_RE = Regex(
"<\\s*(action|narration)\\s*>([\\s\\S]*?)</\\s*\\1\\s*>" +
"|*([\\s\\S]*?)*" +
"|\\*((?:动作|旁白|内心|神态|轻声)\\s*[::]?[\\s\\S]*?)\\*",
)
/** 星号块开头的关键词(可带中/英文冒号)。 */
private val ROLE_KEYWORD_RE = Regex("^(动作|旁白|内心|神态|轻声)\\s*[::]?\\s*")
private val ROLE_KEYWORDS = listOf("动作", "旁白", "内心", "神态", "轻声")
/** 空行切分:`\n` + 若干空白 + `\n`。 */
private val BLANK_LINE_RE = Regex("\\n\\s*\\n")
/** 无关键词的全角星块超过此长度即视为普通文本(对齐 Web `STAR_SHORT_RUNES`)。 */
private const val FULL_STAR_MAX_LEN = 40
private val TAG_NAMES = listOf("action", "narration", "p")
private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$")
private val CLOSE_ROLE_TAG_RE = Regex("^</\\s*(action|narration)\\s*>$")
private val OPEN_P_TAG_RE = Regex("^<p(\\s[^>]*)?>$")
private val CLOSE_P_TAG_RE = Regex("^</p>$")
/** 一次性解析:把一条消息的原始文本拆成若干独立气泡(含旁白依附关系)。 */
fun parse(raw: String): List<ParsedBubble> {
if (raw.isEmpty()) return emptyList()
return buildBubbles(tokenize(raw))
}
/**
* 带缓冲的流式增量解析器:`onDelta` 逐段喂入,`finish` 收口。
* 无副作用;调用方保证单线程访问。
*/
class StreamingContentParser {
private val buffer = StringBuilder()
/** 当前累计的原始文本(含尚未确定、被扣留的尾部)。 */
val raw: String
get() = buffer.toString()
/** 追加一段 delta,返回「当前已确定前缀」解析出的气泡列表。 */
fun onDelta(delta: String): List<ParsedBubble> {
if (delta.isNotEmpty()) buffer.append(delta)
val current = buffer.toString()
return parse(current.substring(0, stablePrefixEnd(current)))
}
/** 收口:对全部缓冲做一次性解析,结果与 `parse(拼接的全部 delta)` 完全一致。 */
fun finish(): List<ParsedBubble> = parse(buffer.toString())
/** 清空缓冲,可复用本实例处理下一条消息。 */
fun reset() {
buffer.setLength(0)
}
}
// ---------------------------------------------------------------------------
// 内部实现
// ---------------------------------------------------------------------------
private sealed interface Tok {
data class Txt(val text: String) : Tok
data class Act(val text: String) : Tok
data class Nar(val text: String) : Tok
object Break : Tok
}
/** 先切 `<p>` 块(决定气泡边界),再在每个文本区做旁白/星号解析。 */
private fun tokenize(raw: String): List<Tok> {
val pMatches = P_BLOCK_RE.findAll(raw).toList()
val out = ArrayList<Tok>()
if (pMatches.isEmpty()) {
appendAnnotations(raw, out, splitBlankLines = true)
return out
}
var last = 0
for (m in pMatches) {
val lead = raw.substring(last, m.range.first)
if (lead.isNotEmpty()) appendAnnotations(lead, out, splitBlankLines = false)
out.add(Tok.Break)
val inner = m.groupValues[2]
if (inner.isNotEmpty()) appendAnnotations(inner, out, splitBlankLines = false)
out.add(Tok.Break)
last = m.range.last + 1
}
val trail = raw.substring(last)
if (trail.isNotEmpty()) appendAnnotations(trail, out, splitBlankLines = false)
return out
}
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
private fun appendAnnotations(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
var last = 0
for (m in ROLE_RE.findAll(text)) {
val before = text.substring(last, m.range.first)
if (before.isNotEmpty()) appendText(before, out, splitBlankLines)
when (val part = classify(m)) {
null -> appendText(m.value, out, splitBlankLines)
is ContentPart.Text -> appendText(part.text, out, splitBlankLines)
is ContentPart.Action -> out.add(Tok.Act(part.text))
is ContentPart.Narration -> out.add(Tok.Nar(part.text))
}
last = m.range.last + 1
}
val tail = text.substring(last)
if (tail.isNotEmpty()) appendText(tail, out, splitBlankLines)
}
/** 追加普通文本;无 `<p>` 时按空行插入气泡边界。 */
private fun appendText(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
if (!splitBlankLines) {
out.add(Tok.Txt(text))
return
}
var last = 0
for (m in BLANK_LINE_RE.findAll(text)) {
val piece = text.substring(last, m.range.first)
if (piece.isNotEmpty()) out.add(Tok.Txt(piece))
out.add(Tok.Break)
last = m.range.last + 1
}
val tail = text.substring(last)
if (tail.isNotEmpty()) out.add(Tok.Txt(tail))
}
/** 把一次正则匹配归类;返回 null 表示「整段保持普通文本」。 */
private fun classify(m: MatchResult): ContentPart? {
if (m.groups[1] != null) {
val name = m.groupValues[1]
val body = m.groupValues[2]
return if (name == "action") ContentPart.Action(body) else ContentPart.Narration(body)
}
if (m.groups[3] != null) return classifyFullStar(m.groupValues[3])
if (m.groups[4] != null) return classifyHalfStar(m.groupValues[4])
return null
}
/** 全角 `*…*`:有关键词按关键词定类,否则短且单行才当 action。 */
private fun classifyFullStar(body: String): ContentPart? {
matchRoleKeyword(body)?.let { return it }
val trimmed = body.trim()
val short = trimmed.isNotEmpty() && trimmed.length <= FULL_STAR_MAX_LEN
if (short && !trimmed.contains('\n') && !trimmed.contains('\r')) {
return ContentPart.Action(trimmed)
}
return null
}
/** 半角 `*…*`:正则已保证以关键词开头,这里只负责剥离关键词与定类。 */
private fun classifyHalfStar(body: String): ContentPart =
matchRoleKeyword(body) ?: ContentPart.Action(body.trim())
/** 匹配并剥离开头关键词;旁白/内心 → narration,其余 → action。 */
private fun matchRoleKeyword(body: String): ContentPart? {
val m = ROLE_KEYWORD_RE.find(body) ?: return null
val rest = body.substring(m.value.length).trim()
return if (m.groupValues[1] == "旁白" || m.groupValues[1] == "内心") {
ContentPart.Narration(rest)
} else {
ContentPart.Action(rest)
}
}
/** 以 Break 为界分组,把「只有旁白没有正文」的组并入相邻气泡,再逐组构造气泡。 */
private fun buildBubbles(tokens: List<Tok>): List<ParsedBubble> {
val groups = ArrayList<MutableList<Tok>>()
var current = ArrayList<Tok>()
for (t in tokens) {
if (t is Tok.Break) {
if (current.isNotEmpty()) groups.add(current)
current = ArrayList()
} else {
current.add(t)
}
}
if (current.isNotEmpty()) groups.add(current)
val merged = ArrayList<MutableList<Tok>>()
var carry = ArrayList<Tok>()
for (group in groups) {
val combined = ArrayList<Tok>(carry.size + group.size)
combined.addAll(carry)
combined.addAll(group)
carry = ArrayList()
if (hasBodyText(combined)) merged.add(combined) else carry = combined
}
if (carry.isNotEmpty()) {
if (merged.isNotEmpty()) merged.last().addAll(carry) else merged.add(carry)
}
return merged.mapNotNull { toBubble(it) }
}
private fun hasBodyText(tokens: List<Tok>): Boolean =
tokens.any { it is Tok.Txt && it.text.isNotBlank() }
/** 构造气泡:合并相邻文本、计算 body,并按首/尾规则分配 head / tail。 */
private fun toBubble(tokens: List<Tok>): ParsedBubble? {
val parts = ArrayList<ContentPart>(tokens.size)
for (t in tokens) {
when (t) {
is Tok.Txt -> {
val prev = parts.lastOrNull()
if (prev is ContentPart.Text) {
parts[parts.size - 1] = ContentPart.Text(prev.text + t.text)
} else {
parts.add(ContentPart.Text(t.text))
}
}
is Tok.Act -> parts.add(ContentPart.Action(t.text))
is Tok.Nar -> parts.add(ContentPart.Narration(t.text))
Tok.Break -> Unit
}
}
if (parts.isEmpty()) return null
val firstText = parts.indexOfFirst { it is ContentPart.Text }
val lastText = parts.indexOfLast { it is ContentPart.Text }
val head = ArrayList<ContentPart>()
val tail = ArrayList<ContentPart>()
val body = StringBuilder()
parts.forEachIndexed { i, part ->
if (part is ContentPart.Text) {
body.append(part.text)
} else if (firstText < 0 || i < firstText || (i > firstText && i < lastText)) {
head.add(part)
} else {
tail.add(part)
}
}
val bodyText = body.toString().trim()
if (bodyText.isEmpty() && head.isEmpty() && tail.isEmpty()) return null
return ParsedBubble(body = bodyText, parts = parts.toList(), head = head.toList(), tail = tail.toList())
}
// ---------------------------------------------------------------------------
// 流式边界:计算「可安全产出」的前缀长度
// ---------------------------------------------------------------------------
/** 返回当前缓冲中可安全解析的前缀长度;其后的未完成片段一律扣留。 */
private fun stablePrefixEnd(buf: String): Int {
var cut = buf.length
val openTags = ArrayDeque<Pair<String, Int>>()
var i = 0
while (i < buf.length) {
val lt = buf.indexOf('<', i)
if (lt < 0) break
val gt = buf.indexOf('>', lt)
if (gt < 0) {
if (isPotentialTagPrefix(buf, lt)) cut = min(cut, lt)
break
}
val tag = buf.substring(lt, gt + 1)
val openName = openTagName(tag)
if (openName != null) {
openTags.addLast(openName to lt)
} else {
val closeName = closeTagName(tag)
if (closeName == null) {
i = lt + 1
continue
}
val idx = openTags.indexOfLast { it.first == closeName }
if (idx >= 0) openTags.removeAt(idx)
}
i = gt + 1
}
if (openTags.isNotEmpty()) cut = min(cut, openTags.first().second)
val freeStart = buf.lastIndexOf('>') + 1
cut = withholdDanglingFullStar(buf, freeStart, cut)
cut = withholdDanglingHalfStar(buf, freeStart, cut)
return cut.coerceIn(0, buf.length)
}
private fun openTagName(tag: String): String? {
OPEN_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
return if (OPEN_P_TAG_RE.matches(tag)) "p" else null
}
private fun closeTagName(tag: String): String? {
CLOSE_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
return if (CLOSE_P_TAG_RE.matches(tag)) "p" else null
}
/** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */
private fun isPotentialTagPrefix(buf: String, lt: Int): Boolean {
var j = lt + 1
if (j < buf.length && buf[j] == '/') j++
while (j < buf.length && buf[j].isWhitespace()) j++
val nameStart = j
while (j < buf.length && buf[j] in 'a'..'z') j++
val name = buf.substring(nameStart, j)
if (j >= buf.length) return name.isEmpty() || TAG_NAMES.any { it.startsWith(name) }
if (name.isEmpty()) return false
if (!buf[j].isWhitespace()) return false
return TAG_NAMES.any { it.startsWith(name) }
}
/** 自由文本区内孤立的全角 `*`(计数为奇)从最后一个起扣留。 */
private fun withholdDanglingFullStar(buf: String, from: Int, cut: Int): Int {
var count = 0
var last = -1
for (i in from until buf.length) {
if (buf[i] == '*') {
count++
last = i
}
}
return if (count % 2 == 1 && last >= 0) min(cut, last) else cut
}
/** 自由文本区内孤立且可能开启关键词旁白的半角 `*` 从最后一个起扣留。 */
private fun withholdDanglingHalfStar(buf: String, from: Int, cut: Int): Int {
var count = 0
var last = -1
for (i in from until buf.length) {
if (buf[i] == '*') {
count++
last = i
}
}
if (count % 2 == 0 || last < 0) return cut
return if (couldStartRoleKeyword(buf.substring(last + 1))) min(cut, last) else cut
}
private fun couldStartRoleKeyword(after: String): Boolean {
if (after.isEmpty()) return true
return ROLE_KEYWORDS.any { it.startsWith(after) || after.startsWith(it) }
}
@@ -0,0 +1,45 @@
package top.yeij.cyrene.domain.share
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.update
/**
* 待落地内容的持有者(与 Android 无关,可单测)。
*
* ## 为什么它必须活在 Compose 与 ViewModel 之外
* 内容要跨过「未登录 → 登录」:分享进来时可能还在登录页,甚至 ChatScreen 都还没被组合出来。
* 而消费方(Compose 的 `LaunchedEffect`、Activity、各会话自己的 ChatViewModel)都会随重组 /
* 重建 / 切会话而消失——把待落地内容放在任何一个消费方里,都会在它消失时一起丢掉。
*
* ## 唯一的不变量:一条内容只能被消费一次
* [take] 是**原子出队**:两个消费者同时来(切会话、连接恢复各触发一次),各自只会拿到不同的一条。
* 判据不能是「消费方自己记得已经发过」——消费方本身就会被重建,一重建就忘。
*
* ## 队列而不是单槽
* 单槽会在两条分享挤在同一窗口时**静默丢掉前一条**(比如未登录时先后从相册和浏览器各分享一次)。
* 队列让它们都留在里面,按收到的顺序逐条落地。
*
* 内容只在**进程内存**里:它跨得过登录,跨不过进程被杀。为那一步准备的落盘副本由
* `app/ShareIntake.kt` 在下次启动时清理。
*/
class PendingShareHolder<T> {
private val state = MutableStateFlow<List<T>>(emptyList())
/** 还没落地的内容(按收到顺序);空列表 = 没有待办。 */
val pending: StateFlow<List<T>> = state.asStateFlow()
/** 追加一条(后到的排在后面:先分享的先落地)。 */
fun put(value: T) = state.update { it + value }
/**
* 取走队首并出队;队列为空时回 `null`。
*
* `getAndUpdate` 的 CAS 循环保证「取到的队首」就是**本次转换**的旧值:两个线程同时调用,
* 一个拿到第一条、另一个拿到第二条,不会双双拿到同一条,也不会漏掉一条。
*/
fun take(): T? = state.getAndUpdate { it.drop(1) }.firstOrNull()
}
@@ -0,0 +1,351 @@
package top.yeij.cyrene.domain.share
/**
* 从别的 App 分享进来、已经可以落地的内容。
*
* [images] / [documents] 里的 URI 在**提取阶段**还是对方给的原始 URI;由 `app/ShareIntake.kt`
* 拷进缓存后换成**本端自己**的 `file://`:外部 `content://` 的读授权只在这个 intent 的接收方
* (Activity / 任务)存活期间有效,跨不过「未登录 → 登录」,所以必须在收到的那一刻就落盘。
*
* 丢件计数按**两条路分开记**(图片 / 文档),因为「本端的接收上限」与「对方给的东西读不到」
* 是两句不同的话,而图片与文档的上限也不是同一个数。分享进来的内容**不许静默少发**。
*
* 还有第三件要说的:[contentUnreadable]——对方给了东西、本端**连地址都没认下来**。它连丢件计数
* 都留不下(本端一条都没接收),所以单独一格;否则「分享点下去什么都不发生」正是它造成的。
*/
data class SharedContent(
val text: String = "",
val images: List<String> = emptyList(),
/**
* 非图片的分享文件。MIME 以 `image/` 开头者走 [images],其余一律走这一条
* (判定见 [classifySharedStream])——这正是修复「把一份 `.md` 当图片去解码、
* 最后报一句『图片读不到』」的地方。
*/
val documents: List<SharedDocument> = emptyList(),
/** 超过 [ShareRules.MAX_IMAGES] 而未被接收的图片条数。 */
val truncatedImages: Int = 0,
/** 已接收但**拷贝失败**(读不到 / 写不进 cache)的图片条数。 */
val failedImages: Int = 0,
/** 超过 [ShareRules.MAX_DOCUMENTS] 而未被接收的文件条数。 */
val truncatedDocuments: Int = 0,
/** 已接收但**拷贝失败**的文件条数。 */
val failedDocuments: Int = 0,
/**
* 这次分享**带着内容迹象**(`EXTRA_STREAM` 有值、或 `ClipData` 有条目),却一条可用内容都没
* 提取出来。
*
* 与 [failedImages] / [failedDocuments] **不是**同一件事:那两个说的是「本端已经认下来的地址
* 读不到」(对方给的东西本端拿不动),这里说的是「本端连地址都没认下来」(多为发送方把
* `EXTRA_STREAM` 塞成了本端认不出的形态,或用的是本端不读的 scheme)。它是**唯一**一种既没有
* 内容、也没有丢件计数的失败形态,所以要单独记一格,否则结局就是「用户点了分享、界面毫无反应」。
*/
val contentUnreadable: Boolean = false,
) {
/** 没能落地的总条数。 */
val droppedImages: Int get() = truncatedImages + failedImages
/** 没能落地的文件总条数。 */
val droppedDocuments: Int get() = truncatedDocuments + failedDocuments
/** 没有任何可发送的内容(此时只剩下丢件计数需要告知)。 */
val isEmpty: Boolean get() = text.isBlank() && images.isEmpty() && documents.isEmpty()
/**
* 这条分享值不值得继续走:有可落地的内容,或有要对用户说清的事(丢件计数 / 读不进来)。
*
* 判据只此一处:`ShareIntake.accept` 用它决定要不要往下接,`shareNoticeFor` 用它守卫「什么都没有
* 的分享一个字都不说」那条边界——两处必须同一条,否则会出现「接了却不说」或「说了却没接」。
* 整条分享里什么都没有(`EXTRA_TEXT` 没有值、`EXTRA_STREAM` 也没有值、`ClipData` 也没有条目)
* 时它是 `false`:那次分享确实无话可说,静默才是对的。
*/
val worthDelivering: Boolean
get() = !isEmpty || droppedImages > 0 || droppedDocuments > 0 || contentUnreadable
}
/**
* 一份分享进来的**非图片**文件。
*
* [uri] 是拷进 cache 后的本端 `file://` 地址;[displayName] 只用来给上传部件命名(服务端只看
* 扩展名)与给用户看,[sizeBytes] 用于发送前的本地体积预检(拿不到时是 -1,不猜)。
*/
data class SharedDocument(
val uri: String,
val displayName: String = "",
val sizeBytes: Long = -1L,
)
/** 一条分享进来的 URI 该走哪条路。 */
enum class SharedStreamKind { Image, Document }
/**
* 按 MIME 分流(纯函数,可单测)。
*
* **以 `image/` 开头才当图片**,其余(含 `null`、`text/plain`、`application/pdf`……)一律走文档路:
* 此前把所有 `EXTRA_STREAM` 都当图片拷、当图片解码,于是一份 `.md` 只有两种结局——
* 「图片处理失败」或者被悄悄当成一张读不出来的图。文档路至少会把字节原样交给服务端,
* 由**字节嗅探**裁决(白名单内的放行,其余用 `415` 的文案如实说)。
*
* `null`(provider 没报类型)也走文档路:本端**不猜**类型,猜错等于替用户把一个合规文件
* 变成一次失败。代价是「图片但 provider 没报 MIME」会落到文档路并被服务端拒——这是刻意的
* 取舍,见 `app/ShareIntake.kt` 的 KDoc。
*/
fun classifySharedStream(mime: String?): SharedStreamKind {
val base = mime?.substringBefore(';')?.trim()?.lowercase()
return if (base != null && base.startsWith("image/")) SharedStreamKind.Image else SharedStreamKind.Document
}
/**
* 分享入口的规则(纯函数,与 Android 无关,可单测;Intent 的读取在 `app/ShareIntake.kt`)。
*
* ## 为什么只认这两个 action、Manifest 里只声明两类 MIME
* Manifest 只声明 `text/plain` 与图片那一类(`image/` 前缀):声明的每一类都必须是**本端真能
* 落地**的东西。后端**明令拒绝** `text/html` / SVG / XML / 压缩包 / 脚本,声明全通配 MIME
* 或文本通配 MIME 会造出一堆「点进去必然失败」的入口(`02/F1` 那条的三缺早已补上,但类型
* 白名单本身就是 deny-by-default 的 22 个扩展名)。`text/plain` 已经能接住 `.txt` / `.md` /
* `.log` 这类文件管理器会报 `text/plain` 的文件;`.md` 若被报成 `text/markdown`,那它**不进**
* 分享菜单——少一个入口,好过一个必然失败的入口。(**Manifest 不动**是硬约束。)
*
* ## 两个 action 与两条路
* [ACTION_SEND] 收文本 + 一个 URI(图片或文件);[ACTION_SEND_MULTIPLE] 本端只声明了图片那一类,
* 所以多选一条实际只可能是图片。URI 走哪条路由 [classifySharedStream] 按 MIME 判。
*
* ## 字符串形态的 `EXTRA_STREAM`(本模块补上的兼容读取)
* 真实世界里 `EXTRA_STREAM` 有三种形态:规范推荐的 `Uri`(多选时 `ArrayList<Uri>`)、`String[]`、
* `ArrayList<String>`。后两种本模块此前**完全认不出**:`getParcelableExtra` 遇到 `String[]` 只往
* logcat 打一句 `ClassCastException`、然后回 `null`,`getParcelableArrayListExtra` 同理,于是整次
* 分享**静默落空**——用户点了分享,后端零请求、库里零新增、界面连一句话都没有。历史遗留的发送方与
* 跨平台壳应用确实在用这两种形态,所以一并收下。形态分派在 `app/ShareIntake.kt` 的
* `shareStreamExtrasOf`(那一步要读 `Intent` 的原始值),这里只管「拿到一串字符串之后怎么用」。
*
* ⚠️ **字符串形态只认 `content://`**(判据见 [stringStreamUri]):那是本端**新开**的一条读入口,
* 松不得。被拒的条目**逐条跳过**——一次分享里有几条坏的,不该把整次分享废掉。
*
* ## 为什么正文一个字都不加
* **绝不**往用户正文里拼「[用户分享]」之类的来源标记:来源标识走后端的独立 `RoleSystem`
* 来源消息 + 结构化 `SenderName`(`02/F4` / `design/11 §10`),人类可见的标记只能放在本端
* 渲染层。把标记拼进正文 = 让用户正文冒充系统指令,是项目红线。分享进来的正文**原样**发出。
*/
object ShareRules {
/** `Intent.ACTION_SEND`:一条文本,或一张图片 / 一个文件(可能还带一段说明文字)。 */
const val ACTION_SEND = "android.intent.action.SEND"
/** `Intent.ACTION_SEND_MULTIPLE`:多条;本端 Manifest 只声明了图片那一类。 */
const val ACTION_SEND_MULTIPLE = "android.intent.action.SEND_MULTIPLE"
/**
* 一次分享最多接收几张图。
*
* 现有发送路径**一条消息只能带一张图**,所以 N 张图 = N 条消息(第一条带上分享的正文)。
* 上限压在 4 是为了不让一次分享在会话里炸出一长串回合;超出的部分不静默丢,
* 由调用方如实告知(见 [SharedContent.truncatedImages])。
*/
const val MAX_IMAGES = 4
/**
* 一次分享最多接收几个非图片文件。
*
* 取服务端的一个回合上限(契约 v0.59 §1.2:`documents` **≤5 条**)——附件是**挂在同一条
* 消息上**的,所以这里不能像图片那样「多出来的另起几条消息」:超过就是服务端
* `400 too_many_documents`。超出的部分同样如实告知,绝不静默裁。
*/
const val MAX_DOCUMENTS = 5
/**
* 图片超限的那一句(**纯函数**):**分享入口与图片选择入口共用同一句**,所以它只有一个出处。
*
* 两个入口的上限本来就是同一个数([MAX_IMAGES])⇒ 告知也必须是同一句:各写一句,迟早会出现
* 「分享说只发前 4 张、选图说只发前 4 张(措辞不同)」这种同一件事的两种说法。
*
* ## 措辞**入口中性**(主人 2026-10-06 拍板)
* W42 把图片按钮改成多选之后,这一句不止在分享那条路上出现——图片入口里根本没有「分享」
* 这件事。原句以「**分享的**图片」开头,那是一句只对其中一条路成立的限定(在图片入口里读着
* 像在说别人的事),所以句子不再指认动作的来处:只说三件两条路都成立的事实——**有上限**
* ([MAX_IMAGES] 张)、**超出的没发**、**发出去的是前 N 张**。也刻意不写「失败」「错误」:
* 被上限截掉不是一次错误,是上限本身。
*
* @param kept 这次真正发出去的张数(`SharedContent.images` 的长度 / `ImageSendPlan.imagesToSend` 的长度)
* @param dropped 被上限截掉的张数;0 ⇒ `null`(一张没少就没什么要说的,说话才是噪声)
*/
fun truncatedImagesNotice(kept: Int, dropped: Int): String? =
if (dropped > 0) "一次最多 $MAX_IMAGES 张,超出的没发,只发送前 $kept 张" else null
/**
* 从分享 Intent 的原始字段提取内容。
*
* ## 优先顺序与去重(一次分享里多种来源并存时)
* 顺序 = `EXTRA_STREAM` 的规范形态([stream] 在前、[streams] 在后)→ 字符串兼容形态(保持原顺序)
* → `ClipData`(只在前两者一条都没认下时兜底)。同一个地址串被两处各给一次时**只留先到的那一条**:
* 先 trim、去空、去重,再按 MIME 分流、再各自套上限——重复项既不该把真图挤到上限之外,也不该
* 被算成「截掉了 N 条」。**先来的先算**这条在字符串形态上尤其要紧:发送方给的顺序就是它给的顺序。
*
* `ClipData` 只在 `EXTRA_STREAM` 一条都没认下时才用(既有口径):两者都有时只取前者,不会把同一张
* 图数两遍。
*
* @param action `Intent.action`;只认 [ACTION_SEND] / [ACTION_SEND_MULTIPLE]
* @param text `EXTRA_TEXT`——按 `CharSequence` 收,因为它常常是 `Spanned`(带样式的
* HTML 富文本),读成 `String` 会直接拿到 `null`
* @param stream `EXTRA_STREAM` 的单值形态([ACTION_SEND] 那个只放一条的字段)
* @param streams `EXTRA_STREAM` 的列表形态([ACTION_SEND_MULTIPLE] 的字段)
* @param streamStrings `EXTRA_STREAM` 的**字符串兼容形态**(`String` / `String[]` /
* `ArrayList<String>` 摊平后的值,形态分派在 `app/ShareIntake.kt`)。这一档每条都要过
* [stringStreamUri] 的白名单,被拒的**逐条跳过**;调用方给的是原样值(还没过滤),
* 所以「有值但一条都不能用」这件事这里看得见——它就是 [SharedContent.contentUnreadable]
* 要说的那种情形。
* @param streamPresent `EXTRA_STREAM` 里确实躺着**原始值**——由调用方读了原始值判出来(本函数
* 看不到那个值),含本端**认不出形态**的那种。它只有一个用处:形态认不出时本端既不猜、也不该
* 因此闭嘴,那照样是一次「带着内容迹象却什么都没读进来」。
* @param clipUris `ClipData` 各条目的 URI,纯文本条目是 `null`(本端**不读** `ClipData` 里的
* 文本,所以「只有纯文本条目」等同于「读不进来」,用来判 [SharedContent.contentUnreadable])
* @param mimeOf 取一条 URI 的 MIME(Android 侧就是 `ContentResolver.getType`)。本函数是
* **纯函数**,所以类型判定由调用方注入;默认「拿不到类型」⇒ 按文档路走
* (见 [classifySharedStream],默认值就是「不猜」)。
*/
fun extract(
action: String?,
text: CharSequence? = null,
stream: String? = null,
streams: List<String>? = null,
streamStrings: List<String> = emptyList(),
streamPresent: Boolean = false,
clipUris: List<String?> = emptyList(),
mimeOf: (String) -> String? = { null },
): SharedContent {
val recognized = action == ACTION_SEND || action == ACTION_SEND_MULTIPLE
if (!recognized) return SharedContent()
val body = text?.toString().orEmpty().trim()
// 两个字段一并收:单值 / 列表是历史形态(当年靠 getParcelableExtra 与
// getParcelableArrayListExtra 两个读法分开取,才不得不按 action 二选一),
// 本端现在只关心「有哪些地址」,重复的由下面的 distinct 收掉。
val uriForms = listOfNotNull(stream) + streams.orEmpty()
val primary = (uriForms + streamStrings.mapNotNull(::stringStreamUri))
.map(String::trim)
.filter(String::isNotEmpty)
.distinct()
// 空串条目(有的 App 会塞占位)与重复条目都要去掉:不然「4 张图」里可能只剩 2 张真图,
// 而截断计数还会把占位算成一张。
val unique = primary.ifEmpty {
clipUris.filterNotNull().map(String::trim).filter(String::isNotEmpty).distinct()
}
val images = mutableListOf<String>()
val documents = mutableListOf<String>()
unique.forEach { uri ->
when (classifySharedStream(mimeOf(uri))) {
SharedStreamKind.Image -> images += uri
SharedStreamKind.Document -> documents += uri
}
}
// 「带着内容迹象」的判据取的是**原料**(EXTRA_STREAM 里躺着的原始值 / ClipData 的条目数),
// 不是「认下来的地址」——「有值但一条都认不出」正是要说的那种情形,用后者判反倒会把它当成
// 「什么都没分享」而闭嘴。
val hadStreamValue = streamPresent ||
!stream.isNullOrBlank() ||
streams.orEmpty().any(String::isNotBlank) ||
streamStrings.any(String::isNotBlank)
return SharedContent(
text = body,
images = images.take(MAX_IMAGES),
// 提取阶段还不知道显示名与大小(要 ContentResolver),先放原样 URI;
// ShareIntake 拷进 cache 后再换成 file:// + 显示名 + 真实字节数。
documents = documents.take(MAX_DOCUMENTS).map { SharedDocument(uri = it) },
truncatedImages = (images.size - MAX_IMAGES).coerceAtLeast(0),
truncatedDocuments = (documents.size - MAX_DOCUMENTS).coerceAtLeast(0),
contentUnreadable = unique.isEmpty() && body.isEmpty() &&
(hadStreamValue || clipUris.isNotEmpty()),
)
}
/**
* 字符串形态的一条候选地址 ⇒ 可用的地址(trim 之后),不可用回 `null`。
*
* ## 为什么字符串形态只认 `content://`(安全纪律)
* 字符串形态是本端**新开**的一条读入口:送进来的只是一段文本,没有任何框架级的担保(`Uri` /
* `ArrayList<Uri>` 那两个形态至少是对方用 URI **对象**写进去的,而 `EXTRA_STREAM` 里的字符串
* 往往是从别处转发来的——网页给的、壳应用拼的)。所以这条入口开得窄:
*
* - `content://` ⇒ 收下。这是 AOSP 推荐的形态,读权限随这次分享由框架授予。
* - `file://` ⇒ **拒**。那等于让发送方替本端指定一个本地路径、由本端拿**自己的**权限去读它
* (本应用私有目录也在其中),而分享的语义是「用户选中的东西」,不是「别人给的任意路径」。
* 何况 Android 13 起跨应用读外部存储的 `file://` 基本已不通,收下来多半也只是变成一次失败。
* - `http(s)://` ⇒ **拒**。那是网络地址不是本地字节源:收下它等于让第三方的一行文本指定一个 URL、
* 由本应用代它去取,而那条取法在本端没有任何既有的授权 / 体积 / 超时约束。
* - 其余(`javascript:` / `intent:` / 自定义 scheme / 没有 scheme 的裸路径 / 空串)⇒ **拒**。
*
* 被拒的条目由调用方逐条跳过(见 [extract]):一次分享里有几条坏的,不该把整次分享废掉;
* 一条可用地址都没剩下时由 [SharedContent.contentUnreadable] 如实告知。
*
* 判 scheme 用的是 RFC 3986 的形态(首字符字母,其后字母 / 数字 / `+` `-` `.`),与 `Uri.parse`
* 认 scheme 的口径一致;本函数是纯的(`domain/share` 不碰 Android 类型)。
*/
fun stringStreamUri(raw: String): String? {
val trimmed = raw.trim()
val scheme = schemeOf(trimmed) ?: return null
return trimmed.takeIf { scheme.equals(CONTENT_SCHEME, ignoreCase = true) }
}
/** 本端唯一收下的 scheme(理由见 [stringStreamUri])。 */
private const val CONTENT_SCHEME = "content"
/** 一条地址的 scheme(按 RFC 3986 的形态判,大小写不敏感地在调用方比);没有 scheme 回 `null`。 */
private fun schemeOf(raw: String): String? {
val colon = raw.indexOf(':')
if (colon <= 0) return null
val head = raw.substring(0, colon)
val first = head[0]
if (first !in 'a'..'z' && first !in 'A'..'Z') return null
val usable = head.drop(1).all {
it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '+' || it == '-' || it == '.'
}
return head.takeIf { usable }
}
/**
* 落盘文件名:**只由本端生成**的 [token] 与序号 [index] 拼成,扩展名取自 MIME 白名单
* (不在表里就回落 `.img`——真正的类型由字节嗅探裁决,不靠扩展名)。
*
* **绝不**沿用外部 URI 里的路径 / 显示名:那是别人给的字符串,直接当文件名等于把路径穿越的
* 口子接到自己的 cache 目录上。这个函数是纯的,所以它连 [token] 都不信——非法字符一律折成
* `_`,`. .` 因此拼不出 `..`,`/` 也活不下来。
*/
fun cacheFileName(token: String, index: Int, mime: String?): String =
"share-${safeToken(token)}-$index.${extensionFor(mime)}"
/**
* 非图片分享文件的缓存名。
*
* 扩展名**照抄对方给的名字**(服务端按扩展名判白名单,缓存件本身只是本端的临时载体,
* 但名字对得上排查时少一次误解),并且同样只由本端 token 与序号构成路径——
* 对方名字里的 `/` 与 `..` 活不下来(见 [safeExtension])。
*/
fun documentCacheFileName(token: String, index: Int, originalName: String): String =
"share-${safeToken(token)}-$index.${safeExtension(originalName)}"
/** 非白名单 / 空 / 含非法字符的扩展名一律回落 `txt`(**不猜**类型)。 */
private fun safeExtension(originalName: String): String {
val extension = originalName.substringAfterLast('.', "").lowercase()
val usable = extension.length in 1..8 && extension.all { it.isLetterOrDigit() }
return if (usable) extension else "txt"
}
private fun safeToken(token: String): String =
token.map { c ->
if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '-' || c == '_') c else '_'
}.joinToString("").ifEmpty { "x" }
/** MIME → 扩展名;未知 / 缺失回落 `img`。 */
private fun extensionFor(mime: String?): String =
when (mime?.substringBefore(';')?.trim()?.lowercase()) {
"image/png" -> "png"
"image/jpeg", "image/jpg" -> "jpg"
"image/webp" -> "webp"
"image/gif" -> "gif"
"image/bmp", "image/x-ms-bmp" -> "bmp"
"image/heic", "image/heif" -> "heic"
else -> "img"
}
}
@@ -0,0 +1,72 @@
package top.yeij.cyrene.ui.admin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
import top.yeij.cyrene.domain.admin.AdminPage
import top.yeij.cyrene.domain.admin.AdminRole
import top.yeij.cyrene.domain.admin.conclusionParagraphs
import top.yeij.cyrene.domain.admin.formatEpochMillis
import top.yeij.cyrene.domain.admin.orDash
/**
* 后台思考页:`GET /api/v1/admin/backthink`(**仅 `owner`/`admin`**)。
* `at` 是 **Unix 毫秒**,与 trace 的 RFC3339 不同源,用 [formatEpochMillis] 单独解析。
* `conclusion` 是**与聊天消息同一套 Web 端 markup**的原文(真实样本里有连写的空 `<p></p>` 与
* `<action>`),因此不直接上屏:先经 [conclusionParagraphs] 解析成渲染模型,再逐段渲染。
*/
@Composable
fun AdminBackthinkScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
val state by viewModel.backthink.collectAsState()
val reload = { viewModel.load(AdminPage.Backthink, role) }
LaunchedEffect(Unit) { reload() }
AdminPageScaffold(title = AdminPage.Backthink.title, onBack = onBack, onRefresh = reload) { padding ->
AdminLoadShell(
state = state,
onRetry = reload,
modifier = Modifier
.fillMaxSize()
.padding(padding),
) { records ->
if (records.isEmpty()) {
AdminCenterHint("暂无后台思考记录")
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = records, key = { it.id }) { record -> BackthinkCard(record) }
}
}
}
}
}
@Composable
private fun BackthinkCard(record: AdminBackthinkItemDto) {
val paragraphs = remember(record.conclusion) { conclusionParagraphs(record.conclusion) }
AdminRichTextCard(
title = orDash(record.trigger),
paragraphs = paragraphs,
notice = backthinkNotice(record),
trailing = if (record.at > 0) formatEpochMillis(record.at) else "-",
)
}
/** 结尾的推送状态行;与 `conclusion` 正文分开渲染,不参与 markup 解析。 */
private fun backthinkNotice(record: AdminBackthinkItemDto): String =
"通知 ${if (record.shouldNotify) "需推送" else "不推送"} · ${if (record.pushed) "已送达" else "未送达"}"
@@ -0,0 +1,306 @@
package top.yeij.cyrene.ui.admin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.domain.admin.AdminFailure
import top.yeij.cyrene.domain.admin.ConclusionParagraph
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.HorizontalDivider
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.ChevronBackward
import top.yukonga.miuix.kmp.icon.extended.Refresh
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 管理页统一外壳:独立的 Miuix [Scaffold] + 返回按钮,各页只填内容。
* 管理员区是聊天之外的独立导航(design D15),所以每页都有自己的 Scaffold。
*/
@Composable
fun AdminPageScaffold(
title: String,
onBack: () -> Unit,
onRefresh: (() -> Unit)? = null,
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
topBar = {
SmallTopAppBar(
title = title,
navigationIcon = {
IconButton(onClick = onBack) {
Icon(imageVector = MiuixIcons.ChevronBackward, contentDescription = "返回")
}
},
actions = {
if (onRefresh != null) {
IconButton(onClick = onRefresh) {
Icon(imageVector = MiuixIcons.Refresh, contentDescription = "刷新")
}
}
},
)
},
content = content,
)
}
@Composable
fun AdminSectionTitle(text: String) {
Text(
text = text,
style = MiuixTheme.textStyles.subtitle,
color = MiuixTheme.colorScheme.primary,
modifier = Modifier.padding(start = 20.dp, top = 20.dp, bottom = 8.dp),
)
}
/** 居中提示(加载中 / 空态 / 无权限),整页级别。 */
@Composable
fun AdminCenterHint(
title: String,
detail: String? = null,
onRetry: (() -> Unit)? = null,
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 32.dp),
) {
Text(
text = title,
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
if (detail != null) {
Spacer(Modifier.height(6.dp))
Text(
text = detail,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
if (onRetry != null) {
Spacer(Modifier.height(12.dp))
TextButton("重试", onClick = onRetry)
}
}
}
}
/** 区块内的行内提示(不占满高度,可放进可滚动列 / LazyColumn 的 item)。 */
@Composable
fun AdminInlineHint(text: String) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp),
)
}
/**
* 取数失败时的整页状态。
* **无权限与其它失败必须分开**:403/401 给「无权限」+ 角色说明,绝不渲染成空列表。
*/
@Composable
fun AdminFailureState(
failure: AdminFailure?,
onRetry: () -> Unit,
) {
when (failure) {
is AdminFailure.Forbidden -> AdminCenterHint(
title = "无权限",
detail = "当前角色不能读取该页,请用 owner 或 admin 账号登录。",
)
else -> AdminCenterHint(
title = "加载失败",
detail = (failure as? AdminFailure.Other)?.message ?: "请稍后重试",
onRetry = onRetry,
)
}
}
/**
* 列表/详情页共用的取数外壳:先判权限,再判加载,最后才渲染数据。
* 顺序不能反——反了就会把 403 渲染成「暂无数据」。
*/
@Composable
fun <T> AdminLoadShell(
state: AdminUiState<T>,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit,
) {
val data = state.data
Box(modifier = modifier) {
when {
state.failure is AdminFailure.Forbidden -> AdminFailureState(state.failure, onRetry)
data == null && state.loading -> AdminCenterHint("加载中…")
data == null -> AdminFailureState(state.failure, onRetry)
else -> content(data)
}
}
}
/** 「左标签 + 右值」一行;必须放在 [PreferenceCard] 内。 */
@Composable
fun AdminValueRow(title: String, value: String, valueColor: Color? = null) {
BasicComponent(
title = title,
endActions = {
Text(
text = value,
style = MiuixTheme.textStyles.footnote1,
color = valueColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
},
)
}
/** 指标卡片:一组「标签 → 值」,单张卡片内多行。 */
@Composable
fun AdminMetricCard(rows: List<Pair<String, String>>) {
PreferenceCard {
Column {
rows.forEachIndexed { index, (label, value) ->
if (index > 0) {
HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
}
AdminValueRow(title = label, value = value)
}
}
}
}
/** 卡片首行:左标题 + 右尾注(长尾注省略)。两种文本卡共用,外观一致。 */
@Composable
private fun AdminCardHeader(title: String, trailing: String?, trailingColor: Color?) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MiuixTheme.textStyles.main, modifier = Modifier.weight(1f))
if (trailing != null) {
Text(
text = trailing,
modifier = Modifier.widthIn(max = 160.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MiuixTheme.textStyles.footnote1,
color = trailingColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
/** 管理与详情页里的普通文本块(长文本用:[PreferenceCard] 里一行标题 + 正文)。 */
@Composable
fun AdminTextCard(
title: String,
body: String,
trailing: String? = null,
trailingColor: Color? = null,
) {
PreferenceCard {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
AdminCardHeader(title, trailing, trailingColor)
if (body.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(
text = body,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
/**
* 承载 **Web 端 markup 原文**的文本块:正文来自 [ConclusionParagraph] 渲染模型,逐段渲染。
* 段与段之间用间距分隔(正文里不再出现拼接的 `\n`);段内片段按原文顺序同行排列,
* 正文用卡片正文色,动作/旁白(`Action` / `Narration`)按聊天页的约定渲染成**灰斜体**
* (见 `ui/chat/MessageBubble.kt` 的旁白行)。[notice] 是挂在段落下方的次要一行(灰、小字)。
*/
@Composable
fun AdminRichTextCard(
title: String,
paragraphs: List<ConclusionParagraph>,
notice: String? = null,
trailing: String? = null,
trailingColor: Color? = null,
) {
PreferenceCard {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
AdminCardHeader(title, trailing, trailingColor)
paragraphs.forEachIndexed { index, paragraph ->
if (index == 0) Spacer(Modifier.height(4.dp)) else Spacer(Modifier.height(8.dp))
ParagraphLine(paragraph)
}
if (!notice.isNullOrBlank()) {
Spacer(Modifier.height(if (paragraphs.isEmpty()) 4.dp else 8.dp))
Text(
text = notice,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
/** 一段:片段同行渲染,正文正常色,动作/旁白灰斜体;片段之间按需补一个空格。 */
@Composable
private fun ParagraphLine(paragraph: ConclusionParagraph) {
val narrationColor = MiuixTheme.colorScheme.onSurfaceVariantSummary
val line = remember(paragraph, narrationColor) {
buildAnnotatedString {
paragraph.parts.forEachIndexed { index, part ->
if (index > 0 && needsSpace(paragraph.parts[index - 1].text, part.text)) append(' ')
when (part) {
is ContentPart.Text -> append(part.text)
is ContentPart.Action, is ContentPart.Narration -> withStyle(
SpanStyle(color = narrationColor, fontStyle = FontStyle.Italic),
) { append(part.text) }
}
}
}
}
Text(
text = line,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainer,
)
}
/** 相邻片段要不要补空格:两边都没自带空白时才补,别在换行前留出多余空格。 */
private fun needsSpace(previous: String, next: String): Boolean =
previous.isNotEmpty() && next.isNotEmpty() &&
!previous.last().isWhitespace() && !next.first().isWhitespace()
@@ -0,0 +1,154 @@
package top.yeij.cyrene.ui.admin
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.domain.admin.AdminPage
import top.yeij.cyrene.domain.admin.AdminRole
import top.yeij.cyrene.domain.admin.canAccess
import top.yeij.cyrene.domain.admin.orDash
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.ChevronForward
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 管理只读监控(C-2):独立的顶层屏幕 + 独立 Scaffold,**不在聊天界面里**(design D15)。
*
* 结构是「入口页列出可用的管理页 → 逐页进入」的**可扩展壳**:页清单由
* [AdminPage] 驱动,新增一个管理模块只需在清单里加一项、在 [AdminPageScreen] 里加一个分支,
* 不需要改动本文件的布局。
*
* 权限降级(api-client §2.0):入口按角色禁用越权页(**不发请求**),
* ViewModel 再做一次同样的短路,服务端返回 403/401 时还有第三层「无权限」状态。
*/
@Composable
fun AdminHomeScreen(
container: AppContainer,
onBack: () -> Unit = {},
) {
val session by container.authManager.state.collectAsState()
val rawRole = (session as? SessionState.LoggedIn)?.role
val role = AdminRole.of(rawRole)
var openPage by remember { mutableStateOf<AdminPage?>(null) }
val viewModel: AdminViewModel = viewModel(
factory = viewModelFactory { initializer { AdminViewModel(container) } },
)
val page = openPage
BackHandler(enabled = page != null) { openPage = null }
if (page == null) {
AdminIndexScreen(
rawRole = rawRole,
role = role,
onOpen = { openPage = it },
onBack = onBack,
)
} else {
AdminPageScreen(
page = page,
role = role,
viewModel = viewModel,
onBack = { openPage = null },
)
}
}
/** 管理入口页:列出全部监控页,越权页显示为禁用的「无权限」行而不是隐藏。 */
@Composable
private fun AdminIndexScreen(
rawRole: String?,
role: AdminRole,
onOpen: (AdminPage) -> Unit,
onBack: () -> Unit,
) {
AdminPageScaffold(title = "管理员监控", onBack = onBack) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
Text(
text = "只读监控 · 当前角色 ${orDash(rawRole)}",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(start = 20.dp, top = 16.dp),
)
AdminSectionTitle("监控页")
AdminPage.entries.forEach { page ->
PreferenceCard { AdminPageRow(page = page, role = role, onOpen = onOpen) }
Spacer(Modifier.height(8.dp))
}
Spacer(Modifier.height(32.dp))
}
}
}
@Composable
private fun AdminPageRow(
page: AdminPage,
role: AdminRole,
onOpen: (AdminPage) -> Unit,
) {
if (canAccess(role, page)) {
BasicComponent(
title = page.title,
summary = page.summary,
onClick = { onOpen(page) },
endActions = {
Icon(imageVector = MiuixIcons.ChevronForward, contentDescription = null)
},
)
} else {
BasicComponent(
title = page.title,
summary = "需要 owner 或 admin 权限",
endActions = {
Text(
text = "无权限",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.error,
)
},
)
}
}
/** 管理页分发:每个页在自己的文件里带 Scaffold,这里只做映射。 */
@Composable
private fun AdminPageScreen(
page: AdminPage,
role: AdminRole,
viewModel: AdminViewModel,
onBack: () -> Unit,
) {
when (page) {
AdminPage.Overview -> AdminOverviewScreen(viewModel, role, onBack)
AdminPage.Personas -> AdminPersonaScreen(viewModel, role, onBack)
AdminPage.Memories -> AdminMemoryScreen(viewModel, role, onBack)
AdminPage.Backthink -> AdminBackthinkScreen(viewModel, role, onBack)
}
}
@@ -0,0 +1,59 @@
package top.yeij.cyrene.ui.admin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.domain.admin.AdminPage
import top.yeij.cyrene.domain.admin.AdminRole
import top.yeij.cyrene.domain.admin.memoryScopeLabel
import top.yeij.cyrene.domain.admin.orDash
/**
* 记忆页:`GET /api/v1/admin/memories`(**仅 `owner`/`admin`**,含私人记忆正文)。
* `viewer` 进不来:入口已禁用、ViewModel 也短路,服务端还会 403。
*/
@Composable
fun AdminMemoryScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
val state by viewModel.memories.collectAsState()
val reload = { viewModel.load(AdminPage.Memories, role) }
LaunchedEffect(Unit) { reload() }
AdminPageScaffold(title = AdminPage.Memories.title, onBack = onBack, onRefresh = reload) { padding ->
AdminLoadShell(
state = state,
onRetry = reload,
modifier = Modifier
.fillMaxSize()
.padding(padding),
) { memories ->
if (memories.isEmpty()) {
AdminCenterHint("暂无记忆")
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = memories, key = { it.id }) { memory ->
AdminTextCard(
// scope 是开放集合:未知取值原样显示。
title = orDash(memoryScopeLabel(memory.scope)),
body = memory.text,
trailing = memory.id,
)
}
}
}
}
}
}
@@ -0,0 +1,135 @@
package top.yeij.cyrene.ui.admin
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.domain.admin.rfc3339EpochSecond
/**
* 可观测面的**合并**与**本地过滤**(纯函数,契约 §2「可观测性契约」+ §3.12)。
*
* 两个来源各自负责一半,谁都不替代谁:
* - **REST 引导读取**(进页面 / 手动刷新 / 断线重连)给一份**全量**快照(最新在前);
* - **推送帧**是**增量**:`observability_trace` 只带订阅之后新完成的 span,
* `observability_log` 只带订阅之后新增的日志(订阅时以「当前最新」为锚,服务端**不会**
* 把环形缓冲里的历史灌成巨型帧);`observability_metrics` 是**整份快照**,见
* `AdminViewModel`(它是替换而不是合并,所以不在这里)。
*
* 两份东西的合并口径**刻意不一样**:
* - **日志**按 `time` 排成**时间倒序**(见 [normalizeLogEntries])——列表的整体顺序是客户端
* 自己拼出来的,契约只对**单份来源**保证「最新在前」,拼接口上还有一个精度差(服务端推送锚点
* 用的是纳秒内部时间,下发的 `time` 却是秒精度),所以这里不去**推**顺序,而是自己**定义**它;
* - **trace** 保持「新帧接在前面」——span 有服务端写入序号,帧本来就是按序来的,
* 而它的 `startedAt` 是纳秒精度、还可能是不同偏移,重排反而会把正确的顺序排乱。
*/
/** trace 保留窗口,与 REST 引导读取的 `limit`(`AdminRepository.DEFAULT_TRACE_LIMIT`)一致。 */
internal const val TRACE_WINDOW = 100
/**
* 日志保留窗口:与 REST 引导读取的 `limit`(`AdminRepository.DEFAULT_LOG_LIMIT`)以及契约
* §3.12 的单帧上限一致(更旧的条目本就落在保留窗口之外)。三个数必须同源,否则会出现
* 「服务端给了 200 条、客户端只留 100 条」这种悄悄丢数据的偏差。
*/
internal const val LOG_WINDOW = 200
/**
* 合并 trace 帧:新 span 在前,按 `id` 去重(同一条 span 不会因为「帧与 REST 竞态」出现两次),
* 保留最近 [window] 条。[incoming] 为空时**原样返回** [existing](空增量不改状态,
* 也让 REST 那份快照保持原样)。
*/
internal fun mergeTraceSpans(
existing: List<AdminTraceSpanDto>,
incoming: List<AdminTraceSpanDto>,
window: Int = TRACE_WINDOW,
): List<AdminTraceSpanDto> = mergeNewestFirst(existing, incoming, window) { it.id }
/**
* 归一化一份日志快照(REST 引导读取,或已经合并过的列表):**去重 → 按时间倒序 → 截到窗口**。
* 这三步的**顺序不能换**:窗口是位置截断,先截再排就会让一条时间上更旧的条目挤掉更新的那条。
*
* 去重键取整条内容([logEntryKey]),是因为日志**没有 id**;重复时保留**先出现**的那一份,
* 所以 [mergeLogEntries] 以「新到的在前」拼输入,帧里的那份会留在原位。
*/
internal fun normalizeLogEntries(
entries: List<AdminLogEntryDto>,
window: Int = LOG_WINDOW,
): List<AdminLogEntryDto> = entries
.distinctBy(::logEntryKey)
.sortedWith(NEWEST_LOG_FIRST)
.take(window)
/**
* 合并日志帧:[incoming] 是**增量**(订阅之后新增的日志),[existing] 是已有基线
* (REST 快照 + 之前几拍帧),结果满足 [normalizeLogEntries] 的同一套不变式。
*
* [incoming] 为空时**原样返回** [existing]:空增量不是「清空」,也让那次 REST 快照保持原样。
*/
internal fun mergeLogEntries(
existing: List<AdminLogEntryDto>,
incoming: List<AdminLogEntryDto>,
window: Int = LOG_WINDOW,
): List<AdminLogEntryDto> =
if (incoming.isEmpty()) existing else normalizeLogEntries(incoming + existing, window)
/**
* 日志的时间倒序:按 RFC3339 解析出的**秒**降序,用**稳定排序**(Kotlin 的 `sortedWith` 是稳定的)
* 保住同秒条目的**到达顺序**——服务端下发的 `time` 只有秒精度,一秒内多条是常态,
* 顺序信息只在拼接顺序里,不能被排序打乱。
*
* 时间判不出来的条目(正常线上不会出现:`time` 恒是 `Format(time.RFC3339)`)排在**最后**、
* 但**不丢**:宁可让它落到列表尾部,也不因为「时间读不出来」把它从界面上抹掉。
*/
private val NEWEST_LOG_FIRST: Comparator<AdminLogEntryDto> =
compareByDescending { rfc3339EpochSecond(it.time) ?: Long.MIN_VALUE }
/**
* 日志条目的业务键;合并去重与列表 `key` 都用它,两处必须一致。
*
* 去重键取整条内容,是因为日志**没有 id**。代价如实说:同一秒内、来源与消息完全相同的两条
* 日志会被收成一条——与 Web 面板(`observabilityStream.ts` 按业务键合并)同一口径。
*/
internal fun logEntryKey(entry: AdminLogEntryDto): String =
"${entry.time}|${entry.level}|${entry.source}|${entry.msg}"
/**
* 按**最低等级**过滤日志,纯客户端行为:服务端的 `?level=` 对推送不适用
* (帧广播给所有 owner/admin 连接,订阅只决定推送循环跑不跑)。
*
* [minLevel] 为空 / 未知 → 不过滤。**等级未知的条目一律保留**:`level` 是开放集合,
* 判不出高低的条目不能因为「可能低于阈值」被静默藏掉(与原样保留未知值的约定一致)。
*/
internal fun filterLogEntries(
entries: List<AdminLogEntryDto>,
minLevel: String?,
): List<AdminLogEntryDto> {
val threshold = levelRank(minLevel) ?: return entries
return entries.filter { (levelRank(it.level) ?: Int.MAX_VALUE) >= threshold }
}
/** 日志等级序:契约里的 `debug|info|warn|error` 由低到高;未知等级返回 null。 */
private fun levelRank(level: String?): Int? = when (level?.trim()?.lowercase()) {
"debug" -> 0
"info" -> 1
"warn" -> 2
"error" -> 3
else -> null
}
/**
* 「最新在前」列表的合并(**只给 trace 用**;日志走 [normalizeLogEntries],因为它还要按时间排序):
* 先把 [incoming] 按原序接在前面(内部同样按 [key] 去重),再补上 [existing] 里没出现过的那些;
* 最后截断到 [window]。
*/
private fun <T> mergeNewestFirst(
existing: List<T>,
incoming: List<T>,
window: Int,
key: (T) -> String,
): List<T> {
if (incoming.isEmpty()) return existing
val seen = HashSet<String>(incoming.size + existing.size)
val merged = ArrayList<T>(incoming.size + existing.size)
for (item in incoming) if (seen.add(key(item))) merged += item
for (item in existing) if (seen.add(key(item))) merged += item
return if (merged.size <= window) merged else ArrayList(merged.subList(0, window))
}
@@ -0,0 +1,367 @@
package top.yeij.cyrene.ui.admin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.domain.admin.AdminFailure
import top.yeij.cyrene.domain.admin.AdminPage
import top.yeij.cyrene.domain.admin.AdminRole
import top.yeij.cyrene.domain.admin.PathGroup
import top.yeij.cyrene.domain.admin.PathHit
import top.yeij.cyrene.domain.admin.PathRow
import top.yeij.cyrene.domain.admin.PathStats
import top.yeij.cyrene.domain.admin.canReadObservabilityLogHistory
import top.yeij.cyrene.domain.admin.formatCounts
import top.yeij.cyrene.domain.admin.formatDouble
import top.yeij.cyrene.domain.admin.formatNanos
import top.yeij.cyrene.domain.admin.formatProps
import top.yeij.cyrene.domain.admin.formatRfc3339
import top.yeij.cyrene.domain.admin.orDash
import top.yeij.cyrene.domain.admin.traceKindLabel
import top.yeij.cyrene.domain.admin.traceStatusLabel
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.HorizontalDivider
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.TabRow
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.ChevronForward
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 概览页:`metrics` 卡片 + 「按路径」分组列表 + 其下的 `trace` 列表 + 「最近日志」
* (api-client §2「可观测性契约」与 §3.12「可观测实时推送」)。
*
* 三块都是**REST 打底、帧叠加**:首屏(以及每次刷新 / 断线重连)拉一次全量快照,
* 之后 `observability_*` 三帧(仅 owner/admin 收得到)由 [AdminViewModel] 合进同一份状态。
* 因此**每一块都按自己那次读取的结果决定怎么画**:区块里绝不出现「用别人的失败解释我」——
* 三路的失败原因是分开的(见 `AdminOverviewData`)。
*
* 「最近日志」的历史来自 `GET /api/v1/admin/observability/logs`,那是 v0.40 的只读监控档,
* **`viewer` 也读得到**;而增量帧只发 owner/admin。所以区块是否存在只看
* [canReadObservabilityLogHistory](= 历史读不读得到),**不看帧有没有到过**——
* `viewer` 的列表不增长,不是错误、也不是空态。
*/
@Composable
fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
val state by viewModel.overview.collectAsState()
val reload = { viewModel.load(AdminPage.Overview, role) }
// 折叠状态放在 LazyColumn 之外:行随滚动回收,状态不能挂在 item 里。
// 只记**用户动过的组**,其余按分组默认档(见 PathStats.expanded),刷新不会把大组锁在打开状态。
val toggledGroups = remember { mutableStateMapOf<String, Boolean>() }
// 日志等级选择(`null` = 全部)。**在本地过滤**:推送帧是广播给所有 owner/admin 连接的,
// 服务端的 `?level=` 对帧不适用(契约 §3.12)。放这里而不是 LazyColumn 的 item 里,
// 是为了让「读状态 → 重算列表」发生在正常的 compose 作用域里。
var logLevel by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { reload() }
AdminPageScaffold(title = AdminPage.Overview.title, onBack = onBack, onRefresh = reload) { padding ->
val data = state.data
val failure = state.failure
val visibleLogs = remember(data?.logs, logLevel) {
filterLogEntries(data?.logs.orEmpty(), logLevel)
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
when {
failure is AdminFailure.Forbidden -> AdminFailureState(failure, reload)
data == null && state.loading -> AdminCenterHint("加载中…")
// 三块都没有可画的东西才整页报错;只要有一路成功,就让它画出来、
// 失败的区块各自说明自己(把一次成功藏在一句「加载失败」后面是另一种撒谎)。
data == null || (data.metrics == null && data.trace.isEmpty() && data.logs == null) ->
AdminFailureState(failure, reload)
else -> LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp),
) {
item(key = "__metrics__") {
Column {
AdminSectionTitle("指标")
if (data.metrics != null) {
AdminMetricCard(metricRows(data.metrics))
Spacer(Modifier.height(8.dp))
AdminMetricCard(countRows(data.metrics))
} else {
AdminTextCard("指标不可用", failureMessage(data.metricsFailure))
}
}
}
item(key = "__paths__") {
// metrics 缺席时这里整段不渲染:上面那张卡已经说明了「指标不可用」,
// 再给一个空列表就是把失败画成「没有路径」。
data.metrics?.let { metrics ->
PathStatsSection(perPath = metrics.perPath, toggled = toggledGroups)
}
}
item(key = "__trace_title__") { AdminSectionTitle("最近 trace") }
when {
// trace 失败时绝不渲染成「暂无数据」——那是把失败伪装成空成功。
// 这里只看 **trace 自己**的失败:日志那边挂了不该让这块说「不可用」。
data.traceFailure != null -> item(key = "__trace_error__") {
AdminTextCard("trace 不可用", failureMessage(data.traceFailure))
}
data.trace.isEmpty() -> item(key = "__trace_empty__") {
AdminInlineHint("暂无 trace span")
}
else -> items(items = data.trace, key = { it.id }) { span ->
TraceSpanCard(span)
}
}
// 日志:历史来自 REST 引导读取(viewer 也读得到),增量来自 observability_log 帧
// (只发 owner/admin)。区块的存在只由「历史读不读得到」决定,帧有没有到过**不参与**
// ——viewer 收不到增量,不该因此看到任何错误或空态(契约 §2.0 / §3.12)。
if (canReadObservabilityLogHistory(role)) {
item(key = "__logs_title__") { AdminSectionTitle("最近日志") }
when {
// 有基线(含空列表):这一块的内容就是它 + 之后的帧增量。
data.logs != null -> if (data.logs.isEmpty()) {
item(key = "__logs_empty__") { AdminInlineHint("暂无日志") }
} else {
item(key = "__logs_level__") { LogLevelTabs(logLevel) { logLevel = it } }
if (visibleLogs.isEmpty()) {
item(key = "__logs_filtered__") { AdminInlineHint("当前等级下没有日志") }
} else {
items(items = visibleLogs, key = { logEntryKey(it) }) { entry ->
LogEntryCard(entry)
}
}
}
// 那次引导读取失败:说失败,绝不说「暂无日志」。
data.logsFailure != null -> item(key = "__logs_error__") {
AdminTextCard("日志不可用", failureMessage(data.logsFailure))
}
// 读取还在飞(例如上一次「日志不可用」之后按了刷新):说加载中。
else -> item(key = "__logs_loading__") { AdminInlineHint("日志加载中…") }
}
}
item(key = "__bottom__") { Spacer(Modifier.height(32.dp)) }
}
}
}
}
}
private fun metricRows(metrics: AdminMetricsDto): List<Pair<String, String>> = listOf(
"总请求数" to metrics.totalRequests.toString(),
"QPS(近 1 秒)" to formatDouble(metrics.qps),
"平均延迟" to "${formatDouble(metrics.avgLatencyMs)} ms",
"最大延迟" to "${metrics.maxLatencyMs} ms",
"LLM 调用" to metrics.llmCalls.toString(),
"子代理事件" to metrics.subagentEvents.toString(),
)
/** 「按方法」「状态码」两类计数仍是一行文本:它们最多三五条,分组反而更碎。 */
private fun countRows(metrics: AdminMetricsDto): List<Pair<String, String>> = listOf(
"按方法" to formatCounts(metrics.perMethod).ifBlank { "-" },
"状态码" to formatCounts(metrics.statusCounts).ifBlank { "-" },
)
/**
* 「按路径」区块:分组 + 可折叠(分组规则与排序全在 [PathStats],这里只负责画)。
*
* 首屏可读性靠两件事:**条数多的大组默认收起**(真实响应里 15 条 admin 路径收成一行组头)、
* 小组默认展开(省一次点击);行里同时给「条数」与「次数」,因为「15 条路径」和「283 次命中」
* 是两个不同的信息。
*/
@Composable
private fun PathStatsSection(perPath: Map<String, Long>, toggled: MutableMap<String, Boolean>) {
val groups = remember(perPath) { PathStats.groupPaths(perPath) }
val pathCount = groups.sumOf { it.pathCount }
val hits = groups.sumOf { it.hits }
// 这一行每次重组都要重算(toggled 是快照状态,读它才能跟着点按更新);纯计算,成本可忽略。
val rows = PathStats.rows(groups, toggled)
AdminSectionTitle("按路径($pathCount 条 · $hits 次)")
if (rows.isEmpty()) {
// 走到这里说明 metrics 请求**成功**了而 `perPath` 为空——成功后的空,不是失败。
AdminInlineHint("暂无路径统计")
return
}
PreferenceCard {
Column {
rows.forEachIndexed { index, row ->
if (index > 0) HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
when (row) {
is PathRow.Header -> PathGroupRow(
group = row.group,
expanded = PathStats.expanded(row.group, toggled),
onToggle = { toggled[row.group.prefix] = !PathStats.expanded(row.group, toggled) },
)
is PathRow.Leaf -> PathLeafRow(hit = row.hit, nested = row.nested)
}
}
}
}
}
/** 组头:前缀 + 「N 条 · M 次」+ 箭头(展开时转向下)。整行可点,是唯一的折叠入口。 */
@Composable
private fun PathGroupRow(group: PathGroup, expanded: Boolean, onToggle: () -> Unit) {
BasicComponent(
onClick = onToggle,
endActions = {
Text(
text = "${group.pathCount} 条 · ${group.hits} 次",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
Icon(
imageVector = MiuixIcons.ChevronForward,
contentDescription = if (expanded) "收起" else "展开",
modifier = Modifier
.padding(start = 6.dp)
.rotate(if (expanded) 90f else 0f),
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
},
) {
Text(
text = group.prefix,
fontSize = MiuixTheme.textStyles.main.fontSize,
fontWeight = FontWeight.Medium,
color = MiuixTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/**
* 路径行:左路径、右计数。
*
* 路径用**等宽字体**(路径是给人对比字符用的,等宽最好扫读),字号取 `footnote1` 与计数一致;
* 过长时**中间省略**——尾部(资源名)才是区分同类路径的部分,尾部省略等于把两条路径画成一样。
*/
@Composable
private fun PathLeafRow(hit: PathHit, nested: Boolean) {
BasicComponent(
endActions = {
Text(
text = hit.count.toString(),
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
},
) {
Text(
text = hit.display,
modifier = if (nested) Modifier.padding(start = 12.dp) else Modifier,
fontSize = MiuixTheme.textStyles.footnote1.fontSize,
fontFamily = FontFamily.Monospace,
color = MiuixTheme.colorScheme.onSurfaceContainer,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
}
@Composable
private fun TraceSpanCard(span: AdminTraceSpanDto) {
AdminTextCard(
title = span.name.ifBlank { span.id },
body = traceBody(span),
trailing = orDash(traceStatusLabel(span.status)),
trailingColor = if (span.status == "error") MiuixTheme.colorScheme.error else null,
)
}
/**
* 日志一行:消息是主体,来源 / 时间作副文本,等级作尾注。
*
* 等级只给 `error` 上错误色——与 trace 卡按 `status` 着色同一口径(Miuix 配色里没有「警告」档,
* 不猜一个近似色)。消息**不截断**:这是诊断视图,被截掉的往往正是要看的那半句。
*/
@Composable
private fun LogEntryCard(entry: AdminLogEntryDto) {
AdminTextCard(
title = entry.msg.ifBlank { "(无消息)" },
body = logMeta(entry),
trailing = entry.level.ifBlank { null },
trailingColor = if (entry.level.equals("error", ignoreCase = true)) {
MiuixTheme.colorScheme.error
} else {
null
},
)
}
/** 副文本:来源 + 本地时间;两者都可能缺,缺了就不占位。 */
private fun logMeta(entry: AdminLogEntryDto): String = listOfNotNull(
entry.source.takeIf { it.isNotBlank() },
formatRfc3339(entry.time),
).joinToString(" · ")
/**
* 日志等级过滤档(`null` = 全部)。最低等级语义与后端 `?level=` 一致(`warn` 含 `error`),
* 只是过滤发生在客户端——推送帧是广播的,服务端没有按连接分级。
*/
private val LOG_LEVEL_TABS = listOf<Pair<String, String?>>(
"全部" to null,
"信息+" to "info",
"警告+" to "warn",
"错误" to "error",
)
@Composable
private fun LogLevelTabs(selected: String?, onSelect: (String?) -> Unit) {
TabRow(
tabs = LOG_LEVEL_TABS.map { it.first },
// 未知取值回落第一档:列表与选中的档位由**同一份清单**驱动,不会出现「都不选中」。
selectedTabIndex = LOG_LEVEL_TABS.indexOfFirst { it.second == selected }.coerceAtLeast(0),
onTabSelected = { index -> onSelect(LOG_LEVEL_TABS[index].second) },
modifier = Modifier.padding(horizontal = 20.dp),
)
}
private fun traceBody(span: AdminTraceSpanDto): String = buildList {
add("${traceKindLabel(span.kind)} · ${formatNanos(span.durationNs)}")
add("开始 ${formatRfc3339(span.startedAt) ?: orDash(span.startedAt)}")
span.endedAt?.let { add("结束 ${formatRfc3339(it) ?: it}") }
span.parentId?.let { add("父 span $it") }
formatProps(span.props)?.let { add("props $it") }
span.events?.forEach { event ->
val props = formatProps(event.props)?.let { " $it" }.orEmpty()
add("事件 ${event.type} @ ${formatRfc3339(event.time) ?: event.time}$props")
}
}.joinToString("\n")
private fun failureMessage(failure: AdminFailure?): String =
(failure as? AdminFailure.Other)?.message ?: "请求失败,请重试"
@@ -0,0 +1,117 @@
package top.yeij.cyrene.ui.admin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
import top.yeij.cyrene.data.api.dto.PersonaThinkingDto
import top.yeij.cyrene.domain.admin.AdminPage
import top.yeij.cyrene.domain.admin.AdminRole
import top.yeij.cyrene.domain.admin.moodLabel
import top.yeij.cyrene.domain.admin.orDash
import top.yeij.cyrene.domain.admin.personaModeLabel
import top.yeij.cyrene.domain.admin.triStateLabel
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 人格页:`GET /api/v1/admin/personas`(`viewer` 可读),含 admin 专属 `thinking` 旋钮。 */
@Composable
fun AdminPersonaScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
val state by viewModel.personas.collectAsState()
val reload = { viewModel.load(AdminPage.Personas, role) }
LaunchedEffect(Unit) { reload() }
AdminPageScaffold(title = AdminPage.Personas.title, onBack = onBack, onRefresh = reload) { padding ->
AdminLoadShell(
state = state,
onRetry = reload,
modifier = Modifier
.fillMaxSize()
.padding(padding),
) { personas ->
if (personas.isEmpty()) {
AdminCenterHint("暂无人格")
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = personas, key = { it.id }) { PersonaCard(it) }
}
}
}
}
}
@Composable
private fun PersonaCard(persona: PersonaSummaryDto) {
val catalog = LocalEnumCatalog.current
PreferenceCard {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(persona.name.ifBlank { persona.id }, style = MiuixTheme.textStyles.main)
if (persona.isDefault) {
Text(
text = " · 默认",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.primary,
)
}
}
PersonaDetail("id", persona.id)
PersonaDetail("character", persona.character.ifBlank { "-" })
// mode 是开放集合:权威表里没有的取值原样显示。
PersonaDetail("mode", personaModeLabel(persona.mode, catalog).ifBlank { "-" })
PersonaDetail("记忆域", persona.memoryNamespace.ifBlank { "-" })
// mood 为空表示「该角色暂无被跟踪的情绪」,不伪造默认值。
PersonaDetail(
"情绪",
persona.mood?.takeIf { it.isNotBlank() }?.let { moodLabel(it, catalog) } ?: "暂无",
)
PersonaDetail("思考", thinkingLabel(persona.thinking))
persona.avatar?.let { PersonaDetail("avatar", it) }
}
}
}
@Composable
private fun PersonaDetail(label: String, value: String) {
Spacer(Modifier.height(2.dp))
Text(
text = "$label:$value",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
/**
* `thinking` 未配置时该键**完全不出现**(不是全零对象);已配置时 `showToUser` 是三态,
* null 必须显示成「未配置」,不能拍平成「不显示」。
*/
private fun thinkingLabel(thinking: PersonaThinkingDto?): String {
if (thinking == null) return "未配置"
return listOf(
"budgetTokens ${thinking.budgetTokens}",
"effort ${orDash(thinking.effort)}",
"mode ${orDash(thinking.mode)}",
"showToUser ${triStateLabel(thinking.showToUser)}",
).joinToString(" · ")
}
@@ -0,0 +1,308 @@
package top.yeij.cyrene.ui.admin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
import top.yeij.cyrene.data.ws.ChatSocket
import top.yeij.cyrene.data.ws.WsEvent
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.admin.AdminFailure
import top.yeij.cyrene.domain.admin.AdminPage
import top.yeij.cyrene.domain.admin.AdminRole
import top.yeij.cyrene.domain.admin.canAccess
import top.yeij.cyrene.domain.admin.canReadObservabilityLogHistory
import top.yeij.cyrene.domain.admin.canReceiveObservabilityFrames
import top.yeij.cyrene.domain.admin.toAdminFailure
/**
* 单个管理页的取数状态。
* [data] 为 null 表示**还没拿到**(加载中或失败),空集合表示**确实没有数据**——
* 两者在 UI 上必须分开,否则 403 会伪装成「暂无数据」。
*/
data class AdminUiState<T>(
val loading: Boolean = false,
val data: T? = null,
val failure: AdminFailure? = null,
)
/** 概览页:metrics / trace / 日志三路取数合成一份状态。 */
data class AdminOverviewData(
val metrics: AdminMetricsDto? = null,
val trace: List<AdminTraceSpanDto> = emptyList(),
/**
* 「最近日志」:null = **还没有历史基线**(引导读取在飞,或那次读取失败)⇒ 界面整段不渲染;
* 非 null(**含空列表**)= REST 读过一次且成功 ⇒ 渲染区块,空列表就是**确实没有日志**。
*
* 推送帧是**增量**(`observability_log` 以订阅那一刻的最新条目为锚,不回灌历史),
* 所以帧**不**负责把它从 null 变成非 null:没有基线就没有可以叠加的东西。
*/
val logs: List<AdminLogEntryDto>? = null,
/**
* 三路各自失败的原因。**必须分开记**:三路各自成败是常态(例如 logs 缓冲未注入回 501,
* 而 metrics / trace 正常),共用一个 `failure` 会让「日志挂了」显示成「trace 不可用」——
* 那是把一次成功说成失败。
*/
val metricsFailure: AdminFailure? = null,
val traceFailure: AdminFailure? = null,
val logsFailure: AdminFailure? = null,
)
/**
* 管理只读监控的四页取数(api-client §2)。
*
* [load] 先按 [AdminRole] 短路:角色不够的页**不发请求**,直接给
* [AdminFailure.Forbidden],UI 因此能显示「无权限」而不是等一个 403 回来。
*
* ## 可观测实时推送(契约 v0.55 §3.12)
* 概览页在已连接时把 `observability_metrics` / `observability_trace` / `observability_log`
* 三帧合并进 [overview]:**REST 是真源**(进页面 / 手动刷新 / 断线重连各拉一次快照,
* 日志的历史基线也由它给),帧只是叠在上面的增量。三帧都只发给 `owner` / `admin`(fail-closed),
* 客户端按 [canReceiveObservabilityFrames] 自行降级:`viewer` 既不开连接也不合帧,
* 但它**读得到**日志历史([canReadObservabilityLogHistory]),所以概览页上它照样看得到
* 「最近日志」,只是那份列表不增长——这不是错误也不是空态。
*
* ## WS 连接:全 App 共享一条,本类**不持有**它
* `observability_*` 三帧在 WS 上,而 WS 是**进程内共享**的那一条(`AppContainer.chatSocket`;
* 为什么可以共享见那里的 KDoc——服务端广播 ⇒ 各 VM 各收全部帧 ⇒ 过滤逻辑一个字都不用改)。
* 本类只订阅它,**不再自己开一条**。
*
* **本类任何路径都不 connect / close 它**:
* - 该不该连着由**登录态**决定,主人是 `SessionsViewModel`(它一进登录态就存在,且活在 Activity 级
* `ViewModelStore` 里);本类只在**某个屏幕**出现时才创建,让它插一手就会把「关掉管理页」
* 变成「关掉全 App 的连接」;
* - `viewer` 也不会因此断连:这三帧服务端本就 fail-closed,本类又用 [canReceiveObservabilityFrames]
* 逐帧再挡一次(见 [onEvent]),所以「连接在不在」与「本类收不收到」无关。
*
* 本类只关心这三个帧类型,收不到也不会让功能失效——REST 会兜住。
*/
class AdminViewModel(private val container: AppContainer) : ViewModel() {
private val _overview = MutableStateFlow(AdminUiState<AdminOverviewData>())
val overview: StateFlow<AdminUiState<AdminOverviewData>> = _overview.asStateFlow()
private val _personas = MutableStateFlow(AdminUiState<List<PersonaSummaryDto>>())
val personas: StateFlow<AdminUiState<List<PersonaSummaryDto>>> = _personas.asStateFlow()
private val _memories = MutableStateFlow(AdminUiState<List<MemorySummaryDto>>())
val memories: StateFlow<AdminUiState<List<MemorySummaryDto>>> = _memories.asStateFlow()
private val _backthink = MutableStateFlow(AdminUiState<List<AdminBackthinkItemDto>>())
val backthink: StateFlow<AdminUiState<List<AdminBackthinkItemDto>>> = _backthink.asStateFlow()
/**
* 全 App 共享的那条推送连接:本类只订阅它,**不连接也不关闭**(理由见类注释——
* 它的主人是「登录态」那条路径,不是某个屏幕)。
*/
private val socket: ChatSocket = container.chatSocket
init {
viewModelScope.launch {
container.authManager.state.collect { auth ->
when (auth) {
SessionState.Unknown -> Unit
// 登出:只丢掉日志基线(`null` = 没有基线 ⇒ 区块立刻整段收起),不留下一个换账号后
// 还挂着上个账号数据的列表。**不碰连接**——那是登录态那条路径的事(见类注释),
// 这时主会话的流式与抽屉的未读都还挂着它。
SessionState.LoggedOut -> {
_overview.update { state ->
state.copy(data = state.data?.copy(logs = null))
}
}
// 登录:**什么都不做**。连接该不该在由登录态决定(主人是 `SessionsViewModel`);
// `viewer` 也不再因此断开——它收不到这三帧是服务端 fail-closed,本类另有
// [canReceiveObservabilityFrames] 逐帧再挡一次(见 [onEvent]),断开连接并不能
// 多挡掉什么,却会掐掉别的屏幕。
is SessionState.LoggedIn -> Unit
}
}
}
viewModelScope.launch { socket.events.collect(::onEvent) }
viewModelScope.launch { reloadOnConnect() }
}
/**
* **刻意不关连接**:`socket` 是**全 App 共享**的那一条,而本 VM 只随「离开管理页 / Activity 结束」
* 被销毁——在 `onCleared` 里 close 会把主会话的流式与抽屉的未读一起掐断。
* 断开只由**登录态**表达(见类注释)。
*/
override fun onCleared() {
super.onCleared()
}
/** 进入页面时调用;每次进入都重新取数(监控数据要新鲜)。 */
fun load(page: AdminPage, role: AdminRole) {
if (!canAccess(role, page)) {
deny(page)
return
}
viewModelScope.launch {
when (page) {
AdminPage.Overview -> loadOverview(role)
AdminPage.Personas -> loadList(_personas) { container.adminRepository.personas() }
AdminPage.Memories -> loadList(_memories) { container.adminRepository.memories() }
AdminPage.Backthink -> loadList(_backthink) { container.adminRepository.backthink() }
}
}
}
/**
* 概览页三路请求并发;任一 403 即整页无权限(聚合进 [AdminUiState.failure]),
* 各路自己的失败原因另外记进 [AdminOverviewData],让每个区块说自己的话。
*
* 三路都**以本次结果为准**:帧送的是增量,而 REST 是全量(metrics 是整份快照、
* trace 含所有 span、日志是那份环形缓冲的尾部)。请求在飞期间到达的帧会被这次快照盖掉——
* 那几条要么本来就在快照里,要么是快照取数之后新产生的、下一拍帧或下次刷新会再带回,
* 不值得为这点竞态引入版本号。
*
* 日志这条在 W10 是例外(帧是唯一来源,重拉会把累积的列表永久抹掉);接了 REST 之后
* 例外消失:**基线随时可以重建**,所以这里与另两路同口径,一次失败不留下「看着像当前、
* 其实停在过去」的列表(见 [AdminOverviewData.logs])。
*/
private suspend fun loadOverview(role: AdminRole) {
_overview.update { it.copy(loading = true, failure = null) }
val repository = container.adminRepository
val (metrics, trace, logs) = coroutineScope {
val metricsCall = async { repository.metrics() }
val traceCall = async { repository.trace() }
// 日志历史按其角色的档位决定读不读(viewer 也读得到);不够档的页在 load() 就被拦下了。
val logsCall = if (canReadObservabilityLogHistory(role)) async { repository.logs() } else null
Triple(metricsCall.await(), traceCall.await(), logsCall?.await())
}
val failures = listOfNotNull(metrics, trace, logs)
.mapNotNull { it.exceptionOrNull()?.toAdminFailure() }
_overview.value = AdminUiState(
data = AdminOverviewData(
metrics = metrics.getOrNull(),
metricsFailure = metrics.exceptionOrNull()?.toAdminFailure(),
trace = trace.getOrNull().orEmpty(),
traceFailure = trace.exceptionOrNull()?.toAdminFailure(),
// 归一化到与帧合并同一套不变式(去重 + 时间倒序 + 窗口 200),
// 于是「REST 基线」与「基线 + 帧」在 UI 眼里完全同形。
logs = logs?.getOrNull()?.let(::normalizeLogEntries),
logsFailure = logs?.exceptionOrNull()?.toAdminFailure(),
),
failure = failures.firstOrNull { it is AdminFailure.Forbidden } ?: failures.firstOrNull(),
)
}
private suspend fun <T> loadList(
target: MutableStateFlow<AdminUiState<List<T>>>,
fetch: suspend () -> Result<List<T>>,
) {
target.value = target.value.copy(loading = true, failure = null)
fetch()
.onSuccess { list -> target.value = AdminUiState(data = list) }
.onFailure { e -> target.value = AdminUiState(failure = e.toAdminFailure()) }
}
private fun deny(page: AdminPage) {
when (page) {
AdminPage.Overview -> _overview.value = AdminUiState(failure = AdminFailure.Forbidden)
AdminPage.Personas -> _personas.value = AdminUiState(failure = AdminFailure.Forbidden)
AdminPage.Memories -> _memories.value = AdminUiState(failure = AdminFailure.Forbidden)
AdminPage.Backthink -> _backthink.value = AdminUiState(failure = AdminFailure.Forbidden)
}
}
/**
* 下行帧收敛:只认可观测推送三帧,且**逐帧再判一次角色门**——帧是自己收的,
* 但「连接当时角色够档」不等于「此刻够档」(重登换角色只换了同一实例的连接)。
* 不够档就整帧丢弃(fail-closed),而不是先渲染再解释。
*/
private fun onEvent(event: WsEvent) {
if (!canReceiveObservabilityFrames(currentRole())) return
when (event) {
is WsEvent.ObservabilityMetrics -> onMetricsFrame(event)
is WsEvent.ObservabilityTrace -> onTraceFrame(event)
is WsEvent.ObservabilityLog -> onLogFrame(event)
else -> Unit
}
}
private fun currentRole(): AdminRole =
AdminRole.of((container.authManager.state.value as? SessionState.LoggedIn)?.role)
/**
* 指标帧 = **整份替换**(载荷就是与 REST 同形的扁平快照本体,服务端只在它变化时才发)。
*
* 两道闸:帧里没有载荷对象([WsEvent.ObservabilityMetrics.metrics] 为 null)时跳过,
* 免得把界面上的真实指标写成全零;还没有 REST 基线([AdminUiState.data] 为 null,
* 即首屏那次引导读取尚未回来)时也不落地——增量只能叠在基线上,凭空造一份
* 「只有指标、trace 为空」的状态会把「还没拉到」渲染成「没有 trace」。
*/
private fun onMetricsFrame(event: WsEvent.ObservabilityMetrics) {
val metrics = event.metrics ?: return
_overview.update { state ->
val data = state.data ?: return@update state
state.copy(data = data.copy(metrics = metrics))
}
}
/** trace 帧是**增量**:与已有快照按 `id` 去重合并(见 [mergeTraceSpans])。 */
private fun onTraceFrame(event: WsEvent.ObservabilityTrace) {
if (event.spans.isEmpty()) return
_overview.update { state ->
val data = state.data ?: return@update state
state.copy(data = data.copy(trace = mergeTraceSpans(data.trace, event.spans)))
}
}
/**
* 日志帧是**增量**:叠在 REST 基线上(去重 + 按时间倒序 + 窗口 200,见 [mergeLogEntries])。
*
* 没有基线([AdminOverviewData.logs] 为 null)时**整帧丢弃**:增量只能叠在基线上,
* 凭空造一份「只有帧、没有历史」的列表,会把「还没拉到」渲染成「就这几条」。
* 丢掉的那几条不会永久消失——服务端的环形缓冲留着 2000 条,下次成功的引导读取会带回最新
* 200 条(只要这期间没产生超过一个窗口的新日志,它们就在里面)。
*/
private fun onLogFrame(event: WsEvent.ObservabilityLog) {
if (event.entries.isEmpty()) return
_overview.update { state ->
val data = state.data ?: return@update state
val baseline = data.logs ?: return@update state
state.copy(data = data.copy(logs = mergeLogEntries(baseline, event.entries)))
}
}
/**
* 每次「连上」(含首次连接与断线重连)都补一次引导读取(REST 兜底)。
*
* 为什么重连后必须重读:推送是**增量**,总线也不回放——断线窗口里的帧是永久丢了,
* 那份增量流不再可信。而且服务端只为「有在线 owner/admin 连接」的凭据保留订阅
* (`observability_push.go` 的 `prune`),订阅可能在连接还没上线的窗口里被剪掉、
* 推送循环随之退出;重读 REST 同时把订阅重新建立起来(契约 §3.12:读取即订阅握手)。
* 首次连接同样要补,正是为了兜住「页面先读、连接后上」这个顺序。
*
* 只在**已经拿到过引导数据**之后触发([AdminUiState.data] 非空):还没读过的那一次
* 由页面自己的 `load` 负责,不重复请求。`StateFlow` 只在值变化时发射,所以这里
* 一次连接只会补一次。
*/
private suspend fun reloadOnConnect() {
socket.state.collect { state ->
if (state == WsState.Connected) reloadOverview()
}
}
private fun reloadOverview() {
val role = currentRole()
if (!canReceiveObservabilityFrames(role)) return
if (_overview.value.data == null) return
viewModelScope.launch { loadOverview(role) }
}
}
@@ -0,0 +1,154 @@
package top.yeij.cyrene.ui.chat
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.delay
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.ui.admin.AdminHomeScreen
import top.yeij.cyrene.ui.common.MiuixDrawer
import top.yeij.cyrene.ui.sessions.SessionDrawerContent
import top.yeij.cyrene.ui.sessions.SessionsViewModel
import top.yeij.cyrene.ui.settings.AboutScreen
import top.yeij.cyrene.ui.settings.SettingsScreen
/** 「这个会话在服务端已经没了」那句告知挂多久(毫秒):它描述一次刚发生的动作,不是持续状态。 */
private const val SESSION_GONE_NOTICE_MILLIS = 8_000L
/**
* 会话在别处被删掉时那句如实告知。
*
* 它必须由**这一层**持有,不能挂在 `ChatViewModel` 上:`onSessionGone` 一被调用,
* `activeSessionId` 就会被收敛回主会话,ChatScreen 的 VM key(含会话 id)随之变化 ——
* 变的是 key,于是**承载横幅的那段组合被销毁重建**;那个 VM 实例本身住在 Activity 级的
* ViewModelStore 里,**仍然活着、继续收帧**(连登出都不回收它,见 `chatViewModelKey` 的 KDoc)。
* 横幅挂在 VM 状态上的话活不过这一帧、一个字都来不及显示(真机实测过)。
* 这一层的 `remember` 活得比它久。
*/
private const val SESSION_GONE_MESSAGE = "这个会话在服务端已经不存在了(可能已在别处删除),已切回主会话"
/**
* 聊天页 + 侧边栏(会话切换)+ 设置页 + 管理员监控。
* 返回键逐层回退:管理/设置 → 聊天;抽屉打开时先关抽屉;都关着才交给系统(回桌面)。
*/
@Composable
fun ChatHomeScreen(
container: AppContainer,
onLogout: () -> Unit = {},
) {
val sessionsViewModel: SessionsViewModel = viewModel(
factory = viewModelFactory { initializer { SessionsViewModel(container) } },
)
val state by sessionsViewModel.state.collectAsState()
var drawerOpen by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
var showAdmin by remember { mutableStateOf(false) }
var showAbout by remember { mutableStateOf(false) }
// 当前会话在别处被删掉时那句告知(见 [SESSION_GONE_MESSAGE])。放在这一层是因为承载它的
// 那段组合(ChatScreen)会在切回主会话的瞬间被销毁重建,而这里的 remember 活得比它久。
var sessionNotice by remember { mutableStateOf<String?>(null) }
LaunchedEffect(sessionNotice) {
if (sessionNotice != null) {
delay(SESSION_GONE_NOTICE_MILLIS)
sessionNotice = null
}
}
BackHandler(enabled = showAdmin) { showAdmin = false }
// 「关于」嵌在设置页里,所以两个条件必须**互斥**:在关于页时设置页的处理器关掉,
// 返回才只退一层(关于 → 设置),不会一步跳回聊天。
BackHandler(enabled = showAbout) { showAbout = false }
BackHandler(enabled = showSettings && !showAbout) { showSettings = false }
BackHandler(enabled = drawerOpen) { drawerOpen = false }
when {
showAdmin -> AdminHomeScreen(
container = container,
onBack = { showAdmin = false },
)
showAbout -> AboutScreen(onBack = { showAbout = false })
showSettings -> SettingsScreen(
container = container,
onBack = { showSettings = false },
onLogout = onLogout,
onOpenAbout = { showAbout = true },
)
else -> MiuixDrawer(
open = drawerOpen,
// 抽屉只会被外部入口打开(MiuixDrawer 的 onOpenChange 只回报关闭),
// 所以这里不用管「打开」分支。
onOpenChange = { open -> drawerOpen = open },
content = {
ChatScreen(
container = container,
sessionId = state.activeSessionId,
onOpenDrawer = {
drawerOpen = true
// 抽屉打开时静默重拉:列表只在 init / 新建 / 删除时取过,而服务端的
// 自动标题与置顶顺序**没有任何 WS 帧**会推过来,只能靠这里对齐。
// 静默是为了不闪「同步中…」——屏幕上本来就有内容。
sessionsViewModel.refresh(silent = true)
},
// 当前会话在别处被删了(`404 session_not_found`)⇒ 先立起那句如实告知,再重拉列表:
// `refresh` 里面会把「指向已删会话」的 activeSessionId 收敛回主会话
// (`resolveActiveSession`),抽屉里那条也跟着消失。
//
// 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key,
// 那段组合连同挂在 VM 状态上的东西一起被丢弃重建,晚一步就什么都看不到了。
//
// ⚠️ 但**不能只靠 `refresh` 把用户送回去**:它只在**拉成功**时才做回落
// (`resolveActiveSession`,且列表为空时刻意不回落;`.onFailure` 那条更是什么都不动)。
// 于是「网络正不稳 / 服务端正好抖一下」时,屏幕上那句「已切回主会话」就成了一句
// 做不到的话——用户被留在一个已经没了的会话上,没有任何出路。这正是这个告知
// 想要修掉的那一幕,所以这里**先在本端收敛**(`select` 是用户点抽屉里那一行走的
// **同一条**路,不另造机制),`refresh` 退回它本来的职责:与服务端对齐、把抽屉
// 里那条去掉。副作用与用户手点主会话进入时逐字相同(就地清未读 + 推进游标)。
onSessionGone = {
sessionNotice = SESSION_GONE_MESSAGE
sessionsViewModel.select(SessionSummaryDto.MAIN_SESSION_ID)
sessionsViewModel.refresh(silent = true)
},
sessionNotice = sessionNotice,
)
},
drawerContent = {
SessionDrawerContent(
state = state,
onSelect = { sessionId ->
sessionsViewModel.select(sessionId)
drawerOpen = false
},
onCreate = sessionsViewModel::createSession,
onDelete = { sessionsViewModel.deleteSession(it.id) },
onTogglePin = { sessionsViewModel.setPinned(it.id, !it.pinned) },
onRename = { sessionId, title -> sessionsViewModel.renameSession(sessionId, title) },
onOpenIot = { /* P2:IoT 页面 */ },
onOpenAdmin = {
drawerOpen = false
showAdmin = true
},
onOpenSettings = {
drawerOpen = false
showSettings = true
},
onDismissError = sessionsViewModel::dismissError,
)
},
)
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,240 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import top.yeij.cyrene.data.repository.CachedDocument
import top.yeij.cyrene.domain.chat.DocumentPreview
import top.yeij.cyrene.domain.chat.DocumentPreviewPlan
import top.yeij.cyrene.domain.chat.DocumentTarget
import top.yeij.cyrene.domain.chat.PreviewSlice
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 预览弹层的三种状态。**加载中与失败必须长得不一样**:失败要能重试,还绝不能被当成「空文件」。 */
sealed interface DocumentPreviewState {
data object Loading : DocumentPreviewState
/** 取字节失败;[message] 是 [top.yeij.cyrene.domain.chat.documentLoadFailureMessage] 的产物。 */
data class Failed(val message: String) : DocumentPreviewState
/** 已取到字节并切好段。[document] 同时是「打开 / 保存」要用的那一份。 */
data class Ready(val document: CachedDocument, val slice: PreviewSlice) : DocumentPreviewState
}
/**
* 弹层下方的一行告知。
*
* [error] 单独一位是因为「已保存」与「没有应用能打开」是**同一行**上的两种性质:都用错误色
* 会让一次成功的保存看着像出事,都用中性色又会让失败被当成提示。
*/
data class DocumentPreviewNotice(val message: String, val error: Boolean = false)
/**
* 附件预览弹层。
*
* 用 Compose 的 `Dialog` + Miuix 内容,与抽屉里那几个弹层同一套做法(抽屉只有 300dp 宽,
* 而正文要一块能盖住全屏、可滚动的地方)。
*
* ## 三条出口
* - **关闭**:只关弹层,什么都不写;
* - **用其它应用打开**:把缓存里的那一份经 `FileProvider` 交给系统([onOpen]);
* - **保存到下载目录**:走 `ACTION_CREATE_DOCUMENT` 让用户挑落点([onSave])。
*
* 后两条在字节还没到位([DocumentPreviewState.Loading] / [DocumentPreviewState.Failed])时
* **不建可点**:点了也只能失败,不如让它们明摆着还没准备好。
*
* ## 正文区由 [plan] 说了算
* 非文本([DocumentPreviewPlan.NotText],如 `application/pdf` 的字节)**不渲染正文**——本端
* 只会把字节当 UTF-8 文本显示,一份 PDF 那样子看起来就是一屏乱码,像文件坏了。此时正文区
* 只留 [DocumentPreviewPlan.NotText.message] 那句人话:**这既不是加载失败**(字节是取回来的,
* 两条出口照旧可用),也不是「文件是空的」。
*
* 那句话有两种来源,本弹层**一视同仁地原样渲染**:本端按类型写的那句,以及服务端说「读不到」
* 时下发的**原因原话**(契约 v0.63 的 `documents[].reason`,判定见
* [top.yeij.cyrene.domain.chat.DocumentPreviewGate.plan])——本层不做任何改写或截断,
* 它拿到的 `message` 就是要显示的那句话。
*
* ## 正文为什么是 `LazyColumn` + 分块
* 上限是 5000 行 / 256 KB,整段塞进一个 `Text` 会让打开弹层的那一刻卡在排版上。切成
* [top.yeij.cyrene.domain.chat.DocumentPreviewLimits.LINES_PER_CHUNK] 行一块交给 `LazyColumn`,
* 就只测量当前可见的那几块。每块各自包一层 `SelectionContainer`,与气泡同一约定:
* 「全选」只作用于当前那一块,不跨块。
*
* ## 截断必须自己说出来
* [PreviewSlice.notice] 非空时在正文上方明写「只显示前 N」并指路另外两条出口——**绝不静默
* 截断**:用户看到一段像是完整的正文却其实是前 256 KB,是最容易被误读的一种失败。
*
* @param target 被点开的那一枚芯片(来源判定见 [DocumentTarget.of],弹层不自己判断)。
* @param plan 正文区能不能按文本渲染([DocumentTarget.previewPlan] 的产物,弹层不自己判断)。
* @param notice 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败);
* null = 没什么要说的。
*/
@Composable
fun DocumentPreviewDialog(
target: DocumentTarget,
state: DocumentPreviewState,
plan: DocumentPreviewPlan,
notice: DocumentPreviewNotice?,
onRetry: () -> Unit,
onOpen: () -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit,
) {
val enabled = state is DocumentPreviewState.Ready
Dialog(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp))
.padding(20.dp),
) {
Text("附件预览", style = MiuixTheme.textStyles.title3)
Spacer(Modifier.height(8.dp))
// 文件名(服务端存储名或待发附件的显示名),长名字中略——扩展名是这里唯一有语义的部分。
Text(
text = target.chips.name,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
Spacer(Modifier.height(6.dp))
// 类型 · 体积 · 字数 · 状态:**复用气泡那枚芯片**,不另写一套标签。
DocumentChipRow(chips = listOf(target.chips))
Spacer(Modifier.height(12.dp))
when (state) {
DocumentPreviewState.Loading -> PreviewHint("正在取回内容…")
is DocumentPreviewState.Failed -> {
// 失败**绝不渲染成空文件**:一句话说清 + 一个重试出口。
PreviewHint(state.message, error = true)
Spacer(Modifier.height(8.dp))
TextButton("重试", onClick = onRetry)
}
is DocumentPreviewState.Ready -> when (plan) {
DocumentPreviewPlan.Text -> {
state.slice.notice?.let { text ->
Text(
text = text,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(bottom = 8.dp),
)
}
PreviewBody(state.slice)
}
// 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用),
// 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。
is DocumentPreviewPlan.NotText -> PreviewHint(plan.message)
}
}
notice?.let { line ->
Spacer(Modifier.height(10.dp))
Text(
text = line.message,
style = MiuixTheme.textStyles.footnote2,
color = if (line.error) {
MiuixTheme.colorScheme.error
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
)
}
Spacer(Modifier.height(16.dp))
PreferenceCard {
BasicComponent(
title = "用其它应用打开",
summary = "交给系统里能读这个类型的应用",
onClick = if (enabled) onOpen else null,
)
}
Spacer(Modifier.height(8.dp))
PreferenceCard {
BasicComponent(
title = "保存到下载目录",
summary = "自己挑一个落点,内容与这里看到的是同一份",
onClick = if (enabled) onSave else null,
)
}
Spacer(Modifier.height(10.dp))
// 契约缺口如实写在界面上:服务端按内容寻址落盘,只回存储名,原始文件名不可得。
Text(
text = "服务端只保留了内容寻址的存储名,保存下来的文件名就是它,原始文件名不会返回给客户端。",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton("关闭", onClick = onDismiss)
}
}
}
}
/** 正文区:按行分块、逐块可选中,块数上限由 [DocumentPreviewLimits] 保证。 */
@Composable
private fun PreviewBody(slice: PreviewSlice) {
val chunks = remember(slice.text) { DocumentPreview.chunks(slice.text) }
if (chunks.isEmpty()) {
PreviewHint("(这份文件是空的)")
return
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = PREVIEW_BODY_MAX_HEIGHT)
.background(MiuixTheme.colorScheme.surfaceContainer, RoundedCornerShape(12.dp))
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
// 键必须是下标:内容相同的两块(整篇都是同一行的那种文件)用内容当键会撞键。
itemsIndexed(items = chunks, key = { index, _ -> index }) { _, chunk ->
SelectionContainer {
Text(
text = chunk,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainer,
)
}
}
}
}
@Composable
private fun PreviewHint(text: String, error: Boolean = false) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
color = if (error) MiuixTheme.colorScheme.error else MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
/** 正文区最大高度:再高就把三条出口挤出窄屏的可见区。 */
private val PREVIEW_BODY_MAX_HEIGHT = 320.dp
@@ -0,0 +1,465 @@
package top.yeij.cyrene.ui.chat
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.FileProvider
import coil3.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yeij.cyrene.data.repository.CachedDocument
import top.yeij.cyrene.data.repository.ImageCacheStore
import top.yeij.cyrene.domain.chat.DocumentOpen
import top.yeij.cyrene.domain.chat.DocumentOpenPlan
import top.yeij.cyrene.domain.chat.ImageDataUri
import top.yeij.cyrene.domain.chat.ImageFormats
import top.yeij.cyrene.domain.chat.ImageNames
import top.yeij.cyrene.domain.chat.ImageOpenTypes
import top.yeij.cyrene.domain.chat.ImageSize
import top.yeij.cyrene.domain.chat.ImageSource
import top.yeij.cyrene.domain.chat.ImageZoom
import top.yeij.cyrene.domain.chat.ZoomPan
import top.yeij.cyrene.domain.chat.imageLoadFailureMessage
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.theme.MiuixTheme
import java.io.File
/**
* 全屏查看的三种状态。**加载中 / 失败 / 就绪必须长得不一样**:
* 取字节失败要能重试,而且**绝不能被画成一张空白图**——「按了没反应」和「图是白的」是两种
* 都会被误读成「这张图就是这样」的失败。
*/
sealed interface ImagePreviewState {
data object Loading : ImagePreviewState
/** 取字节失败;[message] 是 [imageLoadFailureMessage] 的产物。 */
data class Failed(val message: String) : ImagePreviewState
/**
* 字节已落进缓存。[cached] 同时是渲染、交给别的 App、写进下载目录的那**同一份**;
* [openType] 是「用其它应用打开」要声明的 MIME(见 [ImageOpenTypes.openType])。
*/
data class Ready(val cached: CachedDocument, val openType: String) : ImagePreviewState
}
/**
* 图片全屏查看弹层。
*
* 与附件预览([DocumentPreviewDialog])是同一套做法的另一份:`Dialog` + Miuix 内容、
* 缓存与落盘复用 [DocumentCache],只是这一份要**铺满全屏**(`usePlatformDefaultWidth = false`)
* 才谈得上 fit-center 与放大。
*
* ## 手势
* - **双指捏合**缩放([ImageZoom.zoomAround],以双指中点为不动点)、**单指拖动**平移;
* - **双击**在 1x ↔ 2x 之间切换(以双击点为不动点;收回 fit 时位移自动归零);
* - 位移与缩放全部经 [ImageZoom] 钳制:fit 态下拖不动(内容没超出容器),放大后拖不到
* 露出空白,缩放范围恒在 1x…3x 之间。
*
* ## 两个出口(与 W11 完全相同的那两条)
* - **用其它应用打开**:把缓存里那一份经 **FileProvider** 交出去,类型用**图片自己的 mime**
* (嗅探 > 平台声明 > 扩展名 > 图片通配);系统里没有 App 能开时**如实说一句**,
* 并指路「保存到下载目录」——绝不静默失败。
* - **保存到下载目录**:走 `ACTION_CREATE_DOCUMENT`,类型**恒为通配**([ImageOpenTypes.SAVE_MIME])
* ——否则 SAF 会按具体 mime 给文件名补扩展名。
*
* ## 退出
* 右上角关闭按钮 + 系统返回键([BackHandler];`Dialog` 自己也会把返回交给 `onDismissRequest`,
* 两者通向同一个「只关弹层、什么都不写」)。缩放状态活在弹层的组合里,**关闭即复位**,
* 再打开永远从 fit 开始。
*
* @param source 被点开的那张图(来源判定见 [ImageSource.of],弹层不自己判断)。
* @param store 取字节 + 落盘的缓存层(与附件共用 `cacheDir/documents/` 那一层)。
*/
@Composable
fun ImagePreviewDialog(
source: ImageSource,
store: ImageCacheStore,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// 重试计数:取字节失败与解码失败共用它,一次「重试」把两条路都重来一遍。
var retry by remember(source) { mutableStateOf(0) }
// 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败)。
// 复用附件弹层那个类型:它就是「弹层下方的一行告知」,与文档无关。
var notice by remember(source) { mutableStateOf<DocumentPreviewNotice?>(null) }
// Coil 解不开这份字节(拿到了、但不是它能认的图):与「取字节失败」分开说——两件事
// 要用户做的事不一样,前者可以重试,后者换了网络也没用。
var undecodable by remember(source, retry) { mutableStateOf(false) }
val state by produceState<ImagePreviewState>(
initialValue = ImagePreviewState.Loading,
key1 = source,
key2 = retry,
) {
value = ImagePreviewState.Loading
value = store.load(source).fold(
onSuccess = { cached ->
ImagePreviewState.Ready(cached, imageOpenType(context, source, cached))
},
onFailure = { ImagePreviewState.Failed(imageLoadFailureMessage(it)) },
)
}
// 保存到下载目录:与附件那条路同一取舍——MIME 恒 */*,落盘的是缓存里那一份**原始字节**
// (不是重新编码过的位图,JPEG 存下来还是 JPEG)。
val createDocument = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument(ImageOpenTypes.SAVE_MIME),
) { destination ->
val document = (state as? ImagePreviewState.Ready)?.cached
if (destination == null || document == null) return@rememberLauncherForActivityResult
scope.launch {
val saved = withContext(Dispatchers.IO) {
runCatching {
context.contentResolver.openOutputStream(destination)?.use {
it.write(document.bytes)
} ?: error("no output stream for $destination")
}.isSuccess
}
notice = if (saved) {
DocumentPreviewNotice("已保存")
} else {
DocumentPreviewNotice("保存失败,请换一个位置再试", error = true)
}
}
}
fun openElsewhere() {
val ready = state as? ImagePreviewState.Ready ?: return
val contentUri = runCatching {
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", ready.cached.file)
}.getOrNull()
if (contentUri == null) {
notice = DocumentPreviewNotice("无法把这张图交给其它应用:本地缓存文件不存在", error = true)
return
}
val intent = Intent(Intent.ACTION_VIEW)
.setDataAndType(contentUri, ready.openType)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val hasHandler = context.packageManager
.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null
// 裁决与文案复用附件那条路([DocumentOpen]):语义完全一样——「交给系统里能读这个类型的
// 应用」,没有能处理的就如实说一句并指路保存。
when (val plan = DocumentOpen.plan(ready.openType, hasHandler)) {
DocumentOpenPlan.HandOff -> try {
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
notice = DocumentPreviewNotice(DocumentOpen.noHandlerMessage(ready.openType), error = true)
}
is DocumentOpenPlan.NoHandler -> notice = DocumentPreviewNotice(plan.message, error = true)
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
// 系统返回键:只关弹层,绝不落到聊天页的返回层级上([ChatHomeScreen] 那几层返回不变)。
BackHandler(enabled = true) { onDismiss() }
Box(
modifier = Modifier
.fillMaxSize()
.background(MiuixTheme.colorScheme.surface),
) {
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
when (val current = state) {
ImagePreviewState.Loading -> CenteredHint("正在加载图片…")
is ImagePreviewState.Failed -> FailurePane(
message = current.message,
onRetry = { retry++ },
)
is ImagePreviewState.Ready -> if (undecodable) {
FailurePane(
message = "这张图片解不开(内容可能不是图片)",
onRetry = { retry++ },
)
} else {
ZoomableImage(
file = current.cached.file,
onUndecodable = { undecodable = true },
)
}
}
}
ExitPane(
ready = state as? ImagePreviewState.Ready,
notice = notice,
note = nameNoteFor(source),
onOpen = { openElsewhere() },
onSave = {
val name = (state as? ImagePreviewState.Ready)?.cached?.file?.name
createDocument.launch(name ?: FALLBACK_IMAGE_NAME)
},
)
}
// 右上角关闭:底部那一列出口只讲「拿这张图去做什么」,退出这件事单独放在它的位置。
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp),
) {
Icon(
imageVector = MiuixIcons.Basic.Close,
contentDescription = "关闭",
tint = MiuixTheme.colorScheme.onSurface,
)
}
}
}
}
/**
* 可缩放平移的图片。
*
* 渲染的**是缓存里那一份文件**(不是原始 URL):取字节与落盘已经在 [ImageCacheStore] 里做完,
* 于是「看得到的」与「交给别的 App 的」是同一条路上的同一份字节——不会出现「预览能看、打开
* 却失败」。Coil 只负责解码本地文件(HEIC 这类本端说不清类型的图在这里也能看,解码是系统的事)。
*
* 两个 `pointerInput` 分开挂:一个认双击,一个认捏合与拖动。它们不会互相吃掉——
* 拖动一旦过了 touch slop 就被 `detectTransformGestures` 消费,双击因此不会被拖动误触。
* 键取两个尺寸:测量结果到达时重建手势检测器,避免闭包里留着上一次的尺寸。
*
* @param onUndecodable 字节拿到了但 Coil 解不开(例如服务端被代理换成了 HTML 错误页)。
*/
@Composable
private fun ZoomableImage(file: File, onUndecodable: () -> Unit) {
var containerSize by remember { mutableStateOf(IntSize.Zero) }
var imageSize by remember { mutableStateOf(IntSize.Zero) }
val zoom = remember { mutableStateOf(ZoomPan()) }
val container = ImageSize(containerSize.width.toFloat(), containerSize.height.toFloat())
val image = ImageSize(imageSize.width.toFloat(), imageSize.height.toFloat())
Box(
modifier = Modifier
.fillMaxSize()
.clipToBounds()
.onSizeChanged { containerSize = it }
.pointerInput(imageSize, containerSize) {
detectTransformGestures { centroid, pan, zoomChange, _ ->
val focusX = centroid.x - containerSize.width / 2f
val focusY = centroid.y - containerSize.height / 2f
val zoomed = ImageZoom.zoomAround(
current = zoom.value,
targetScale = zoom.value.scale * zoomChange,
focusX = focusX,
focusY = focusY,
container = container,
image = image,
)
zoom.value = ImageZoom.pan(zoomed, pan.x, pan.y, container, image)
}
}
.pointerInput(imageSize, containerSize) {
detectTapGestures(
onDoubleTap = { position ->
zoom.value = ImageZoom.doubleTap(
current = zoom.value,
focusX = position.x - containerSize.width / 2f,
focusY = position.y - containerSize.height / 2f,
container = container,
image = image,
)
},
)
},
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = file,
contentDescription = "查看的图片",
contentScale = ContentScale.Fit,
onSuccess = { result ->
imageSize = IntSize(result.result.image.width, result.result.image.height)
},
onError = { onUndecodable() },
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = zoom.value.scale
scaleY = zoom.value.scale
translationX = zoom.value.offsetX
translationY = zoom.value.offsetY
},
)
}
}
/**
* 底部那一列出口(与附件弹层同一形态)。
*
* 字节还没到位([ImagePreviewState.Loading] / [ImagePreviewState.Failed])时两条出口**不给点**:
* 点了也只能失败,不如让它们明摆着还没准备好。但**关闭**任何时候都在(右上角那个按钮)。
*/
@Composable
private fun ExitPane(
ready: ImagePreviewState.Ready?,
notice: DocumentPreviewNotice?,
note: String,
onOpen: () -> Unit,
onSave: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp, bottom = 12.dp),
) {
notice?.let { line ->
Text(
text = line.message,
style = MiuixTheme.textStyles.footnote2,
color = if (line.error) {
MiuixTheme.colorScheme.error
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
modifier = Modifier.padding(horizontal = EXIT_TEXT_MARGIN, vertical = 6.dp),
)
}
PreferenceCard {
BasicComponent(
title = "用其它应用打开",
summary = ready?.let { "交给系统里能看 ${it.openType} 的应用" } ?: "图片还在加载",
onClick = if (ready == null) null else onOpen,
)
}
Spacer(Modifier.height(8.dp))
PreferenceCard {
BasicComponent(
title = "保存到下载目录",
summary = "自己挑一个落点,内容与这里看到的是同一份",
onClick = if (ready == null) null else onSave,
)
}
Spacer(Modifier.height(8.dp))
Text(
text = note,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(horizontal = EXIT_TEXT_MARGIN, vertical = 2.dp),
)
}
}
@Composable
private fun CenteredHint(text: String) {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
/** 取字节失败 / 解不开:一句话说清 + 一个重试出口(**绝不画成一张空白图**)。 */
@Composable
private fun FailurePane(message: String, onRetry: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = message,
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(Modifier.height(10.dp))
TextButton("重试", onClick = onRetry)
}
}
/**
* 「用其它应用打开」要声明的类型。
*
* 服务端来源在地址里就有名字(那扩展名来自服务端自己那一次嗅探);本地来源要问一次平台
* (`ContentResolver.getType`,一次 IPC,只在打开时做,不做缓存);内联来源的类型写在
* `data:` 头里。三者都只是**候选**,最终由 [ImageFormats.sniff](字节)压过它们——
* 字节是唯一裁决,与文档那条路同一纪律。
*/
private fun imageOpenType(context: Context, source: ImageSource, cached: CachedDocument): String {
val declared = when (source) {
is ImageSource.Local -> runCatching {
context.contentResolver.getType(Uri.parse(source.raw))
}.getOrNull()
is ImageSource.Inline -> ImageDataUri.declaredMime(source.raw)
is ImageSource.Server -> null
}
val name = when (source) {
is ImageSource.Server -> ImageNames.fromServerUrl(source.raw)
// 内联与本地都没有服务端名字,用缓存里那个(内容寻址 + 嗅探所得的扩展名)。
else -> cached.file.name
}
return ImageOpenTypes.openType(ImageFormats.sniff(cached.bytes), declared, name)
}
/** 名字这件事按来源如实说:服务端来源的名字是它的存储名,还没上去的那张是本端自己算的。 */
private fun nameNoteFor(source: ImageSource): String = when (source) {
is ImageSource.Server ->
"服务端只保留了内容寻址的存储名,保存下来的文件名就是它,原始文件名不会返回给客户端。"
else ->
"这张图还没有服务端存储名,保存时用的是本端按内容(sha256)算出来的名字。"
}
/** 建议文件名的兜底:理论上到不了(缓存名恒非空),只是不让一个空名字进 SAF。 */
private const val FALLBACK_IMAGE_NAME = "image.png"
/** 底部告知文字与左侧卡片的对齐余量([PreferenceCard] 自己带 12dp 外边距)。 */
private val EXIT_TEXT_MARGIN = 20.dp
@@ -0,0 +1,70 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.share.ShareRules
/**
* 一次发送切出来的一条消息([imageSendPlan] 的元素)。
*
* [imageUri] 是这条消息要带的那张图(契约里 `chat.image` 是**单图字段**);[documents] 是这条
* 消息要带的非图片附件(`chat.documents` 是数组,但一次只挂在一条消息上)。
*/
data class PlannedMessage(
val text: String,
val imageUri: String?,
val documents: List<DraftDocument>,
)
/** 一次发送的切分结果([imageSendPlan] 的返回值)。 */
data class ImageSendPlan(
/** 要投递的消息,顺序 = 用户选图的顺序。**至少一条**:没有图时它就是承载正文与附件的那条。 */
val messages: List<PlannedMessage>,
/** 超过 [ShareRules.MAX_IMAGES] 而**不会**发出去的张数(0 = 一张没少)。 */
val truncatedImages: Int,
) {
/** 这次真正会发出去的图片(顺序不变)。超限告知里说的「前 N 张」,N 就是它的长度。 */
val imagesToSend: List<String> get() = messages.mapNotNull { it.imageUri }
}
/**
* 「一次发送(正文 + N 张图 + 若干附件)要切成几条消息、每条带什么、超限几张」——**纯函数**,
* 与 Android 无关、可单测(见 `ImageSendPlanTest`)。
*
* ## 为什么 N 张图 = N 条消息
* 契约里 `chat.image` 是**单图字段**:一次上行只能带一张。所以多选进来的 N 张只能落成 N 条消息,
* 顺序 = 用户选图的顺序,而**第一条**带上正文与全部附件——正文是用户打的这一段字、附件是挂在
* 这条消息上的数组,两者本来就只该出现一次。第二条起每条只有一张图、正文为空:空正文的那几条
* 不会凭空替用户多说一个字。
*
* ## 上限只在这里判一次
* 上限 [ShareRules.MAX_IMAGES] 与分享路径用**同一个数**(4):两条路都是「一次最多接住几张图」。
* 多出来的张数由 [ImageSendPlan.truncatedImages] 如实报出来,调用方据此说一句
* (文案 [ShareRules.truncatedImagesNotice])——**绝不静默截断**。
*
* ## N = 0 为什么也回一条
* 没有图时这一次发送仍然是一条普通消息(正文 + 附件),它是同一条规则的空形态。把「有没有东西
* 可发」留给 [hasSendableContent] 判,这里只回答「要切几条、每条带什么」——两个判据各管一件事,
* 谁也不替谁下结论。空串 / 重复项由调用方在入口处清掉(草稿与分享两条入口本来就各自清了)。
*/
fun imageSendPlan(
text: String,
images: List<String>,
documents: List<DraftDocument>,
): ImageSendPlan {
val kept = images.take(ShareRules.MAX_IMAGES)
val messages = if (kept.isEmpty()) {
listOf(PlannedMessage(text = text, imageUri = null, documents = documents))
} else {
kept.mapIndexed { index, uri ->
PlannedMessage(
text = if (index == 0) text else "",
imageUri = uri,
documents = if (index == 0) documents else emptyList(),
)
}
}
return ImageSendPlan(
messages = messages,
truncatedImages = images.size - kept.size,
)
}
@@ -0,0 +1,727 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import top.yeij.cyrene.core.prefs.LocalAppPrefs
import top.yeij.cyrene.data.api.dto.DocumentRefDto
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.repository.canDeleteMessage
import top.yeij.cyrene.data.repository.canEditMessage
import top.yeij.cyrene.domain.chat.DocumentChipModel
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.chat.ImageHostRebase
import top.yeij.cyrene.domain.chat.TurnPhase
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.extended.More
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 流式进行中的光标。 */
private const val CURSOR = "▍"
/** 气泡圆角(正文、图片、附件芯片三处各自的形状里,只有这一处是气泡本体)。 */
private val BubbleShape = RoundedCornerShape(18.dp)
/** 角标行里那枚「⋯」的大小;刻意小于 Miuix 的最小交互尺寸,别把每条消息的角标行撑高。 */
private val MESSAGE_ACTION_ICON_SIZE = 18.dp
/** 「⋯」与来源角标之间的间距。 */
private val MESSAGE_ACTION_GAP = 4.dp
/** 「⋯」的读屏名字,同时也是设备验收时 `uiautomator` 拿来定位它的 `content-desc`。 */
private const val MESSAGE_ACTION_DESCRIPTION = "消息操作"
/** 长按气泡的读屏名字:说清按下去会发生什么(不长按就永远不知道)。 */
private const val MESSAGE_ACTION_LONG_PRESS_LABEL = "打开这条消息的操作菜单"
/**
* 「这条消息什么都没传过来」时写在气泡里的那句话。
*
* 只陈述**本端确定的事实**(内容没有到达本端),**不猜原因**:不说「对方删了」、也不说「这个渠道
* 不支持」——那两句我们都没有证据(真正的根因在服务端,已登记 `devdocs/02` 的「IM 渠道的文件没有挂到
* 消息的 documents 上」那一条)。措辞见 [ContentlessNote]。
*/
private const val CONTENTLESS_MESSAGE_NOTE = "这条消息的内容没有被传过来"
/**
* 这条消息**什么都没有**:正文是空的、没有图、没有附件,也没有思维链。
*
* 判据刻意**窄**,只有 `user` 消息才可能成立:
* - 任何一项非空都不成立——`content` 里哪怕只有一个空格也算有内容(`isBlank` 只吃掉纯空白);
* - `assistant` 一律不成立:她的空消息是另一个话题(可能是流式还没到、也可能是有意不说),
* 顺手改会波及所有助手气泡,不在本次范围内。
*
* 为什么需要它:微信等 IM 渠道进来的文件**没有挂到消息的 `documents` 上**(`documents` 键整个缺席、
* `content` 也是空),而昔涟那边确实读到了内容。客户端拿到手的真是一条什么都没有的消息,此前渲染成
* **空气泡**——看的人只能猜。这一条判据就是「要如实说一句」的那个入口。
*/
fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
message.role == "user" &&
message.content.isBlank() &&
message.image.isNullOrBlank() &&
message.documents.isEmpty() &&
message.thinking.isNullOrBlank()
/**
* 一条落库消息渲染成一个或多个气泡:
* - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分,
* `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方;
* - 其它 mode 原样整条渲染,不解析标签;
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
* - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画);
* - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick]);渲染前先过
* [ImageHostRebase](见 [imageBaseUrl]);
* - `user` 消息**什么都没有**([isContentlessUserMessage])时,气泡位置改为一句如实的说明
* ([ContentlessNote])——空气泡是最糟的一种呈现:用户完全不知道发生了什么。
*
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
* 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。
*
* ## 编辑 / 删除的两个入口(D3)
* - **长按气泡**(`combinedClickable`)打开动作菜单;
* - 气泡下方那一行里的 **「⋯」**(content-desc「消息操作」)打开**同一个**菜单。
*
* 为什么要两个入口:气泡里的正文被 [SelectionContainer] 包着,而**长按正文是「选字」的手势**
* (两者抢同一个长按,谁先消费由运行时决定,不可靠)——只留长按的话,用户最自然会按的那块区域
* 恰恰可能不响。因此多给一个看得见、点得着的入口,长按照旧保留(气泡内边距、附件芯片、
* 图片这些不参与选字的区域按下去就是菜单)。
*
* 哪些动作可用由**产品口径**的纯函数给出([canEditMessage] / [canDeleteMessage]),
* 与 VM 里那道闸是同一份判据;两个都不成立时(未知 role)两个入口都不出现。
*
* @param imageBaseUrl 当前的服务器地址(`AuthManager.currentBaseUrl`)。只用来把历史图片里**绑死在
* 上传那一刻 host 上的同源绝对地址**换到现在的 host(`02/F6①`:`POST /upload` 的 `url` 按请求时的
* `r.Host` 拼、且原样落库 ⇒ 换地址后旧图取不回)。空串 = 不替换([ImageHostRebase] 会原样返回)。
* @param phase 这条消息在**太忙时并行六态**里的哪一态(见 [ChatUiState.phaseOf] / 规范 §1.1)。
* 只有 ③ 排队中与 ④ 回答中改变外观,①②⑤ 一律按今天的样子画(所以老服务端上逐字不变)。
* @param legBadge 「并行路 N」小标(**真的是并行路**且 `leg >= 2` 才有,见 [legBadgeOf])。挂在来源角标那一行里:
* 规范 §1.4 A 要的就是「同一路的两条消息在界面上看得出来是一路」——提问侧挂同一个标,
* 回复侧在 [StreamingBubble] 上挂一次,用户就能把它们配起来。
* @param onEdit 用户选中「编辑」:交给上层打开编辑弹层(本组件不自己发请求)。
* @param onDelete 用户选中「删除」:交给上层打开删除确认(不可恢复,必须二次确认)。
*/
@Composable
fun MessageBubble(
message: HistoryMessageDto,
imageBaseUrl: String = "",
phase: TurnPhase = TurnPhase.Sent,
legBadge: String? = null,
onDocumentClick: (DocumentRefDto) -> Unit = {},
onImageClick: (String) -> Unit = {},
onEdit: (HistoryMessageDto) -> Unit = {},
onDelete: (HistoryMessageDto) -> Unit = {},
) {
val isUser = message.role == "user"
// 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同:
// 表只能给「id → 标签」,说不出哪个 mode 是「按 markup 切气泡的聊天式渲染」,
// 所以这条字面量刻意保留为客户端渲染契约,不表驱动。
val isChat = message.mode.isNullOrBlank() || message.mode == "chat"
val bubbles = remember(message.content, isChat) {
val parsed = if (isChat) parse(message.content).filterNot { it.isEmpty } else emptyList()
parsed.ifEmpty { listOf(plainBubble(message.content)) }
}
val editable = canEditMessage(message)
val deletable = canDeleteMessage(message)
val canAct = editable || deletable
// 每个气泡各自记住菜单开着没有:重建 / 滚动复用不会把上一个气泡的菜单带过来。
var menuOpen by remember(message.id) { mutableStateOf(false) }
val openMenu = if (canAct) { { menuOpen = true } } else null
// 灰态(规范 §1.1 ④「已读、未答」):正文区灰 + 光标指示。判据只认 `Answering` 一档,
// 所以 ③ 排队中**不会**被渲染成灰态——规范 §1.3 特意点名不要合并这两态。
val gray = phase == TurnPhase.Answering
MessageGroup(
isUser = isUser,
footer = bubbleFooter(
sourceLabel = messageSourceLabel(message, LocalEnumCatalog.current),
phase = phase,
legBadge = legBadge,
shared = isSharedMessage(message),
),
onOpenMenu = openMenu,
) {
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
DocumentChipRow(
chips = message.documents.map(DocumentChips::of),
alignEnd = isUser,
onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
)
message.image?.takeIf { it.isNotBlank() }?.let { ImageHostRebase.apply(it, imageBaseUrl) }
?.let { ChatImage(it, onImageClick) }
if (isContentlessUserMessage(message)) {
// 空气泡是最糟的一种呈现:用户完全不知道发生了什么。这里如实说一句(见 [ContentlessNote])。
ContentlessNote(onLongPress = openMenu)
} else {
bubbles.forEachIndexed { index, bubble ->
Bubble(
bubble = bubble,
isUser = isUser,
isLast = index == bubbles.lastIndex,
showCursor = gray,
onLongPress = openMenu,
gray = gray,
)
}
}
}
if (menuOpen) {
MessageActionMenu(
editable = editable,
deletable = deletable,
onEdit = {
menuOpen = false
onEdit(message)
},
onDelete = {
menuOpen = false
onDelete(message)
},
onDismiss = { menuOpen = false },
)
}
}
/**
* 「这条是分享进来的」那枚角标。
*
* 它只是**一枚脚注**,与来源角标同一行、同一套字级与颜色(`footnote2` +
* `onSurfaceVariantSummary`),刻意不做成可点的小药丸——[LegBadge] 那种样式代表「这里有个入口」,
* 而这一枚只是说明,点它无事发生。
*/
const val SHARED_MESSAGE_BADGE = "[用户分享]"
/** 六态 ③ 的角标文案(规范 §1.1:「排队 ≠ 被受理」,语气与「发送中…」并列、不与「已读」混同)。 */
const val QUEUED_BADGE = "排队中"
/**
* 「这条还没连上、留在本端等连接」的小标(W40):接在 [QUEUED_BADGE] 后面,合成
* 「排队中 · 还没连上」。
*
* 为什么要在气泡上(而不只是那一句柔和告知):告知八秒就收走了,而「这条还在等连接」是个会持续
* 几十秒的**状态**——牌子撤掉之后,光看「排队中」分不出这是「她已经收下、只是前面还排着队」
* 还是「连都没连上、一个字节都没上路」。两者对用户意味着不同的事(前者会照常回复,后者要等网络),
* 所以状态本身要一直挂在气泡上。
*
* 语气刻意与 [QUEUED_BADGE] 一致(都是陈述,不是警告):没连上不是错误,内容也一条没丢。
*/
const val AWAITING_CONNECTION_BADGE = "还没连上"
/**
* 乐观气泡下方那一行小标的文案(**纯函数**,可单测)。
*
* [awaitingConnection] 只在 ③ 上成句,而且是**加在既有小标之后**的四个字(见
* [AWAITING_CONNECTION_BADGE]):这条消息在服务端**还没有任何身份**(没入队、没落库),
* 与「她忙、这条已受理排队」必须看得出区别。刻意**不新增第七态**([TurnPhase] 的六个取值是
* W30 定下的口径,见 `ChatUiState.phaseOf`),所以这里只是给同一个 ③ 补一句更准的话。
*
* ① 发送中时它一定是 false(没连上就不可能是「发送中」),这里也按 `when` 的顺序把它挡在
* ③ 之外:真的传进 `true` 也只会说「发送中…」——不为了一个不该发生的组合编一句话。
*/
fun pendingBubbleFooter(phase: TurnPhase, awaitingConnection: Boolean): String = when {
phase != TurnPhase.Queued -> "发送中…"
awaitingConnection -> "$QUEUED_BADGE · $AWAITING_CONNECTION_BADGE"
else -> QUEUED_BADGE
}
/**
* 气泡下方那一行角标的文案(纯函数,可单测)。
*
* 四段内容各自独立,用「·」连起来:**并行路小标**(多路时才在)→ **六态说明**(只有 ③④ 成句)
* → **分享角标**([SHARED_MESSAGE_BADGE],只有分享进来的那条才在)→ **来源角标**(既有规则,
* 见 [messageSourceLabel])。分享角标紧挨来源角标:两者回答的都是「这条从哪来」,
* 挨着放读起来是一件事的两面(「你分享进来的 · 来自 安卓」)。
*
* 为什么 ③④ 要在这里说话:它们**都不是错误、也都不是「已送达」**,而气泡本身在两种态下都长得像
* 一条正常消息。不说一句,用户就分不清「她还没开始理我」与「已经在回答我了」——
* 规范 §1.1 特意把这两态分开,界面上就必须能分开。
*
* [sourceLabel] 由调用方从组合里取(`messageSourceLabel(message, LocalEnumCatalog.current)`):
* 那个 composition local **只能在可组合函数里读**,所以这里收进来当参数,好让本函数保持纯粹、
* 能被单测直接钉死。
*
* [shared] 同样由调用方判([isSharedMessage]——它已经含了「只认用户侧」那道 role 闸)。
* 判据不在这函数里,是为了让它继续只做「把几个既成事实拼成一句话」这一件事。
*/
fun bubbleFooter(
sourceLabel: String?,
phase: TurnPhase,
legBadge: String?,
shared: Boolean = false,
): String? {
val parts = buildList {
legBadge?.let { add(it) }
when (phase) {
TurnPhase.Queued -> add(QUEUED_BADGE)
TurnPhase.Answering -> add("已读,正在回答…")
else -> Unit
}
if (shared) add(SHARED_MESSAGE_BADGE)
sourceLabel?.let { add(it) }
}
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
}
/**
* 乐观发送中的用户消息(还没落库)。
*
* 图片显示的是**本地** `content://` URI(不是上行的那段 base64):预览不需要联网、
* 也不必把几 MB 的字符串塞进 UI 状态。落库后由 `message.image` 接管。
* 附件同理显示**本地显示名**(那时还没有服务端存储名,也还没有任何服务端结论)。
* 这张本地图同样可点进全屏查看([onImageClick]):读的是本地那一份字节。
*
* [phase] 只会是 ① 发送中或 ③ 排队中(乐观气泡还没有服务端 id,绑不到任何一路——
* 见 [ChatUiState.phaseOf])。③ 的角标文案按规范 §1.1 用「排队中」,语气与「发送中…」并列而不
* 与「已读」混同:**排队 ≠ 被受理**(规范 §1.3)。
*
* [awaitingConnection] 是 ③ 里更细的那一档(W40:**本端**因为没连上把这条留住了,一个字节都还
* 没上路),文案由纯函数 [pendingBubbleFooter] 给:接上「还没连上」四个字。判据在 VM 状态里
* ([ChatUiState.isAwaitingConnection]),这里只负责画。
*/
@Composable
fun PendingBubble(
item: PendingMessage,
phase: TurnPhase = TurnPhase.Sending,
awaitingConnection: Boolean = false,
onDocumentClick: (DraftDocument) -> Unit = {},
onImageClick: (String) -> Unit = {},
) {
MessageGroup(
isUser = true,
footer = pendingBubbleFooter(phase, awaitingConnection),
) {
DocumentChipRow(
chips = item.documents.map(DocumentChips::of),
alignEnd = true,
onClick = { index -> item.documents.getOrNull(index)?.let(onDocumentClick) },
)
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it, onImageClick) }
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
}
}
/**
* 附件芯片排:**换行排布**——一枚放不下就落到下一行,N 枚都看得见。
*
* 它曾经是「横向可滚的单行 + `widthIn(max = 320.dp)`」,**实机一看就是坏的**(2026-10-04
* 主人在主会话里发现):服务端存储名是 **32 位十六进制 + 扩展名**(`36a4aba0…5524808.txt`),
* 一枚芯片就能吃掉 ~267dp,于是 320dp 的视口里第二枚只剩一条边露在屏幕外——看的人根本
* 不知道还有第二个附件,也没人看得出那一行可以滚。换行之后 5 枚会占 2~3 行,比「藏起来」值。
*
* [alignEnd] = 用户消息(气泡在右侧):整排贴右;否则一枚短芯片会贴在该排左端、看着像错位。
* [onRemove] 非空即带移除按钮——待发位(输入栏上方)要能反悔,历史气泡里则不给。
* [onClick] 非空即整枚可点(附件预览的入口)。**点击区就是这一枚芯片本身**(`clip` + `background`
* 那个 `Row`),不是里面的两行文字——否则手指落在芯片的空白处就没有反应。
*
* 附件可能是空列表(绝大多数消息都是),此时**不占位、不留空白**。
*/
@Composable
fun DocumentChipRow(
chips: List<DocumentChipModel>,
modifier: Modifier = Modifier,
alignEnd: Boolean = false,
onRemove: ((Int) -> Unit)? = null,
onClick: ((Int) -> Unit)? = null,
) {
if (chips.isEmpty()) return
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(
space = 6.dp,
alignment = if (alignEnd) Alignment.End else Alignment.Start,
),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
chips.forEachIndexed { index, chip ->
DocumentChip(
model = chip,
onRemove = onRemove?.let { remove -> { remove(index) } },
onClick = onClick?.let { click -> { click(index) } },
)
}
}
}
/**
* 一枚附件芯片。
*
* 状态分两处说,**不许互相顶替**(契约 v0.59 §1.2):
* - `status:"unreadable"` = **昔涟读不到这个文件** → 整枚转灰 + 明写「读不到」;
* - `truncated:true` = **注入被预算截断**(正文照样进了上下文)→ 只加一句「注入已截断」。
*
* 未知 `status` 原样显示(开放集合,见 [DocumentChips.statusLabel])。
*
* [onClick] 非空时整枚可点(进附件预览),并带上 `contentDescription`——两行文字各自是可读的,
* 但读屏需要知道「点它会发生什么」。
*/
@Composable
private fun DocumentChip(model: DocumentChipModel, onRemove: (() -> Unit)?, onClick: (() -> Unit)?) {
val clickModifier = if (onClick == null) {
Modifier
} else {
Modifier
.clickable(onClick = onClick)
.semantics { contentDescription = "查看附件 ${model.name}" }
}
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
if (model.unreadable) {
MiuixTheme.colorScheme.surfaceContainer
} else {
MiuixTheme.colorScheme.surfaceContainerHigh
},
)
.then(clickModifier)
.padding(start = 10.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = model.name,
style = MiuixTheme.textStyles.footnote1,
color = if (model.unreadable) {
MiuixTheme.colorScheme.onSurfaceVariantSummary
} else {
MiuixTheme.colorScheme.onSurfaceContainerHigh
},
maxLines = 1,
// 存储名是 32 位十六进制 + 扩展名,长名字必须中略——从尾部截会把扩展名切掉,
// 而扩展名正是这里唯一有语义的部分。
overflow = TextOverflow.MiddleEllipsis,
)
Text(
text = model.detail,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
onRemove?.let { remove ->
IconButton(onClick = remove) {
Icon(
imageVector = MiuixIcons.Basic.Close,
contentDescription = "移除附件 ${model.name}",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
/**
* 正在流式的助手回复:思维链 → 工具卡 → 气泡(末尾带光标)。
*
* ## 并行路小标(规范 §1.4 A / 主人裁决 ①)
* [legBadge] 非空时在最上方挂一枚小标(「并行路 2」),点它跳到**这一路归属的那条提问**
* ([onJumpToQuestion])。这就是规范给的单列形态:回复仍按历史顺序先后成条,靠小标而不是靠分栏
* 表达「这是另起的一路」。**不是并行路**(`turn_begin.kind != "parallel"`)或单路(`leg < 2`)时
* [legBadge] 为 `null`,**一个像素都不多画**——挂标这件事本身就是「这里有真正的多路」的信号
* ([legBadgeOf] 的 KDoc)。
*
* 跳不到(那条提问不在已加载的窗口里、或这一路没绑消息)时**不显示「跳到提问」**:一个按下去
* 什么都不发生的入口比没有入口更糟。[onJumpToQuestion] 传 `null` 即这一档。
*/
@Composable
fun StreamingBubble(
streaming: StreamingState,
legBadge: String? = null,
onJumpToQuestion: (() -> Unit)? = null,
) {
MessageGroup(isUser = false) {
if (legBadge != null) {
LegBadge(text = legBadge, onClick = onJumpToQuestion)
}
if (streaming.thinking.isNotBlank() || streaming.thinkingActive) {
ThinkingCard(text = streaming.thinking, active = streaming.thinkingActive)
}
streaming.tools.forEach { ToolCard(it) }
val bubbles = if (streaming.bubbles.isEmpty() && streaming.active) {
listOf(plainBubble(CURSOR))
} else {
streaming.bubbles
}
bubbles.forEachIndexed { index, bubble ->
Bubble(
bubble = bubble,
isUser = false,
isLast = index == bubbles.lastIndex,
showCursor = streaming.active,
)
}
}
}
@Composable
private fun MessageGroup(
isUser: Boolean,
footer: String? = null,
onOpenMenu: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp),
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
content()
// 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)。
// 「⋯」与它同一行:那枚图标是**看得见的**动作入口(长按同样能开,见 [MessageBubble])。
if (footer != null || onOpenMenu != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MESSAGE_ACTION_GAP),
) {
if (footer != null) {
Text(
text = footer,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
)
}
if (onOpenMenu != null) {
// 用 Icon + clickable 而不是 IconButton:后者的最小交互尺寸会把每一条消息的
// 角标行撑到 48dp,整屏因此凭空变高一大截;这里只要一枚小图标能点中就够了
// (长按气泡那条主路径仍然在)。
Icon(
imageVector = MiuixIcons.More,
contentDescription = MESSAGE_ACTION_DESCRIPTION,
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier
.size(MESSAGE_ACTION_ICON_SIZE)
.clip(CircleShape)
.clickable(onClick = onOpenMenu),
)
}
}
}
}
}
@Composable
private fun Bubble(
bubble: ParsedBubble,
isUser: Boolean,
isLast: Boolean,
showCursor: Boolean = false,
onLongPress: (() -> Unit)? = null,
gray: Boolean = false,
) {
AnnotationLine(bubble.head)
val body = if (showCursor && isLast && bubble.body.isNotEmpty()) bubble.body + CURSOR else bubble.body
if (body.isNotEmpty()) BubbleBody(body, isUser, onLongPress, gray)
AnnotationLine(bubble.tail)
}
/**
* 并行路小标(规范 §1.4 A 的「小标」):一枚圆角小药丸,可点即跳。
*
* 配色刻意用 `primary` 文字 + `surfaceContainerHigh` 底:它既不是正文(不是内容),也不像来源角标
* 那样只是脚注——它是**一个入口**。规范把「点击可跳到对应提问」列为推荐形态,所以能跳时它必须
* 看得出可点([onClick] 非空才带涟漪与手势)。
*/
@Composable
private fun LegBadge(text: String, onClick: (() -> Unit)?) {
val shape = RoundedCornerShape(10.dp)
Row(
modifier = Modifier
.clip(shape)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick))
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (onClick == null) text else "$text · 跳到提问",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.primary,
)
}
}
/** 旁白/动作:灰斜体小字,依附在气泡上/下。 */
@Composable
private fun AnnotationLine(parts: List<ContentPart>) {
if (!LocalAppPrefs.current.showNarration) return
val text = parts.joinToString(" ") { it.text }.trim()
if (text.isEmpty()) return
SelectionContainer(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
fontStyle = FontStyle.Italic,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
/**
* 「这条消息的内容没有被传过来」——**在气泡里**如实说一句(不是顶部横幅:那句话说的是这一条
* 消息,挂在页面上就等于在说整页;而且横幅会自己消失,空气泡不会)。
*
* ## 为什么必须是灰底灰字
* 它是**说明**,不是内容:正经气泡的配色(用户青色 / 助手灰底)会让这句话读起来像「他说的某一句话」,
* 而事实恰恰是「这一条里什么都没有」。灰底 + 文件里既有的灰态小字([MiuixTheme.textStyles.footnote1]
* + `onSurfaceVariantSummary`,与旁白那行同源)在两种角色下都读得清,也一眼看得出与正文不是一回事。
*
* ## 长按仍然开动作菜单
* 这条消息在服务端是真实存在的一行,用户能做的事里最相关的一件就是**删掉它**(内容永远到不了了)。
* 气泡没有正文可以让长按去选字,所以长按在这里没有歧义,直接接 [MessageBubble] 的那一个入口。
*/
@Composable
private fun ContentlessNote(onLongPress: (() -> Unit)? = null) {
Box(
modifier = Modifier
.widthIn(max = 300.dp)
.clip(BubbleShape)
.background(MiuixTheme.colorScheme.surfaceContainer)
.then(
if (onLongPress == null) {
Modifier
} else {
Modifier.combinedClickable(
onLongClickLabel = MESSAGE_ACTION_LONG_PRESS_LABEL,
onLongClick = onLongPress,
onClick = {},
)
},
)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text(
text = CONTENTLESS_MESSAGE_NOTE,
style = MiuixTheme.textStyles.footnote1,
fontStyle = FontStyle.Italic,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
/**
* 气泡本体。
*
* [gray] = **灰态**(规范 §1.1 ④ 的「已读、未答」):底色一律退回中性的
* `surfaceContainer`、文字退回 `onSurfaceVariantSummary`,**两种角色都画成同一种灰**——
* 这一态的语义是「这条已经在被处理,但还没有答案」,它既不是用户说的话(不该是主人的青色),
* 也不是她的回答(不该是助手的正常底色)。正文一个字都不改,只是**褪色**,让人一眼看出
* 「没丢,只是在等」。
*/
@Composable
private fun BubbleBody(
text: String,
isUser: Boolean,
onLongPress: (() -> Unit)? = null,
gray: Boolean = false,
) {
Box(
modifier = Modifier
.widthIn(max = 300.dp)
// clip + background 与原来那句 `background(color, shape)` 画出来完全一样,
// 多出来的这一层是为了让长按的涟漪被圆角裁住(否则它会溢出到方角外)。
.clip(BubbleShape)
.background(
color = when {
gray -> MiuixTheme.colorScheme.surfaceContainer
isUser -> MiuixTheme.colorScheme.primary
else -> MiuixTheme.colorScheme.surfaceContainer
},
)
.then(
if (onLongPress == null) {
Modifier
} else {
Modifier.combinedClickable(
onLongClickLabel = MESSAGE_ACTION_LONG_PRESS_LABEL,
onLongClick = onLongPress,
// 短按不放任何功能:气泡上没有「点一下要做的事」,而涟漪仍然给得出
// 「这块能按」的反馈(长按入口靠它才被发现)。
onClick = {},
)
},
)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
SelectionContainer {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = when {
gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary
isUser -> MiuixTheme.colorScheme.onPrimary
else -> MiuixTheme.colorScheme.onSurfaceContainer
},
)
}
}
}
/**
* 气泡里的图片(缩略)。
*
* 点了就进全屏查看——[onClick] 收到的是**已经替换过 host** 的那一份字符串([ImageHostRebase]:
* `data:` 与本地引用原样、只有服务端同源图片地址会被换到当前 baseUrl),
* 来源判定交给 `ImageSource.of`,这里不猜。点击区就是这张图本身(`clip` 之后可点,
* 涟漪不会溢出圆角);读屏也需要知道点它会发生什么,所以带上 `contentDescription`。
*
* 高度不设限:图片按比例撑开,气泡那一列本来就是纵向排列。
*/
@Composable
private fun ChatImage(model: String, onClick: (String) -> Unit) {
AsyncImage(
model = model,
contentDescription = "查看图片",
modifier = Modifier
.widthIn(max = 240.dp)
.clip(RoundedCornerShape(14.dp))
.clickable { onClick(model) },
)
}
private fun plainBubble(text: String) = ParsedBubble(
body = text,
parts = emptyList(),
head = emptyList(),
tail = emptyList(),
)
@@ -0,0 +1,239 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import top.yeij.cyrene.data.repository.MESSAGE_EDIT_MAX_RUNES
import top.yeij.cyrene.data.repository.runeCount
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 一条消息上的动作菜单(长按气泡 / 点气泡旁那枚「⋯」都会打开它)。
*
* 用 Compose 的 `Dialog` + Miuix 内容,与抽屉里的新建 / 重命名 / 删除确认**同一套做法**:
* 项目里没有 Miuix 的菜单类组件,而「凭空去猜一个没见过的弹层 API」正是 `SessionDrawerContent`
* 当初刻意避开的做法。选项行仍是 `PreferenceCard { BasicComponent(...) }`
* (Miuix 列表项自身没有背景与圆角)。
*
* 两个动作的可见性由调用方按**同一份判据**给出(`canEditMessage` / `canDeleteMessage`):
* 编辑只对 user 消息出现,删除对 user 与 assistant 都出现。两条都不成立时这个弹层不会被打开。
*
* 每一行都带一句 summary 说明会发生什么——尤其是「其它端会跟着变」和「不可恢复」这两件
* 用户看不见的后果。
*/
@Composable
fun MessageActionMenu(
editable: Boolean,
deletable: Boolean,
onEdit: () -> Unit,
onDelete: () -> Unit,
onDismiss: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp))
.padding(DIALOG_PADDING),
) {
Text("这条消息", style = MiuixTheme.textStyles.title3)
Spacer(Modifier.height(12.dp))
if (editable) {
PreferenceCard {
BasicComponent(
title = "编辑",
summary = "改这条消息的内容,其它端会跟着刷新",
onClick = onEdit,
)
}
Spacer(Modifier.height(8.dp))
}
if (deletable) {
PreferenceCard {
BasicComponent(
title = "删除",
summary = "从所有端删除这条消息,不可恢复",
onClick = onDelete,
)
}
}
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton("取消", onClick = onDismiss)
}
}
}
}
/**
* 编辑弹层:一个多行输入框 + 码点计数 + 取消 / 保存。
*
* ## 编辑框里是纯文本,但绝不静默丢东西
* 初值是 `domain/chat/MessageEditText.kt` 的 `editableMessageOf` 算出来的**纯文本**:后端的正文里
* 可能有 Web 渲染链路的标记(`<p>` / `<action>` / `<narration>`),它们在气泡里被 `domain/parse`
* 解析成正文与旁白——让用户对着尖括号改字不是「改这条消息」该有的样子。
* 原文本来就是纯文本时**逐字节不变**(不 trim、不折叠空行),所以「剥标记」这条路径只在真有标记时才生效。
*
* 剥标记是有代价的:保存回去的是这段纯文本,原来的旁白/动作就没了。所以这件事**必须明说**——
* [MessageActionState.Edit.markupReplaced] 为 true 时,输入框下面会多一行小字,把「保存后这条消息
* 只剩你写的这段文字、原来的旁白与动作会被替换掉」讲清楚;为 false(原文没有标记)时**一行都不多显示**。
*
* 反过来,保存时也**不**把标记猜着装回去:任何回填都要先猜「旁白/动作该插回哪一句之间」,
* 猜错就是静默移动她说过的话,且用户看不出发生了什么。提交的就是用户写的这段字本身
* ([ChatViewModel.submitEdit]),不猜是刻意的。
*
* ## 计数为什么按码点
* 服务端上限是 4000 **rune**([MESSAGE_EDIT_MAX_RUNES]),所以这里也用 [runeCount]:
* 按 `length` 数会把 emoji 算成两个,边界上既会误伤也会误放。超限时保存键直接不可点,
* 免得用户点了才知道——服务端那 400 一样会如实说出来,但能提前避免就提前避免。
*
* ## 失败留在弹层里
* [MessageActionState.Edit.error] 非空时显示在按钮上方:这条消息原地重试即可,输入不丢。
* (弹层被关掉之后才失败的,落到页面顶部那行横幅,见 [ChatUiState.applyMessageActionFailure]。)
*
* @param state 弹层的草稿(纯文本)/ 原文带不带标记 / 进行态 / 失败原因(状态在 VM 里,
* 弹层自己不持有提交结果)。
*/
@Composable
fun MessageEditDialog(
state: MessageActionState.Edit,
onDraftChange: (String) -> Unit,
onSubmit: () -> Unit,
onDismiss: () -> Unit,
) {
val used = runeCount(state.draft)
val tooLong = used > MESSAGE_EDIT_MAX_RUNES
// 空白内容服务端必拒(400),本端不发这一趟;在飞时也不许再点一次(没有幂等键)。
val canSave = state.draft.isNotBlank() && !tooLong && !state.inFlight
Dialog(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp))
.padding(DIALOG_PADDING),
) {
Text("编辑这条消息", style = MiuixTheme.textStyles.title3)
Spacer(Modifier.height(12.dp))
TextField(
value = state.draft,
onValueChange = onDraftChange,
modifier = Modifier.fillMaxWidth(),
label = "消息内容",
useLabelAsPlaceholder = true,
maxLines = 8,
)
Spacer(Modifier.height(6.dp))
Text(
text = "$used / $MESSAGE_EDIT_MAX_RUNES",
style = MiuixTheme.textStyles.footnote2,
color = if (tooLong) {
MiuixTheme.colorScheme.error
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
)
if (state.markupReplaced) {
Spacer(Modifier.height(6.dp))
// 明说「保存会替换掉什么」:原文的旁白/动作标记不会再出现在这条消息里。
Text(
text = "原文带旁白/动作标记:保存后这条消息只剩你写的这段文字,原来的旁白与动作会被替换掉。",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
if (state.error != null) {
Spacer(Modifier.height(8.dp))
Text(
text = state.error,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.error,
)
}
Spacer(Modifier.height(20.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton("取消", onClick = onDismiss)
Spacer(Modifier.width(8.dp))
TextButton(
text = if (state.inFlight) "保存中…" else "保存",
onClick = onSubmit,
enabled = canSave,
)
}
}
}
}
/**
* 删除的二次确认弹层。
*
* 与抽屉删会话那一处同形(`ConfirmDialog` 的做法):`Dialog` + Miuix 内容 +
* 标题 / 说明 / 取消 + 确认。这里的说明必须把两件事说清:**影响所有端**、**不可恢复**——
* 删除是唯一一个没有回头路的动作。
*
* [MessageActionState.Delete.error] 非空时显示在按钮上方(例如别的端已经先删掉了,回 404):
* 用户看到的是一句能解释原因的话,而不是一个点了没反应的按钮。
*/
@Composable
fun MessageDeleteDialog(
state: MessageActionState.Delete,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp))
.padding(DIALOG_PADDING),
) {
Text("删除这条消息?", style = MiuixTheme.textStyles.title3)
Spacer(Modifier.height(8.dp))
Text(
text = "它会从所有端移除,且不可恢复。",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
if (state.error != null) {
Spacer(Modifier.height(8.dp))
Text(
text = state.error,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.error,
)
}
Spacer(Modifier.height(20.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton("取消", onClick = onDismiss)
Spacer(Modifier.width(8.dp))
TextButton(
text = if (state.inFlight) "删除中…" else "删除",
onClick = onConfirm,
enabled = !state.inFlight,
)
}
}
}
}
/**
* 三个弹层共用的内边距(与 `NewSessionDialog` / 重命名 / 删除确认一致):四周 20dp。
* 抽成一处是为了「改一次都跟着改」——它们本来就该长得一样。
*/
private val DIALOG_PADDING = 20.dp
Loaded 100 of 264 files, more files were not shown because too many files have changed in this diff. Show more