AskaEth
|
6e00697110
|
feat(chat): 分享进来的消息加一枚「用户分享」渲染层角标
背景:ACTION_SEND 分享进来的内容在会话里与手打的完全看不出区别,主人要求「可识别」。
与后端定过的红线(devdocs/02 的 F4、ShareRules 的「为什么正文一个字都不加」)要求人类可见
标记只能放在客户端渲染层,所以本次正文、content 与任何上行字段一个字节都没动。
改了什么:
- ChatViewModel 的 emitAndDeliver 多一个 localIdPrefix 参数(默认 local-,手打那条路逐字不变),
sendShared 的每一条投递都传 share-:多张图会发多条消息,它们都是分享进来的,所以每条都带。
前缀只加在幂等键前面,后面仍是随机 UUID,唯一性不受影响。
- MessageSource.kt 新增两个常量与两个纯函数:isSharedClientMsgId 只认 share- 且要求后面还有尾巴
(恰好 share- 一律 false:本端铸键永远是 share-uuid,没有尾巴的键不是本端铸的,不能替一条
来路不明的消息声明「这是你分享的」);isSharedMessage 在它之上加一道只认 user 侧的闸。
- MessageBubble 的 bubbleFooter 多一个 shared 参数,角标 [用户分享] 紧挨来源角标拼在角标行里,
沿用这行既有的字级与颜色,不新造样式、不用 Material3,也只对用户侧气泡出现。
- 新增 SharedMessageBadgeTest:判据的六类边界取值与角标行里的落位。
为什么用幂等键而不是正文标记:clientMsgId 由服务端原样回填,是客户端读得回来的既有字段,
「这条怎么进来的」写在它上面不用新增任何协议,也不污染用户正文。
|
2026-10-05 11:30:56 +08:00 |
|
AskaEth
|
f2fbdff9a7
|
W33:并行落回排队的告知加一道本端档位闸 + 忙时被拒那条横幅的只读查证
一、改了什么
加一道闸:pending_ack{reason:"parallel_limit"} 那句降级告知(「她正忙,这条没能另起
一路…」),只在本端当前的「她忙时发消息」档位确实处于「并行」档(InterruptMode.Parallel)
时才给。判据扩了既有纯函数 parallelLimitNoticeFor 的签名(reason + mode 一起判),
本端不在并行档时一律返回 null;调用点在 onEvent 的 PendingAck 分支,档位沿用
currentInterruptMode() 现读(偏好是用户在设置页当场切的东西,缓存住就留一个静默不生效
的窗口),因为 onEvent 不是 suspend 函数,这一句起一个协程去读(与 ensureMessageKnown
同一形态),不起常驻订阅。
二、为什么
pending_ack 是广播帧,每条已认证连接都收得到。极端情况下「别的端在同一会话声明并行
又落回排队、而本端并未选并行」时,本端也会收到这一帧并说那句话 —— 这就是误报。
可那件事与本端无关:用户没选并行时,他这条消息本来就是普通排队、他预期的就是排队
⇒ 落回排队不是意外 ⇒ 不说并不构成静默(与「绝不静默」不冲突)。反过来,在那个时刻
说一句「这条没能另起一路」会让用户以为是自己刚发的那条出了岔子;误报会教用户不信任
这条提示,代价比少说一句大。
三、只读查证(主人点名:忙时被拒那条横幅到底会不会出现)
结论:不缺,pending_full 不带 clientMsgId 时共三档落位,其中两档都写 error,横幅照常
渲染 ——
· 恰好一条待发气泡 ⇒ 回滚档,error = QUEUE_FULL_MESSAGE(她正忙且队列已满,这条没
发出去,请稍后手动重试);
· 两条及以上 ⇒ 说不准档,error = QUEUE_FULL_UNKNOWN_MESSAGE(她忙不过来,这条可能
没发出去),两个气泡一个都不动;
· 两档都带 errorIsQueueFullNotice = true(柔和档配色),横幅由 ChatScreen 同一条
error 通道渲染。
只有「零条待发气泡」那一档是 Ignore,按设计什么都不写:没有气泡会挂住,也就没有要说
的,不能凭空弹一句 —— 理由在 queueFullOutcome 的 KDoc 与 QueueFullOutcomeTest 的两条
测试里(既有,非本次新增)。即「没带 clientMsgId」不等于「说不准档」,本次查证没有
发现需要改代码的地方。
四、测试
新增 3 条(ParallelLimitNoticeTest):追加队列档 + 该 reason ⇒ 不提示;打断档 + 该
reason ⇒ 不提示;再加一条把三档一起摆出来的汇总断言(只有并行档给文案)。「并行档 +
该 reason ⇒ 提示」由既有的那条测试覆盖(现在显式传 InterruptMode.Parallel)。总测试
1030 → 1033,0 失败 0 错误。
|
2026-10-05 10:12:41 +08:00 |
|
AskaEth
|
e85209eebb
|
fix(chat): 忙时被拒那条横幅调柔和(只它,真错误照旧红)+ 补 pending_ack 的并行降级告知
一、忙时被拒的横幅改用柔和档颜色(它不是错误,不该刺眼)
改了什么:那句告知(她正忙且队列已满,这条没发出去,请稍后手动重试)一直走 state.error 这一条通道,
ChatScreen 里那条 Banner 的调用点对所有 error 一律用 colorScheme.error(正红)。现在给 ChatUiState
加一位 errorIsQueueFullNotice(只决定配色),忙时被拒的两条入口(WS 广播 pending_full 与定向
error{chat_busy} 的队列满处置、REST 409 chat_busy 的回滚)把这一位写成 true,ChatScreen 那一处调用
据此在 onSurfaceVariantSummary(同文件里分享/附件/定位三条告知用的那档柔和色)与 error 之间选色。
为什么:主人裁决形态不变(摘乐观气泡 + 顶部横幅如实告知 + 正文/图片/附件还给输入栏 + 重发复用幂等键),
但红色不要太醒目——忙时被拒不是坏了,是真没排上,红得像故障会吓人。而它与真错误(登录失效、网络失败、
服务端 4xx、图片与附件处理失败……)共用 error 这同一条通道,直接改那个调用点的颜色会把真错误一并调柔和,
所以先分档再选色:写 error 的每一处都显式带着这一位,边界写进了 ChatUiState.errorIsQueueFullNotice
与 Banner 的 KDoc,免得后来人统一改色。
二、消费 pending_ack 的 reason,如实告知「并行没排上、已按普通排队走」
改了什么:reason 字段在解析层早就原样保留(WsEvent.kt 的 PendingAck.reason),但一直没有任何地方消费它
——收到它时界面与普通排队毫无区别。新增纯函数 parallelLimitNoticeFor(reason)(已知值给一句话,未知值 /
缺席 / 空白给 null,不为不认识的 reason 编文案)、常量 PARALLEL_LIMIT_REASON 与 PARALLEL_LIMIT_NOTICE,
以及状态位 ChatUiState.parallelLimitNotice(与 shareNotice / attachmentNotice 同一套机制:8 秒自动收走、
用柔和色、不给关闭按钮),在 WS 的 PendingAck 分支里接上。
为什么:设置页新增并行档之后,开关 ON 不等于一定起第二路——服务端 CHAT_MAX_PARALLEL_TURNS 小于 2 或
额度已满时会让它落回排队,并回 pending_ack{reason:"parallel_limit"}。用户选了并行却看不出区别就是静默,
违反项目铁律「绝不静默」;而这条不是错误、是降级,所以措辞把三件事说清(并行没排上、这条没丢、她做完
手上这句就轮到它),配色与上面那条忙时告知同一档。
测试:新增 ParallelLimitNoticeTest 8 条(reason 的三档穷举 + 横幅温度的边界:忙时被拒为柔和、真错误不
继承那份柔和)。assembleDebug 与 testDebugUnitTest 全绿:1030 个测试,0 失败 0 错误(基线 1022,新增 8)。
|
2026-10-05 09:55:53 +08:00 |
|
AskaEth
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
664fd8dcef
|
merge: W3 — B1 /upload(大图走上传,历史只留 URL)
|
2026-10-04 17:44:23 +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
|
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
|
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
|
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 |
|