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 |
|