AskaEth
|
39a63f8d30
|
fix(chat): 并行路角标改以 turn_begin.kind 为准,不再拿 leg 冒充并行
leg 是服务端**会话内的路序号**:beginLegLocked 对每一条新路都自增 legSeq,
普通串行回合照样占号——会话里发第二条普通消息,leg 就是 2。而角标旧判据是
leg >= 2,于是那条串行消息被挂上「并行路 2」,凭空声称存在并不存在的并行。
「是不是并行路」真正的判据是 turn_begin.kind(turn = 普通串行回合,
parallel = 忙时另起的一路);解析层本来就把它读出来了,只是绑定时丢掉了。
- TurnBinding 新增 parallel;withTurnBegin 收下 kind,由 isParallelLegKind
判定:只有逐字等于 parallel 才算,turn、缺席、空串、大小写不同一律不算。
- 合并规则:新建按 kind 定;同 turnId 重复到达时取或(old.parallel || 本帧
是并行),一旦为 true 就永远是 true。与 closed 同一条纪律——单调的既成事实
不许被后到的帧翻回去;也顾及 steer_ack 可能先建一条没有 kind 的占位表、
随后才由这一路自己的 turn_begin 补上并行的现实顺序。
- 降级:kind 不来的老服务端上 parallel 恒为 false,角标一枚都不挂——宁可少挂
一枚,也不拿 leg 去猜、去多说一句假话。
- legBadgeOf 改成同时看 parallel 与 leg,三处调用点(提问侧、回复侧、流式气泡)
都跟着传 parallel;文案仍是「并行路 N」。
测试:ParallelTurnsTest 补 kind=turn / kind=parallel / kind 缺席三档、重复帧的
合并(含「已收口的并行路不被重新打开也不被降级」);TurnStreamsTest 把
「leg>=2 就挂标」的断言改成以 kind 为准,并补上那条真机回归——会话里第二条
普通消息(leg=2、kind=turn)在提问侧与回复侧都不挂角标。
|
2026-10-05 11:59:21 +08:00 |
|
AskaEth
|
a776e9f608
|
feat(settings): 设置页「她忙时发消息」追加第三档「并行」——忙时不打断、另起一路(chat.parallel=true)
主人要求:在既有的「追加队列 / 打断」之下再追加一档「并行」。三档语义互补——追加队列进队列等
(默认档,逐字节不变)、打断先发 interrupt 再发这条、并行不打断、另起一路同时处理。
改了什么:
- AppSettings:InterruptMode 加第三个值 Parallel(wireScope = null, wireParallel = true),并给枚举
补出第二根线 wireParallel(wireScope 只管 interrupt 的 scope,表达不了「要声明并行」这件事)。
既有两个值的 name 与落盘键 interrupt_mode 一个字未动——改名会让已存偏好读不出来并静默回落默认档。
落盘字符串 → 档位的映射抽成顶层纯函数 toInterruptMode(AppSettings 本体要 Android + DataStore,
单测里起不来),新增的第三个名字能正确读回,未知字符串仍回落 AppendQueue(既有行为未变)。
- 判据抽成纯函数 parallelForSend(mode, busy) 与 parallelWireValue(declare):前者与
interruptScopeForSend 共用同一个忙判据(ChatUiState.busyMode != null),只在会话正忙时为真。
规范 §2.2 没写空闲时要不要也声明——空闲时这条本来就是它自己的回合,声明与不声明在服务端是同一
件事,故取与打断同一个判据(最小惊讶:空闲时三档逐字节完全相同)。
- 两条互斥由枚举取值本身保证,不是靠约定:并行档 wireScope 是 null ⇒ 绝不发 interrupt;另外两档
wireParallel 是 false ⇒ 绝不带 parallel 键。默认档「追加队列」线上零差异由此结构性成立。
- 发送路径接线:ChatViewModel.deliver 只读一次偏好、同时喂给两个判据(读两次会在用户切档的瞬间
出现「既打断又并行」);ChatSocket.sendChat 与 ChatRequest 各加一个 parallel 参数,false 一律
翻成整键省略——显式 false 会被序列化成 parallel:false,那是「显式否认」而不是「未声明」,
默认档就不再逐字节不变。
- 设置页:新选项标题「并行」,summary 说清后果——不打断她正在说的这一句、两路各算一遍会多花一份
算力与 token(主人裁决:峰值 token 近翻倍可接受,但用户有权知道)。UI 仍是 Miuix,与同栏另外
两条逐字同形,勾选标记位置相同。
- 分享路径同样不套用这一档偏好(W26 的 allowInterrupt 因此更名为 applySendPreference,它现在管
的是整档偏好):分享是批量投递,逐条索要并行会被服务端 2 路额度把同一次分享劈成两种待遇,且每
条各算一遍;用户也没按过发送键说这条要并行。单张图时同样不声明,规则才能一句话说清。
为什么:主人 2026-10-05 的裁决(真两路并行)需要客户端有一个显式声明入口;W30 已备好 chat.parallel
的线上形状(默认 null = 整键省略),本次只把设置页这一档与发送路径接通,上行仍是纯增量——没选
「并行」的用户线上一个字节都不变。
验收:assembleDebug 通过;testDebugUnitTest 通过(1022 测试 / 0 失败 / 0 错误;基线 1004,新增 18:
ParallelForSendTest 13 条 + InterruptScopeTest 1 条 + InterruptModeTest 4 条)。
|
2026-10-05 09:37:26 +08:00 |
|
AskaEth
|
29299b190e
|
feat(chat): 忙时并行回合的客户端半边 —— 六态 + 灰态 + (sessionId, turnId) 两级分桶 + 新帧消费(全部可降级)
依据:Cyrene_v2/DevDocs/_specs/忙时并行-规范草案.md(§1 行为 / §2 接口 / §4 主人裁决)。
后端 v0.65 尚未落地这些帧,所以本次的新键与新帧全部按可降级实现:帧不来、字段缺席时,
App 在今天的真后端上行为与改前逐字相同(下面的「降级保证」逐条给了证据)。
一、改了哪些文件
新增(纯逻辑,可单测)
- app/src/main/kotlin/top/yeij/cyrene/domain/chat/ParallelTurns.kt
两级分桶键 TurnBucket(sessionId, turnId) 与 turnBucketOf / closingBucketOf;
六态枚举 TurnPhase 与 turnPhaseOf;灰态收口判据 closesAnswering;
消息↔回合绑定 TurnBinding 及其纯函数(withTurnBegin / withSteeredMessage / closingTurn /
allClosed / bindingOfMessage);路小标文案 legBadgeOf。
- app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt
StreamingLeg(bucket, StreamingState) 与 withLeg / beginLeg / finishingLeg / withoutLeg;
六态在状态上的推导 ChatUiState.phaseOf(消息 / 乐观气泡);
路小标与「跳到提问」的查表 legBadgeForMessage / legBadgeForTurn / jumpIndexForTurn。
修改
- data/api/dto/MessageDto.kt:HistoryMessageDto 增可选 turnId(历史行 / 落库行都用它)。
- data/api/dto/ChatDto.kt:ChatRequest 增可选 parallel(REST 与 WS chat 同形)。
- data/ws/WsOutbound.kt:chat 增可选 parallel、interrupt 增可选 turnId,省略即整键不进线上。
- data/ws/WsEvent.kt:新增 turn_begin / turn.end / steer_ack 三个事件与其载荷;
session_busy / session_idle / pending_drain 增可选 activeTurns,session_interrupted 增
activeTurns + cancelledTurns,pending_ack 增可选 reason。
- ui/chat/ChatViewModel.kt:流式解析器与「本回合见过结构化正文帧」由单值升为按桶的 map/set,
新增「本会话最近见过的 turnId」锚点;消费 turn_begin / steer_ack / turn.end;
助手落库行按其 turnId 收口灰态并只摘掉它自己那一路的流式气泡;排队回执记进 queuedKeys。
- ui/chat/MessageBubble.kt:MessageBubble / PendingBubble 增 phase 与 legBadge,
正文气泡增灰态配色,新增并行路小标(可点即跳);角标文案抽成纯函数 bubbleFooter。
- ui/chat/ChatScreen.kt:按桶取每一路的流式状态并渲染;传入六态与小标;忙碌行加「并行 N 路」。
- 新增 4 个测试文件(共 82 条):ParallelTurnsTest、TurnStreamsTest、
WsEventTurnFramesTest、WsOutboundParallelTest。
二、为什么这么做
- 界面必须按 (sessionId, turnId) 两级分桶(规范 §1.7 第 1 件硬要求):两路并行时两个不同
turnId 的 message.delta / thinking.delta / tool_call 会交错到达,照老代码「一律累加到当前那
一个流式气泡」会把两条回复糊成一条(规范 §1.4 点名的那件事)。现在每一路各有一份解析器、
一份思维链、一份工具卡表、一个流式气泡。
- 灰态(④ 已读、未答)的唯一权威收口事件是 turn.end(规范 §1.2)。本端另外在 stream_end
(既有的「整轮结束」帧,v0.36 起带 turnId)与「同 turnId 的助手落库行」上也收口:
前者在 turn.end 到位之前是唯一能收口的帧,后者是规范给的兜底。三者顺序不定、重复到达都无害
(纯函数幂等)。
- 六态判定与收口判据的不变量都抽成纯函数,可直接单测;判定顺序即证据强度:
被拒 > 已绑定未收口 > 已绑定已收口 > 本地乐观气泡 > 排队回执。
三、字段缺失时的降级保证(本次最要紧的一条)
- 全链路没有一处「turnId 为空就不渲染」。turnBucketOf 在帧壳没给 turnId 时先回落本会话最近
见过的 turnId、再回落匿名桶(turnId 为空串)⇒ 所有无 turnId 的帧落在同一个桶里,
那就是老代码那唯一一个流式气泡。
- turn_begin / steer_ack.turnId / turn.end 不来 ⇒ 一条绑定都建不起来 ⇒ 六态里只可能出现
①②③⑤,灰态根本不会进入;界面与改前逐字相同。
- activeTurns 缺席时不动这个计数(null 是「不知道」,0 是「确实空闲」,两件事不许互相顶替);
pending_ack.reason 缺席就是 null,绝不伪造。
- 上行三个新字段(chat.parallel、interrupt.turnId、ChatRequest.parallel)本端今天一个字节都
不发:真并行要用户显式声明,而声明入口不在本次范围内。服务端照旧走排队 + 回合内 steer。
- 已被单测钉住的证据:
ParallelTurnsTest「所有无 turnId 的帧落进同一个桶」「两个空白 turnId 不算匹配,绝不收口」;
TurnStreamsTest「单路时长度恒为 1」「没有任何 turn 帧时已落库=②、本地气泡=①」;
WsEventTurnFramesTest「老形状的帧解出来新键一律为 null(不是 0、不是空串)」;
WsOutboundParallelTest「省略时那条帧逐字节相同,且不带该键」。
四、等 v0.65 的部分
- turn_begin / steer_ack.turnId / turn.end 的真实到达,历史行的可选 turnId,activeTurns /
pending_ack.reason / cancelledTurns 的真实取值。
- 真并行的触发入口(谁在什么时候把 chat.parallel 置 true)——本次只备好线上形状,不接 UI。
- 仍未决且影响客户端的两项:graph_update 两路互相顶掉(规范未决 10,等 design/19 裁决);
按 turnId 单独核算用量(规范未决 14,可选)。
五、规范里与本端既有定案冲突的一处(如实登记)
规范 §1.1 ⑥「被拒」要求气泡带红色「未送达 · 会话正忙」并可重试;本端被拒(pending_full /
定向 chat_busy)的既有定案是摘掉乐观气泡 + 横幅如实告知 + 正文还给输入栏(可再按一次发送,
幂等键保证不会落成第二条消息)。既有行为由现有测试钉住,本次不改;六态判定里保留了 Rejected
档并由单测直接覆盖,只是本端的现实路径走的是回滚那一支。
验证:assembleDebug 绿 + testDebugUnitTest 绿(0 失败 0 错误 0 跳过)。
|
2026-10-05 09:10:45 +08:00 |
|
AskaEth
|
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
|
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
|
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
|
1794e53d1c
|
merge: W24 —— 编辑弹层只显示纯文本(原文含标记时如实告知会被替换,绝不静默丢旁白)
|
2026-10-04 22:08:08 +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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
a7b3452e6d
|
feat(admin): 新增 C-2 管理员只读监控(概览/人格/记忆/后台思考)
侧边栏「管理员监控」进入的独立管理区 + 独立 Scaffold,四页全部只读,
不调用任何 /admin/* 写端点。
- data:AdminApi(5 条只读 GET)+ AdminDto + AdminRepository,
经 ApiClientFactory 创建以复用既有 token / 401 处理
- domain:AdminAccess.kt 为权限真源(纯函数,便于单测);
AdminFormat.kt 把 RFC3339 与 Unix 毫秒两种时间形状分开解析
- ui:AdminHomeScreen 做成可加页的壳(加页 = AdminPage 加一条枚举),
四个子页 + 统一失败态;列表项一律 PreferenceCard + BasicComponent
- RBAC:后端契约要求客户端自行降级(api-client §2.0),按矩阵分档——
viewer 只能进概览/人格;入口禁用 + ViewModel 短路(不发请求)+
服务端 403 也渲染「无权限」态,三层 fail-closed;
任何失败都不渲染成「暂无数据」
为什么:M0–M5 + 设置页 C-1 已完成,C-2 是路线上的下一项;后端 5 个读端点
均已实现(非 501),且手机端后续要走「全功能管理」(后端工作小结 主线②,
凭据前置已完成),故管理区做成可扩展的壳而非只读死胡同。
测试:74 个单测全绿,其中新增 29 个
(AdminAccessTest 9 / AdminFormatTest 13 / AdminDtoDecodingTest 7)
|
2026-10-03 21:31:09 +08:00 |
|
AskaEth
|
84de51c7b7
|
feat: 初始化 Android 客户端(M0 骨架 + M1 登录 + M2 基础模块)
M0:Gradle 9.7.1 / AGP 9.2.1 / Kotlin 2.4 / Miuix 0.9.3,minSdk 33,compileSdk 37。
M1:登录页(服务器地址可配、服务端/本地错误分流、EncryptedSharedPreferences、自动登录与静默重登、401 登出)。
M2 基础模块:消息内容解析器(<p>/<action>/<narration> + 流式增量)、WebSocket 客户端、REST 数据层与分页去重。
45 个单元测试全绿,真机验证通过(登录页 + 启动图标)。
devdocs/ 收录设计文档、前后端协同事项与细化方案。
|
2026-09-08 20:40:58 +08:00 |
|