Commit Graph
100 Commits
Author SHA1 Message Date
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