AskaEth
|
5a9f52a069
|
feat(chat): 图片入口改多选,N 张图按既有切分落成 N 条消息
主人 2026-10-05 定案「那肯定要多选」:图片按钮从 PickVisualMedia(单选、回一个 Uri)换成
PickMultipleVisualMedia(系统 Photo Picker 的多选形态,仍然零权限,不退回落 SAF / 存储权限)。
文件按钮那条 OpenMultipleDocuments 一个字没动。
为什么复用分享路径那一段(不另起一套发送逻辑):
分享落地(ChatViewModel.sendShared)早就把「一条消息只能带一张图 ⇒ N 张图 = N 条消息」做完了,
那段切分本来内联在它自己的 forEach 里,不是可复用的纯函数。这次把判据抽成纯函数 imageSendPlan
(ui/chat/ImageSendPlan.kt,跟 SharePayload.kt 与 SendQueue.kt 一样的写法,可单测),投递抽成
emitPlannedMessages(私有),输入栏那条路与分享落地那条路共用同一段:两处各写一份循环,迟早会在
「哪条带正文 / 哪条带附件 / 哪条清草稿 / 超过上限怎么办」上分叉。每张图各自的解码 / 缩放 / 重编码
与「内联还是上传」的取舍仍旧逐张走既有实现(deliver 与 deliverImage),一行没改。
为什么 N 张 = N 条(而不是一条带多张):
契约里 chat.image 是单图字段,一次上行只能带一张,所以多选进来的 N 张只能落成 N 条消息:
第一条带正文 + 第一张图 + 全部附件,其余每条一张图,顺序 = 用户在多选器里选的顺序。契约与服务端
一个字没动。
上限与告知沿用分享路径的口径:
上限仍是 ShareRules.MAX_IMAGES(4)。超限那几张不静默丢,如实告知;那句文案抽成
ShareRules.truncatedImagesNotice,分享入口与选图入口共用同一句(逐字未改,ShareNoticeTest 钉住)。
系统选择器自己会把可选张数卡在 4 上,只有回落成 ACTION_OPEN_DOCUMENT 的设备(它表达不了最多几张)
以及「草稿里已有几张、再选几张」这两种情形会真超,那时由入口与投递两处说出同一句。
其余口径与今天一致:
输入栏最多攒 4 张(到顶后不再显示图片入口),每张各自可移除;取消选择什么都不做;
发送时第一条带正文并清草稿(N 条是一次发送,草稿只清一次),失败的那一条只把自己那一张还给输入栏。
一次发送切出的多条里只有第一条套用「她忙时发消息」偏好——第 2 条起声明打断就是打断自己刚起的
上一轮(与分享路径不声明同一条理由),N = 1 时与今天逐字节相同。
顺带:
- ChatUiState.draftImage(String?)改为 draftImages(List<String>),rollbackPending 把失败那张补回
待发列表(不再覆盖),输入栏渲染成每张各自带移除键的一排预览;
- QueueFullOutcomeTest 里断言单图的四处改成新事实(断言一条没删);
- 新增 ImageSendPlanTest,覆盖 N = 0 / 1 / 2 / 4 / 5(超限),并钉住附件只跟第一条、顺序不重排、
超限那句与分享路径同一句。
|
2026-10-05 14:41:21 +08:00 |
|
AskaEth
|
97992bdb6b
|
docs(05): 锚点补记十三——W41 三级阶梯(未连接先探 health,通就回落 REST 发出去、不通才进队列),含 Offline 为何不探测、gapCursor 那个被补掉的洞、以及图/附件走不到这条路的边界
|
2026-10-05 14:35:14 +08:00 |
|
AskaEth
|
eb22521269
|
merge: W41 —— 未连接时先探 health,通就回落 REST 发出去,不通才进队列(三级阶梯)
|
2026-10-05 14:34:12 +08:00 |
|
AskaEth
|
9f77fb469c
|
feat(chat): W41 未连接时先探一次健康端点,通就回落 REST 发出去,不通才进队列
主人第二次定案:「未连接状态你就不能拿 httpAPI 尝试吗?」→ 明确为「可以走 health 端点
验证连通性」。W40 的「未连接就进队列」保留为最后一级,队列、气泡小标与那套柔和告知原样复用。
## 为什么先探测再决定,而不是直接排队
网关只有一条:POST /api/v1/chat 与 /ws 是同一个端口上的两条车道。握手被挡(代理不转发
Upgrade、中间设备掐长连接)时 REST 往往是通的,直接排队等于把「WS 这条车道暂时不通」当成
「后端不可用」,让用户白等一次重连——而那一刻这条消息本来就能发出去。
## 三级阶梯(判据是纯函数 sendRouteFor,见 ui/chat/SendFallback.kt)
1. WS 就绪 → 照旧走 WS。成功路径的字节与状态机一个字没变:sendThroughLadder 的第一件事
仍然是 sendChat,前面不加任何判断、不挂任何起点。
2. 未就绪 → 探一次 GET /api/v1/health(2.5 秒整通上限,只看 2xx;失败 / 超时 / 抛异常一律
按不通处理,fail-closed)。通 → 用既有的 POST /api/v1/chat 把这条发出去(短文本也走
REST);不通 → 进队列。
3. 回落 REST 失败:网络类与超时 → 进队列;服务端给过裁决的(明确拒绝、401)与配置类错误
(证书 / 明文被禁 / 地址格式 / 解析不出来)仍旧回滚加文案,不装成「还没连上」。
4. 补发走同一个函数(fromQueue=true):阶梯只有一份实现,两条路不会分叉成两种行为。
Offline 那一档直接进队列、连探测都不做:它的语义是握手 401 且续期也失败(会话已知是死的),
而健康端点不鉴权(Cyrene_v2/internal/services/gateway/routes.go:51)必然回 200 —— 探了只会
发出一次注定 401 的 REST,把 W40 那句「已排队不会丢」换成一次回滚加一句误导人的鉴权错误。
## 为什么重发是安全的
回落与补发用的都是那条乐观气泡的 localId,也就是上行幂等键 clientMsgId,一个字节都不新铸。
服务端按 (session_id, client_msg_id) 唯一索引仲裁:重复到达只回 chat_duplicate /
duplicate:true,不再落库、不再起回合(契约 v0.56 §3.13)。于是「超时了但服务端其实已经
收下」这种最难判的情况不需要本端猜。
## 回复怎么回来
那一刻没有 WS 连接,内容帧没人收,靠重连时的正向增量补回来(gapCursor → catchUp →
loadAfter → absorb)。本 VM 从没连上过时 gapCursor 是 null、重连那一步什么都不会拉,所以
回落发出去且服务端已受理之后补记一次游标(armGapCursorForFallback,只在它还是 null 时补)。
## 其他
- 探测结果不缓存,每条消息最多探一次(只有 WS 没把帧交出去才问);WS 就绪那条路上一次探测
都不发。
- 补发从同步循环改成单飞协程:阶梯里要探测、要发 REST,会挂起,「两个触发点都在主线程上
所以不可能交错」这条旧论证不成立 ⇒ 改用 drainingQueue 单飞闸加串行出队,出队仍在有结论
之后。
- 离线时的上界:探测不通就直接进队列、一次 REST 都不会发,用户最多等探测那一段(约 2.5 秒);
回落那一次 REST 另有 10 秒上限(stream:true 的回执本该毫秒级回来)。
- 新增测试 SendFallbackTest(17 例):优先级、fail-closed、Offline 不探测、回落失败分档、
探测超时落在 2 到 3 秒、有图或有附件的消息压根不进这层阶梯。
- devtools/fake-backend 补上 GET /api/v1/health,否则本机跑假后端时这条阶梯会退化成直接排队。
|
2026-10-05 14:33:34 +08:00 |
|
AskaEth
|
7b981dd88a
|
docs(05): 补记十二补齐——W40「未连接进队列」的真机回归结果(断线时不退回不报错、恢复后只补发一条),并记下新 AVD 的实测点击坐标
|
2026-10-05 14:08:28 +08:00 |
|
AskaEth
|
3611455357
|
docs(05): 锚点补记十二——AVD 调参第二轮(缩小到 1080x2340、vCPU 降到 5、删 skin 才让 hw.lcd 生效),并记下「无窗口会降级成软件 GL,反而更慢」这条与直觉相反的实测
|
2026-10-05 14:00:56 +08:00 |
|
AskaEth
|
31e8e31679
|
merge: W40 —— 普通发送未连接时进队列(不再回滚),连接恢复后按发送顺序自动补发
|
2026-10-05 14:00:10 +08:00 |
|
AskaEth
|
67b2e58fdf
|
docs(05): 锚点补记十一——W40 普通发送未连接时进队列(主人定案落地),并登记 REST 未覆盖与三条待办
记下这一批的口径与证据,供下一个会话接续:
- 分支 feat/W40-send-queue-offline(基线 e60ae7a),代码与测试提交 df9fc85;未合并、未推送。
- 证据:assembleDebug 成功;testDebugUnitTest 1118 / 0 失败 / 0 错误 / 0 跳过(83 套件)。
本批没有真机回归。
- 判据:wsSendOutcome 三档(Dispatched / Queued / Failed);补发触发点是连接状态流变成
Connected 那一刻,无轮询;顺序 = 用户发送先后顺序;只发一次的保证点是队头加出队恰好一次,
第二道是复用同一个 clientMsgId。
- 状态表达:不新增第七态(等连接仍是 ③ 排队中),气泡小标加成「排队中 · 还没连上」,
另有一条柔和的 sendQueueNotice 告知(一轮队列只说一次,Offline 那一档说的是
重新登录 / 检查服务器地址)。
- REST 通道(长文 / 图片 / 附件)本批未覆盖,仍走回滚加既有网络错误文案,理由与影响面写在
补记里。
- 待办里更新了四条:真机回归、REST 要不要也排队(需主人定案)、Offline 立刻告知、
以及那一处刻意未动的失败文案字面量。
|
2026-10-05 13:58:20 +08:00 |
|
AskaEth
|
df9fc85f76
|
feat(chat): 普通发送未连接时进队列(不再回滚),连接恢复后按发送顺序自动补发
主人 2026-10-05 裁决:普通发送在「未连接」时进队列。
此前 deliver 拿到 sent=false 就 rollback:气泡摘掉、正文退回输入栏,外加一句「还没连上,
这条没发出去,请稍后手动重试」。内容是没丢,但把「网络还没好」说成了一次要用户自己负责的
失败,而且他还得对着输入栏再发一遍。这一批按 W38「分享落地等连接」那套语义重做普通发送:
不报错、内容不丢、连接恢复后自动补发。W38 的落地时机、PendingShareHolder.take() 的原子语义、
ChatSocket 的连接状态机一个字都没动。
① 三档处置:未就绪就排队(新纯函数 wsSendOutcome,ui/chat/SendQueue.kt)
判据的输入只有两个:sendChat 的回值,以及这一刻 socket 自己的连接状态。sent=true ⇒ Dispatched
(帧已经交给 socket,成功路径一个字没动:不打标记、不说告知、不写任何状态);sent=false 且
连接就绪 ⇒ Failed(连接是好的,那 false 只可能来自 socket 对象已空 / 已被 cancel / 序列化抛
异常,「发送失败」在这一刻是事实 ⇒ 走今天那条 rollback + SEND_FAILED_MESSAGE);sent=false 且
未就绪 ⇒ Queued(ChatSocket.send 第一行就按状态挡掉,帧连序列化都没做、服务端一个字节都收不到,
所以既不回滚也不进 error 横幅)。未就绪四档(Idle / Connecting / Reconnecting / Offline)
一视同仁:主人的原话是「未连接就进队列」,Offline 只是不会自己变好,内容仍然一个字节都不会丢,
两档的差别只在告知措辞(这一点与 W39 给分享落地定的规矩一致)。
② 队列与补发:顺序与「只发一次」的保证点
deliver 的 WS 分支只改了「未就绪」这一支:queueForConnection 把这条留在待发态
(ChatUiState.queuedForConnection,条目只有 localId 与投递那一刻定下的并行声明;正文 / 图片 /
附件仍从那条气泡上现取,同一件事不留第二份真相源,也就不存在两条真相分叉)。触发点只有一个:
socket 状态变成 Connected 那一刻(onConnectionChanged,由状态流驱动、没有任何轮询),外加
入队之后的一次竞态兜底(连接恰好在投递与入队之间就绪时,那一次补发会扑空)。补发从队头开始,
FIFO 顺序 = 用户发送的先后顺序。
「只发一次」的保证点是「队头 + 出队恰好一次」:队头只在这一步有了结论(发出去了 / 真失败 /
气泡已经不在了)之后才被 dropQueuedSend 摘掉,所以重复的触发拿到的是同一个键、不会多出第二次
出队;flushQueuedSends 是同步函数(一个挂起点都没有),两个触发点在主线程上不可能交错。
第二道保证在服务端:补发复用同一个 clientMsgId(绝不铸第二个),真重复到达也只会被回
chat_duplicate,不会落成第二条消息。补发又失败时按今天的失败路径处理(回滚 + 文案),
绝不静默吞掉。已知边界(写在 KDoc 里):打断声明不随补发重放——补发时在跑的那一轮早就不是
用户按发送时看到的那一个了,隔半分钟再去打断可能毁掉他没见过的一轮。
③ 状态表达:仍是六态,③ 里多一句更准的话
不新增第七态:等连接的这条走六态里的 ③ 排队中(phaseOf 的判据从「收到过入队回执」扩为
「收到过回执,或本端因未连接把它留住了」),两类排队的区别由气泡小标说清——新纯函数
pendingBubbleFooter 给 ③ 加四个字:「排队中 · 还没连上」。为什么挂在气泡上而不只是那句告知:
告知八秒就收走,而这是个会持续几十秒的状态,牌子撤掉之后光看「排队中」分不出是「她已经收下、
前面还排着队」还是「连都没连上、一个字节都没上路」。柔和告知走新通道 ChatUiState.sendQueueNotice
(与 shareNotice 分开:两条描述的是不同的动作,可以同时挂着),文案 SEND_QUEUED_NOTICE 是
「还没连上,这条排队等着,连上后自动发出」;Offline 那一档换 SEND_QUEUED_OFFLINE_NOTICE
「现在处于离线,需要重新登录或检查服务器地址;这条还没发出去,已排队不会丢」——不提「连上」
「自动发」,那一档没有会自动到来的连接。不刷屏:一轮「队列从空到空」只说一次(连续几条离线
消息不会每按一次发送就弹一遍),记账在 queueForConnection。
④ 未覆盖:REST 通道(长文 / 图片 / 附件)保持今天的行为
POST /api/v1/chat 那条路仍是失败回滚 + 既有网络错误文案,本批不动。两条理由:一是它给不出
「未连接」这个信号(拿到的只是一次网络错误,而请求可能已被服务端收到、也可能没有),
判据不可靠;二是要补发就得重跑图片重编码与附件上传换存储名,而本地 URI 的读授权可能已经失效。
代价:未连接时发长文 / 图片 / 附件仍要用户手动重发一次;影响面仅这一条通道,WS 那条(短文本,
日常绝大多数消息)已经覆盖。
⑤ 未连接时不再有的那句话
「还没连上,这条没发出去,请稍后手动重试」(W39 加的 SEND_NOT_CONNECTED_MESSAGE)与它的判据
sendFailureMessage 一并删除:回滚那条路现在只在连接就绪时才走,这句话已经没有出口,留着就是
一段描述旧行为的死文案。SendFailureMessageTest 的断言改成新事实(未就绪 ⇒ 排队,而不是回滚
加那句话)并连同新的队列判据并入 SendQueueTest,一条断言都没删。另把「空输入什么都不发生」
这条边界抽成纯函数 hasSendableContent,输入栏的 send 与投递口的 emitAndDeliver 共用同一条判据
(行为不变,只是原本写在两处的同一个 if 现在可单测,也不会再分叉)。
测试:新增 SendQueueTest(21 条:三档判据穷举 WsState.entries、两档告知措辞、队列顺序与出队
恰好一次、气泡小标与六态、空输入边界)。assembleDebug 成功;testDebugUnitTest 1118 条
(1101 减 4 加 21),failures 0 / errors 0。
|
2026-10-05 13:57:54 +08:00 |
|
AskaEth
|
e60ae7aecc
|
docs(05): 锚点补记十——W39 三处措辞不诚实已修(告知不再两句叠加 / 未连接与发失败分开说 / Offline 不再承诺自动发出),并登记两处刻意未做与一条主人定案项
|
2026-10-05 13:43:01 +08:00 |
|
AskaEth
|
b55d220e28
|
merge: W39 —— 分享告知不再两句叠加;未连接与发送失败分开说;Offline 不再承诺「连上后自动发出」
|
2026-10-05 13:42:15 +08:00 |
|
AskaEth
|
a763592ace
|
fix(chat): 分享告知不再两句叠加;未连接与发送失败分开说;Offline 不再承诺「连上后自动发出」
三处「对用户不够诚实」的措辞缺陷,各自独立修复。行为上只多了一处「少说一句」,
其余语义一字未改。
① 分享告知两句叠加(ChatScreen.shareNoticeFor)
实机读到的原文是「有 2 个文件读不到,未发送;分享的内容是空的,没有可发送的内容」,
读起来像两个不同的问题。可这两句说的是不同的免责,而「认下了地址却读不到」本身就蕴含
「没有可发送的内容」——叠上去一个字都没多说。故丢件计数为真时不再追加「内容是空的」那句;
其余组合保持今天的行为(只有丢件计数时只说丢件计数;「读不进来」与丢件计数说的是两件事,
照样并列)。KDoc 里记下了这一档今天的真实出口:worthDelivering 要求 isEmpty 的分享必须
有丢件计数或 contentUnreadable 才值得往下走,而后者已被「读不进来」那一档接走,
所以那句加了前提之后一次都不会再出现。
② deliver 的 WS 分支:连接未就绪不等于发送失败(ChatViewModel)
sendChat 回 false 有三种成因(连接状态不是 Connected / socket 对象已空或已被 cancel /
序列化抛了异常),此前共用一句「发送失败,请重试」,用户无从分辨「这条根本没上路」
与「发出去了但失败了」,而后者在连接未就绪时不是事实。现在按失败那一刻的连接状态分开说:
不是 Connected 时说「还没连上,这条没发出去,请稍后手动重试」,仍是 Connected 时逐字保持
「发送失败,请重试」。回滚照旧(摘掉气泡、内容退回输入栏、记下幂等键),没有自动重发,
普通发送的其它语义没动。判据抽成纯函数 sendFailureMessage(WsState),用例覆盖
WsState.entries 的每一个取值。连接状态取 socket 自己的 StateFlow——那正是 sendChat 判的值,
不用界面镜像过来的 ChatUiState.connection,免得晚一帧读到 Connected 反而说错话。
③ WsState.Offline 不再等一句永不到来的「连上」(ShareLanding / ChatScreen)
Offline 是终态(握手 401 且续不上、或缺少 token / 服务器地址),它不会自动重连。
对它说「连上后自动发出」,用户等的是一个永远不会到来的连接——拿一句不兑现的承诺糊住
一个需要他动手的状况,比不说更糟。新增 ShareLanding.TellOffline:等够久、同一轮只说一次
这些时机与可恢复的那几档完全一致,只有措辞不同:「现在处于离线,需要重新登录或检查
服务器地址;分享的内容一个字节都没发出去,不会丢」。W38 的落地时机一个字没改:
仍然不 take()、不发送,连接恢复后照旧自动补发。哪档配哪句由纯函数 shareWaitingNotice 定,
不再散在 Compose 的 when 里,于是「Offline 换口径」这件事不需要组合界面就能测。
测试:ShareNoticeTest 把「两句都在」的断言改成新事实并穷举同族组合;ShareLandingTest 新增
Offline 的差异、时机与文案用例;新增 SendFailureMessageTest 覆盖每一种 WsState。
assembleDebug 成功;testDebugUnitTest 1101 条,failures 0 / errors 0。
|
2026-10-05 13:41:52 +08:00 |
|
AskaEth
|
4eaee04255
|
docs(05): 补记九补齐 W38 的真机回归——断线分享得到柔和告知且内容不丢,恢复连接后自动补发且只发一次
|
2026-10-05 13:25:08 +08:00 |
|
AskaEth
|
f15f0bf9bc
|
docs(05): 锚点补记九——W38 分享「等连接」缺陷已修(四态纯判据 + 只在该发时才取走内容)、W37 设备验证通过、并行误标的设备证据、以及两条测量教训
顺带登记三处未修的小账:告知文案两句叠加、deliver 对未就绪与发失败仍不分、WsState.Offline 缺少专门的措辞。
|
2026-10-05 13:22:10 +08:00 |
|
AskaEth
|
d8228fb315
|
merge: W38 —— 分享落地必须等到连接就绪:超时不再等于发送失败,内容不再从队列丢失,连接恢复后自动补发
|
2026-10-05 13:21:26 +08:00 |
|
AskaEth
|
aeb37458e8
|
fix(chat): 分享落地必须等到连接就绪,超时不再等于发送失败
为什么超时不能等于发送失败
WS 还没连上时投递的分享(ACTION_SEND)此前走成了「发送失败,请重试」,内容还从待发队列里
永久消失。来处是「等连接」那句 withTimeoutOrNull 的返回值被丢掉了:超时与成功走同一条下游,
照样 take() 再 sendShared → deliver → ChatSocket.sendChat 回 false ⇒ ChatViewModel 落到
rollback(localId, "发送失败,请重试")。实机取证(2026-10-05 11:56):GET /ws/chat status=200
前后的窗口里投递分享,界面上是失败横幅 + 正文退回输入栏,而后端零请求(POST /api/v1/chat
一条都没有,也没有任何 error 帧)——那条横幅把「一次都没尝试发出去」说成了「发送失败」。
ShareIntake.take() 是原子出队且没有归还路径:那一次「其实一次都没发出去」的失败之后,内容
既不在 holder 里、也不在服务端,只剩回滚进输入栏的那一份(多图分享里除首条之外,连输入栏
都回不去)。重试资格因此一起丢了。所以判据只有一条:没连上就一个字节都别动——不 take()、
不发送、不产生任何 error 横幅。
为什么取走时机必须晚于连接就绪
短文本走 WS chat 帧,而分享冷启动必然撞上握手还没完成;帧发不发得出去完全取决于握手结果。
所以等待里不再有「到点就照发」这一档,只有「连上才发」:连接恢复之后由连接状态自己把这条
落地重新触发一遍。
改法
- 判据抽成纯函数 shareLanding(ui/chat/ShareLanding.kt,可单测):没有待发 ⇒ Nothing;已连接
⇒ Send(压过后两条,且返回的是单值动作,所以发送与告知不可能同时发生);未就绪且还没等够
⇒ KeepWaiting;未就绪、已等够、还没说过 ⇒ TellWaiting;已经说过 ⇒ KeepWaiting(不刷屏)。
- ChatScreen 的那个 effect 把连接状态并进键,连接后来就绪会自动重跑、自动补发;「不会重复
发送」仍然由 ShareIntake.take() 的原子性兜住——哪怕某一次拿着已经过期的快照进来,take()
给出的也只是队列里的下一条或 null。
- 等太久只做一件事:经既有的柔和通道 ChatViewModel.showShareNotice 说一句「还没连上,分享的
内容会留着,连上后自动发出」(不是带 X 的 error 横幅),同一次等待只说一次。
- SHARE_CONNECT_TIMEOUT_MILLIS 改名 SHARE_WAIT_NOTICE_MILLIS:它不再是发送的时限,只是
「等多久才值得说那一句」;「等够久」的计时只跟「队列里有没有东西」与「连上了没有」走,
连接在 Connecting / Reconnecting 之间抖动不重置它。
- 普通发送路径(输入框、图片、附件)一行未动。
测试
- ShareLandingTest(新,7 条):未就绪在任何连接状态下都恒不 Send(不取、不发);未就绪且没
等够恒 KeepWaiting(也不告知);等够了只说一次、说过了就不再刷屏;已连接恒 Send(压过告知);
无待发恒 Nothing(任何连接状态下都不做事);文案必须说清「还没连上」与「自动发出」,且不含
「失败」「重试」。
assembleDebug 与 testDebugUnitTest 全绿(1091 tests / 0 failures / 0 errors)。
|
2026-10-05 13:21:09 +08:00 |
|
AskaEth
|
44b58db58a
|
docs(05): 锚点补记八——分享路径设备验证成绩单(文本热/冷启动、超长走文件三条通过;图片受阻于工具)+ W37 说明 + 三条新环境坑(黑屏/adb root 冲掉 reverse/system_server 崩)+ 并行误标问题的取证结论
把「adb 造不出带授权的 URI intent,别再花时间」与「黑屏会让 input tap 全部落空、先确认屏幕是亮的」写成纪律。
|
2026-10-05 13:13:05 +08:00 |
|
AskaEth
|
a2350a6d5f
|
merge: W37 —— 分享认下字符串形态的 EXTRA_STREAM(String[] / ArrayList<String>),并在一条内容都没读进来时如实告知,不再静默
|
2026-10-05 13:09:22 +08:00 |
|
AskaEth
|
0764bc269c
|
docs(02): 客户端答复回执十九——给出那次分享失败的精确时间锚点(11:56:08 握手成功后 ~10 秒内)与「无 error 帧、帧根本没发出」的证据链,并采纳「把允许发送绑在握手完成之后」
同时确认 kind/leg 的用法边界(kind 只做展示、归属只看 messageIds),以及语音按 v0.67「契约先行、未落地」处理。
|
2026-10-05 13:09:18 +08:00 |
|
AskaEth
|
6cdf111419
|
fix(share): 认下字符串形态的 EXTRA_STREAM,并在一条内容都没读进来时如实告知
为什么字符串形态必须认
真实世界里 EXTRA_STREAM 有三种形态:规范推荐的 Uri(多选时 ArrayList<Uri>)、String[]、
ArrayList<String>。Android 上确实有一部分发送方(历史遗留 / 跨平台壳应用)在用后两种,而本端
此前完全认不出:getParcelableExtra 遇到 String[] 只往 logcat 打一句 ClassCastException 再回
null,getParcelableArrayListExtra 同理。实测 am start ... --esa android.intent.extra.STREAM
uri1,uri2 投两张图进来就是这个样子。
为什么静默是 bug
上面那次分享的结局是:后端零请求、库里零新增、界面连一句软告知都没有。用户点了分享,没有任何
东西告诉他"没读进来"还是"在路上"——这是最糟的那种失败形态,所以本次一并消灭:带着内容迹象却
一条可用内容都没提取出来时,走既有的柔和通道说一句"这次分享的内容没能读进来,没有发送任何内容"。
EXTRA_TEXT 与 EXTRA_STREAM 都为空(用户确实没分享任何东西)时仍然保持静默,那条边界有测试钉住。
做了什么
- ShareIntake 改成读 EXTRA_STREAM 的原始值再按形态分派(Bundle.get 不做类型检查),Uri / Uri
数组 / String / CharSequence / 数组 / 列表一并摊平;顺带不再往 logcat 丢那两行 ClassCastException。
- 字符串形态只认 content://:那是本端新开的一条读入口,送进来的只是一段文本,没有任何框架级担保。
file:// 等于让发送方替本端指定一个本地路径、由本端拿自己的权限去读它;http(s):// 是网络地址,
收下它等于让第三方的一行文本指定一个 URL 由本应用代取。被拒的条目逐条跳过,不让整次分享失败。
- 优先顺序与去重不变:EXTRA_STREAM 的规范形态 -> 字符串兼容形态 -> ClipData(仅前者全为空时兜底),
trim / 去空 / 去重,先来的先算;重复项既不算截断,也不把真图挤到上限之外。
- 「带着内容迹象」的判据取 EXTRA_STREAM 的原始值(由调用方读了原始值判出来,含形态本端认不出的那种),
不是「认下来的地址」——否则「有值但一条都认不出」恰恰会被判成「什么都没分享」而继续静默。
- SharedContent 新增 contentUnreadable 与 worthDelivering:后者是唯一判据,ShareIntake 用它决定
要不要往下接,shareNoticeFor 用它守卫"什么都没有就一个字都不说"那条边界,两处不会走岔。
- 分类(图片 vs 文档)、落 cache、截断与拷贝失败的既有语义一概不动。
测试
- ShareExtractionTest:字符串形态的顺序与逐条跳过、两种形态并存去重、scheme 白名单表、
读不进来 / 形态认不出 / 静默三条边界。
- ShareStreamExtrasTest(新):按真实对象验形态分派(String[] / ArrayList<String> / String /
CharSequence / 认不出的形态不猜但照样算带着内容),并接到提取层验一次端到端。
- ShareNoticeTest(新):"没能读进来"成句且不说成发送失败、正常落地一个字不说、空白分享仍然静默。
assembleDebug 与 testDebugUnitTest 全绿(1084 tests / 0 failures / 0 errors)。
|
2026-10-05 13:05:20 +08:00 |
|
AskaEth
|
ef257c3167
|
docs(05): 锚点补记七——并行两路实机验通(三条判据)+ leg 误报并行已修(W36)+ 假后端横幅两档验过 + 模拟器调参(iowait 196%→14%)+ 分享接入其实早已实现
把「别用 leg 判并行、只看 turn_begin.kind」写成纪律,并记下 ANR 判据的正确做法(拉 trace 看主线程栈,别先怀疑代码)。
|
2026-10-05 12:01:18 +08:00 |
|
AskaEth
|
405388e325
|
docs(02): 客户端答复——回执十八那两个问题的答案(都不解析正文标记、单段样式零差异)、把 turn_begin.kind 立为形状依赖(角标已改以 kind 为准)、并行两路实机验通、分享发送失败一次的观察
为什么不提交后端会话的在途追加:他们正往 02 里写回执十八(未提交),按 02 的协作纪律处理——先另存他们的补丁、把文件还原到 HEAD、只提交我方这一节,随后再把他们的内容原样贴回末尾。两边都不丢,也不把他人的在途工作算作我的提交。
|
2026-10-05 12:01:14 +08:00 |
|
AskaEth
|
39a63f8d30
|
fix(chat): 并行路角标改以 turn_begin.kind 为准,不再拿 leg 冒充并行
leg 是服务端**会话内的路序号**:beginLegLocked 对每一条新路都自增 legSeq,
普通串行回合照样占号——会话里发第二条普通消息,leg 就是 2。而角标旧判据是
leg >= 2,于是那条串行消息被挂上「并行路 2」,凭空声称存在并不存在的并行。
「是不是并行路」真正的判据是 turn_begin.kind(turn = 普通串行回合,
parallel = 忙时另起的一路);解析层本来就把它读出来了,只是绑定时丢掉了。
- TurnBinding 新增 parallel;withTurnBegin 收下 kind,由 isParallelLegKind
判定:只有逐字等于 parallel 才算,turn、缺席、空串、大小写不同一律不算。
- 合并规则:新建按 kind 定;同 turnId 重复到达时取或(old.parallel || 本帧
是并行),一旦为 true 就永远是 true。与 closed 同一条纪律——单调的既成事实
不许被后到的帧翻回去;也顾及 steer_ack 可能先建一条没有 kind 的占位表、
随后才由这一路自己的 turn_begin 补上并行的现实顺序。
- 降级:kind 不来的老服务端上 parallel 恒为 false,角标一枚都不挂——宁可少挂
一枚,也不拿 leg 去猜、去多说一句假话。
- legBadgeOf 改成同时看 parallel 与 leg,三处调用点(提问侧、回复侧、流式气泡)
都跟着传 parallel;文案仍是「并行路 N」。
测试:ParallelTurnsTest 补 kind=turn / kind=parallel / kind 缺席三档、重复帧的
合并(含「已收口的并行路不被重新打开也不被降级」);TurnStreamsTest 把
「leg>=2 就挂标」的断言改成以 kind 为准,并补上那条真机回归——会话里第二条
普通消息(leg=2、kind=turn)在提问侧与回复侧都不挂角标。
|
2026-10-05 11:59:21 +08:00 |
|
AskaEth
|
6e00697110
|
feat(chat): 分享进来的消息加一枚「用户分享」渲染层角标
背景:ACTION_SEND 分享进来的内容在会话里与手打的完全看不出区别,主人要求「可识别」。
与后端定过的红线(devdocs/02 的 F4、ShareRules 的「为什么正文一个字都不加」)要求人类可见
标记只能放在客户端渲染层,所以本次正文、content 与任何上行字段一个字节都没动。
改了什么:
- ChatViewModel 的 emitAndDeliver 多一个 localIdPrefix 参数(默认 local-,手打那条路逐字不变),
sendShared 的每一条投递都传 share-:多张图会发多条消息,它们都是分享进来的,所以每条都带。
前缀只加在幂等键前面,后面仍是随机 UUID,唯一性不受影响。
- MessageSource.kt 新增两个常量与两个纯函数:isSharedClientMsgId 只认 share- 且要求后面还有尾巴
(恰好 share- 一律 false:本端铸键永远是 share-uuid,没有尾巴的键不是本端铸的,不能替一条
来路不明的消息声明「这是你分享的」);isSharedMessage 在它之上加一道只认 user 侧的闸。
- MessageBubble 的 bubbleFooter 多一个 shared 参数,角标 [用户分享] 紧挨来源角标拼在角标行里,
沿用这行既有的字级与颜色,不新造样式、不用 Material3,也只对用户侧气泡出现。
- 新增 SharedMessageBadgeTest:判据的六类边界取值与角标行里的落位。
为什么用幂等键而不是正文标记:clientMsgId 由服务端原样回填,是客户端读得回来的既有字段,
「这条怎么进来的」写在它上面不用新增任何协议,也不污染用户正文。
|
2026-10-05 11:30:56 +08:00 |
|
AskaEth
|
5d4888ab71
|
docs: 历史回填决策落地——做(带三条约束:先 dry-run / 留回滚点 / 一次性不留常驻逻辑),02 给后端请求、05 记决策
|
2026-10-05 11:17:15 +08:00 |
|
AskaEth
|
108feefac4
|
docs(05): 锚点补记六——文档已拆分(含新的文件地图与写回规则)+ <p> 规整上线(5de708a,我方零改动)+ 三件待办 + 一条待主人拍板(历史回填)
|
2026-10-05 11:15:53 +08:00 |
|
AskaEth
|
681ae96a9c
|
docs(02): 收下后端会话的追加(回执十七·补2:<p> 规整已落地 5de708a,写入侧收口、我方零改动)+ 合并 W34 的拆分
|
2026-10-05 11:14:22 +08:00 |
|
AskaEth
|
95e657e704
|
merge: W34 —— 拆分 02/05 两份过长协作文档(历史部分归档,内容零丢失)
|
2026-10-05 11:14:16 +08:00 |
|
AskaEth
|
98b2274e68
|
docs(devdocs): 二次瘦身——02 里已确认完结的登记条目整块归档,05 收回两段纪律
02-前后端协同事项.md:860 行 -> 454 行;02-archive-历史回执.md:1274 行 -> 1681 行
- 把已确认完结的登记条目整块搬进归档:A(A1~A5)、B(B1~B6)、C(C1~C3)、D(D1~D5)、E(E1~E6)、F3~F5、G1~G4、H1,共 8 块 30 条,按原顺序插在归档前言之后、回执一之前
- 主文件保留:标题与用途、顶部 P0 置顶块、归档说明、登记规范、仍未闭环的登记(F1/F2/F6 与 G5)、附:状态一览表、最近 5 条往来
- 唯一改动原文处:状态表 C1 行的指针「见本文 §C1」改成「见 02-archive-历史回执.md §C1」
- 归档前言新增一行,说明这批条目的来路,以及 F/G 两节的主标题与未闭环条目仍在主文件
05-开发进度与交接.md:601 行 -> 624 行;05-archive-历史批次.md:437 行 -> 418 行
- 从归档里捞回两段纪律(本轮新增约定/坑、派 agent 进 worktree 的硬规矩),放进「坑与勘误」节末尾,各加一个小标题
- 锚点节里加一行指针:历史批次的细节已移到 05-archive-历史批次.md
零丢失的证据
- 逐行多重集比对(对基线 b0b73d0):02 的非空行 1560 -> 1568,正好 +8,即新增的说明行;C1 那处是同一行整行替换,一删一增净零
- 05 的非空行 846 -> 851,正好 +5,即 2 个小标题 + 1 行锚点指针 + 2 行归档前言
- 除上述新增行与空行计数漂移外,行集合与原文件完全一致,无任何内容行丢失或重复
- 12 个代表字符串的 grep -c 拆分前后相等,含跨主文件与归档的 pinned DESC 排序串、834ef36、title must not be blank 三条
- 搬迁全程用 sed 按行区间原样切割、未改写文字;行尾统一 CRLF
未跑 Gradle(纯文档搬运),未 push。
|
2026-10-05 11:13:28 +08:00 |
|
AskaEth
|
cb5fc0c605
|
docs(devdocs): 拆分 02/05 两份过长协作文档,历史部分归档,内容零丢失
02-前后端协同事项.md:2126 行 → 860 行
- 历史回执(回执一~十六及同时段已闭环的登记)整体搬到新建 devdocs/02-archive-历史回执.md(1274 行)
- 主文件保留:标题与用途、顶部「待后端处理的 P0」置顶块、登记规范、A~I 全部登记条目、附:状态一览表、仍未闭环的登记(2026-10-04 深夜续那条待主人拍板的口径冲突)、最近 5 条往来
- 置顶块之后新增 5 行归档说明:历史回执去向 / 新回执照旧写在本文末尾 / 已闭环的老登记会定期挪进归档 / 本文旧指针去归档里找对应小节
- 不动置顶块、不动状态一览表、不动未闭环的登记,后端会话仍可照旧在本文末尾追加回执
05-开发进度与交接.md:1035 行 → 601 行
- 历史批次细节与已过时的验收现场记录搬到新建 devdocs/05-archive-历史批次.md(437 行):本轮追加(W4–W7)、W8–W10、W11–W12、W13–W14、W15–W16、装机验收轮、分享到 App 验收轮、W27
- 主文件保留全部「★ 恢复锚点」性质的节(当前状态与待办、锚点补记一~五、纪律补记)与当下要用的节(当前状态、构建、验证方式、凭据处理红线、环境要点、后端、模拟器、易忘的实现约定、管理区约定、C-2 验证结论、坑与勘误、后端协同、实机验收方法、三条待拍板项定案)
零丢失的证据
- 全部搬运用 sed 按行区间原样切割,未改写任何既有文字;新增内容只有 02 主文件那 5 行归档说明与两份归档文件开头的 2 行说明
- 逐行多重集比对(awk 计数 + diff):除上述新增行与个别空行外,原文件与新「主文件+归档」的行集合完全一致,无增无删
- 8 个代表字符串的 grep -c 拆分前后相等:679cee0 3=3、18381f6 1=1、guardrail_input 3=3、internal/config/runtimeconfig/models.go:300-311 1=1、回执十七标题 1=1、devtools/fake-backend/ 4=4、W15-retry-2-EDITED 3=3、ChatImageBudget.SERVER_MAX_CHARS 1=1
- 行尾统一为 CRLF(与 devdocs 下其余文件一致)
未跑 Gradle(本次纯文档搬运),未 push。
|
2026-10-05 11:10:03 +08:00 |
|
AskaEth
|
b0b73d063b
|
docs(05): 补一条硬纪律——合并 worktree 后当场清理(本批 8 个全残留、主人先发现的)+ 假后端进程同样是遗留
|
2026-10-05 10:44:27 +08:00 |
|
AskaEth
|
54e7b35e60
|
docs(02): 回执十七答复——p 半包裁定=b 写入侧规整(我方零改动、历史一起变好);思考流等「在哪开」;⏳ 帧已上线 ⇒ 视为阻塞解除,开始真机验证
|
2026-10-05 10:35:50 +08:00 |
|
AskaEth
|
9e6a6b395c
|
docs(02): 把「标签包裹」从观察升级为明确请求——主人要求问「能不能修」,附三条可选路径与必须的裁定
|
2026-10-05 10:26:43 +08:00 |
|
AskaEth
|
83ea5ddff7
|
docs(02): 请后端确认两件事——思考流(reasoning.kind 在 anthropic 组上非法,开关在协议原生 thinking 形状里,谁决定?)+ 一条回复没拆气泡(产出侧 <p> 包裹不完整,非客户端解析问题)
|
2026-10-05 10:25:28 +08:00 |
|
AskaEth
|
e1223fdee5
|
docs(05): 锚点补记五——忙时并行整批(W28-W33 + FB2)+ 思考流排查结论(reasoning 键在 anthropic 组上非法,开关在协议原生 thinking 形状里,待查)+ 环境与教训
|
2026-10-05 10:23:54 +08:00 |
|
AskaEth
|
c3cfef75bb
|
merge: FB2 —— 假后端加 --hold-replies(收到消息不自动落库),让「忙时被拒」的横幅可稳定复现
|
2026-10-05 10:23:40 +08:00 |
|
AskaEth
|
c73dd532ff
|
feat(devtools): 假后端加上行挂起开关 --hold-replies,让「忙时被拒 / 排队 / 队列满」可稳定复现
为什么
客户端手里还挂着一条没发出去的乐观气泡时,收到「队列满 / 忙时被拒」会怎样,判据是
ChatViewModel.queueFullOutcome(本会话待发 0 条 ⇒ 刻意什么都不说、1 条 ⇒ 回滚 + 横幅、
≥2 条 ⇒ 一个气泡都不动、只提示「可能没发出去」)。但假后端收到上行 chat 或
POST /api/v1/chat 时会立刻把那条用户消息落库并广播 message_persisted,客户端拿
clientMsgId 一比就把乐观气泡收走(consumePendingFor)—— 一个往返之后待发列表就空了,
那三档里只剩「0 条」能凑出来。真后端忙时是先排队或先拒绝、不会立刻落库,所以这是假后端的
建模缺陷,本次修它。
加了什么(只动 devtools/fake-backend/,不碰 app/ devdocs/ 构建文件)
- 启动开关 --hold-replies(默认关):收到上行消息只记录、不落库、不广播 message_persisted。
上行帧照常进 out/upstream.jsonl、state.wsUpstreamByType 照常计数;回复路径
(--stream-chunk / --auto-reply / /__control/reply 自己的 persist)一概不受影响 ——
关的只是「上行那条的落库」这一件事。
- 运行期开关 POST /__control/hold-replies?on=1|0,便于在同一个进程里做「先挂着 → 再落库」两段。
- 显式补落库 POST /__control/persist?session=&id=<holdId|messageId|clientMsgId>&index=N:
不给选择器就按 FIFO 落最旧那条;落库时才盖 createdAt、才认领幂等键(与「队列满的判定发生在
认领之前」一致),广播的 message_persisted 与默认路径同形状。挂起列表在 GET /__control/state
的 heldMessages 里。
- 挂起的消息在挂起时先占号(msg-xxxxxx),所以 REST 回执里的 messageId 与之后真正落库的是同一个。
三处 message_persisted 广播点各自改成了什么
- 原 :657(WS 上行 chat 之后那条)与 原 :1235(REST POST /api/v1/chat 之后那条):受本开关控制,
挂起时整条跳过(仍记录上行帧)。
- 原 :773(send_reply_round 里助手消息的落库)刻意不动:那是回复路径,本来就有自己的 persist
开关,本开关不去关它 —— 任务里也明说不要把回复路径一并关掉。已实跑验证:开关打开时
reply?persist=0 不落库、reply?persist=1 照旧广播 message_persisted。
拒绝场景(任务第 2 项):不需要新造
/__control/pending-full(载荷 {sessionId,mode,max,clientMsgId?,messageId?},与客户端
PendingFullData 逐字对应)、/__control/error?code=chat_busy(带 clientMsgId/messageId)与能发
任意帧的 /__control/frame 早就在了,形状也不缺东西。本次只实跑复核(三帧都在探针那边看到原样帧体、
回执 delivered=1)并写进 README,没有改它们的形状。
默认行为未变
--hold-replies 默认关,state.holdReplies=false、heldMessages=[];关着时三处广播、add_message 的
id 分配(新增的 message_id 参数只在挂起补落库时给值)、REST 回执形状逐字与改动前一致。
验证(本机实跑,端口 18099/18098,绝不用真后端的 10942)
挂起档:ws_probe 发 chat 后 2 秒里零回帧、out/downstream.jsonl 里 message_persisted 计数 0,
而 upstream.jsonl 与 wsUpstreamByType.chat 都记到了;POST /__control/persist?id=local-1 之后才在
活着的连接上收到 message_persisted(delivered=1,id 正是挂起时先占的 msg-000001)。REST 路径同理:
/api/v1/chat 的 202 回执时刻下游流水没有那一帧,2 秒后 persist 才有(时间戳可对)。pending_full /
error{chat_busy} / __control/frame 三帧都投递成功。运行期 on=0 → 立刻落库、on=1 → 挂住,双向有效。
默认档回归:立即落库、灌消息分页、复位都照旧。
README
新增第 6 节「场景四:忙时被拒 —— 让『待发气泡还挂着』可复现(--hold-replies)」:写清乐观气泡被
一个往返收走这个机制、开关怎么用、一条可复制的命令序列(起服务 → 发 chat 挂住 → 注入 pending_full →
期望的横幅文案),以及帧上带不带 clientMsgId/messageId 时客户端四种不同的处置。原第 6-10 节顺延为
7-11 节,并在开关表、控制端点表、记录文件节、已知不确定节各补了对应内容。
|
2026-10-05 10:23:09 +08:00 |
|
AskaEth
|
1f6c16c590
|
merge: W33 —— parallel_limit 提示加「本端确在并行档」的闸 + 查证并发额度横幅(结论:代码无缺,是验证条件问题)
|
2026-10-05 10:13:08 +08:00 |
|
AskaEth
|
f2fbdff9a7
|
W33:并行落回排队的告知加一道本端档位闸 + 忙时被拒那条横幅的只读查证
一、改了什么
加一道闸:pending_ack{reason:"parallel_limit"} 那句降级告知(「她正忙,这条没能另起
一路…」),只在本端当前的「她忙时发消息」档位确实处于「并行」档(InterruptMode.Parallel)
时才给。判据扩了既有纯函数 parallelLimitNoticeFor 的签名(reason + mode 一起判),
本端不在并行档时一律返回 null;调用点在 onEvent 的 PendingAck 分支,档位沿用
currentInterruptMode() 现读(偏好是用户在设置页当场切的东西,缓存住就留一个静默不生效
的窗口),因为 onEvent 不是 suspend 函数,这一句起一个协程去读(与 ensureMessageKnown
同一形态),不起常驻订阅。
二、为什么
pending_ack 是广播帧,每条已认证连接都收得到。极端情况下「别的端在同一会话声明并行
又落回排队、而本端并未选并行」时,本端也会收到这一帧并说那句话 —— 这就是误报。
可那件事与本端无关:用户没选并行时,他这条消息本来就是普通排队、他预期的就是排队
⇒ 落回排队不是意外 ⇒ 不说并不构成静默(与「绝不静默」不冲突)。反过来,在那个时刻
说一句「这条没能另起一路」会让用户以为是自己刚发的那条出了岔子;误报会教用户不信任
这条提示,代价比少说一句大。
三、只读查证(主人点名:忙时被拒那条横幅到底会不会出现)
结论:不缺,pending_full 不带 clientMsgId 时共三档落位,其中两档都写 error,横幅照常
渲染 ——
· 恰好一条待发气泡 ⇒ 回滚档,error = QUEUE_FULL_MESSAGE(她正忙且队列已满,这条没
发出去,请稍后手动重试);
· 两条及以上 ⇒ 说不准档,error = QUEUE_FULL_UNKNOWN_MESSAGE(她忙不过来,这条可能
没发出去),两个气泡一个都不动;
· 两档都带 errorIsQueueFullNotice = true(柔和档配色),横幅由 ChatScreen 同一条
error 通道渲染。
只有「零条待发气泡」那一档是 Ignore,按设计什么都不写:没有气泡会挂住,也就没有要说
的,不能凭空弹一句 —— 理由在 queueFullOutcome 的 KDoc 与 QueueFullOutcomeTest 的两条
测试里(既有,非本次新增)。即「没带 clientMsgId」不等于「说不准档」,本次查证没有
发现需要改代码的地方。
四、测试
新增 3 条(ParallelLimitNoticeTest):追加队列档 + 该 reason ⇒ 不提示;打断档 + 该
reason ⇒ 不提示;再加一条把三档一起摆出来的汇总断言(只有并行档给文案)。「并行档 +
该 reason ⇒ 提示」由既有的那条测试覆盖(现在显式传 InterruptMode.Parallel)。总测试
1030 → 1033,0 失败 0 错误。
|
2026-10-05 10:12:41 +08:00 |
|
AskaEth
|
79fcd803a0
|
merge: W32 —— 忙时被拒那条横幅调柔和(只调那一档,真错误仍红)+ 并行额度落回排队时如实说一句
|
2026-10-05 09:56:22 +08:00 |
|
AskaEth
|
e85209eebb
|
fix(chat): 忙时被拒那条横幅调柔和(只它,真错误照旧红)+ 补 pending_ack 的并行降级告知
一、忙时被拒的横幅改用柔和档颜色(它不是错误,不该刺眼)
改了什么:那句告知(她正忙且队列已满,这条没发出去,请稍后手动重试)一直走 state.error 这一条通道,
ChatScreen 里那条 Banner 的调用点对所有 error 一律用 colorScheme.error(正红)。现在给 ChatUiState
加一位 errorIsQueueFullNotice(只决定配色),忙时被拒的两条入口(WS 广播 pending_full 与定向
error{chat_busy} 的队列满处置、REST 409 chat_busy 的回滚)把这一位写成 true,ChatScreen 那一处调用
据此在 onSurfaceVariantSummary(同文件里分享/附件/定位三条告知用的那档柔和色)与 error 之间选色。
为什么:主人裁决形态不变(摘乐观气泡 + 顶部横幅如实告知 + 正文/图片/附件还给输入栏 + 重发复用幂等键),
但红色不要太醒目——忙时被拒不是坏了,是真没排上,红得像故障会吓人。而它与真错误(登录失效、网络失败、
服务端 4xx、图片与附件处理失败……)共用 error 这同一条通道,直接改那个调用点的颜色会把真错误一并调柔和,
所以先分档再选色:写 error 的每一处都显式带着这一位,边界写进了 ChatUiState.errorIsQueueFullNotice
与 Banner 的 KDoc,免得后来人统一改色。
二、消费 pending_ack 的 reason,如实告知「并行没排上、已按普通排队走」
改了什么:reason 字段在解析层早就原样保留(WsEvent.kt 的 PendingAck.reason),但一直没有任何地方消费它
——收到它时界面与普通排队毫无区别。新增纯函数 parallelLimitNoticeFor(reason)(已知值给一句话,未知值 /
缺席 / 空白给 null,不为不认识的 reason 编文案)、常量 PARALLEL_LIMIT_REASON 与 PARALLEL_LIMIT_NOTICE,
以及状态位 ChatUiState.parallelLimitNotice(与 shareNotice / attachmentNotice 同一套机制:8 秒自动收走、
用柔和色、不给关闭按钮),在 WS 的 PendingAck 分支里接上。
为什么:设置页新增并行档之后,开关 ON 不等于一定起第二路——服务端 CHAT_MAX_PARALLEL_TURNS 小于 2 或
额度已满时会让它落回排队,并回 pending_ack{reason:"parallel_limit"}。用户选了并行却看不出区别就是静默,
违反项目铁律「绝不静默」;而这条不是错误、是降级,所以措辞把三件事说清(并行没排上、这条没丢、她做完
手上这句就轮到它),配色与上面那条忙时告知同一档。
测试:新增 ParallelLimitNoticeTest 8 条(reason 的三档穷举 + 横幅温度的边界:忙时被拒为柔和、真错误不
继承那份柔和)。assembleDebug 与 testDebugUnitTest 全绿:1030 个测试,0 失败 0 错误(基线 1022,新增 8)。
|
2026-10-05 09:55:53 +08:00 |
|
AskaEth
|
bb4bb583d5
|
docs(02): 收下回执十六(形态冲突裁决=调柔和 / turn_id 要加 / 并行开关归前端 / 三处更正经核对均不影响我方 / 文档搬家)+ 我方答复五条
|
2026-10-05 09:51:01 +08:00 |
|
AskaEth
|
4847d74d87
|
merge: W31 —— 设置栏「她忙时发消息」追加第三档「并行」(忙时上行 chat.parallel=true,绝不发 interrupt)
|
2026-10-05 09:38:00 +08:00 |
|
AskaEth
|
a776e9f608
|
feat(settings): 设置页「她忙时发消息」追加第三档「并行」——忙时不打断、另起一路(chat.parallel=true)
主人要求:在既有的「追加队列 / 打断」之下再追加一档「并行」。三档语义互补——追加队列进队列等
(默认档,逐字节不变)、打断先发 interrupt 再发这条、并行不打断、另起一路同时处理。
改了什么:
- AppSettings:InterruptMode 加第三个值 Parallel(wireScope = null, wireParallel = true),并给枚举
补出第二根线 wireParallel(wireScope 只管 interrupt 的 scope,表达不了「要声明并行」这件事)。
既有两个值的 name 与落盘键 interrupt_mode 一个字未动——改名会让已存偏好读不出来并静默回落默认档。
落盘字符串 → 档位的映射抽成顶层纯函数 toInterruptMode(AppSettings 本体要 Android + DataStore,
单测里起不来),新增的第三个名字能正确读回,未知字符串仍回落 AppendQueue(既有行为未变)。
- 判据抽成纯函数 parallelForSend(mode, busy) 与 parallelWireValue(declare):前者与
interruptScopeForSend 共用同一个忙判据(ChatUiState.busyMode != null),只在会话正忙时为真。
规范 §2.2 没写空闲时要不要也声明——空闲时这条本来就是它自己的回合,声明与不声明在服务端是同一
件事,故取与打断同一个判据(最小惊讶:空闲时三档逐字节完全相同)。
- 两条互斥由枚举取值本身保证,不是靠约定:并行档 wireScope 是 null ⇒ 绝不发 interrupt;另外两档
wireParallel 是 false ⇒ 绝不带 parallel 键。默认档「追加队列」线上零差异由此结构性成立。
- 发送路径接线:ChatViewModel.deliver 只读一次偏好、同时喂给两个判据(读两次会在用户切档的瞬间
出现「既打断又并行」);ChatSocket.sendChat 与 ChatRequest 各加一个 parallel 参数,false 一律
翻成整键省略——显式 false 会被序列化成 parallel:false,那是「显式否认」而不是「未声明」,
默认档就不再逐字节不变。
- 设置页:新选项标题「并行」,summary 说清后果——不打断她正在说的这一句、两路各算一遍会多花一份
算力与 token(主人裁决:峰值 token 近翻倍可接受,但用户有权知道)。UI 仍是 Miuix,与同栏另外
两条逐字同形,勾选标记位置相同。
- 分享路径同样不套用这一档偏好(W26 的 allowInterrupt 因此更名为 applySendPreference,它现在管
的是整档偏好):分享是批量投递,逐条索要并行会被服务端 2 路额度把同一次分享劈成两种待遇,且每
条各算一遍;用户也没按过发送键说这条要并行。单张图时同样不声明,规则才能一句话说清。
为什么:主人 2026-10-05 的裁决(真两路并行)需要客户端有一个显式声明入口;W30 已备好 chat.parallel
的线上形状(默认 null = 整键省略),本次只把设置页这一档与发送路径接通,上行仍是纯增量——没选
「并行」的用户线上一个字节都不变。
验收:assembleDebug 通过;testDebugUnitTest 通过(1022 测试 / 0 失败 / 0 错误;基线 1004,新增 18:
ParallelForSendTest 13 条 + InterruptScopeTest 1 条 + InterruptModeTest 4 条)。
|
2026-10-05 09:37:26 +08:00 |
|
AskaEth
|
82d3ca3953
|
docs(02): W30 客户端半边登记(结构性降级保证)+ 一条与规范的形态冲突请后端裁决(被拒消息:红气泡 vs 我方既有的横幅+回滚)+ 三条待确认
|
2026-10-05 09:12:20 +08:00 |
|
AskaEth
|
005720575a
|
merge: W30 —— 忙时并行的客户端半边(六态 + 灰态 + (sessionId,turnId) 两级分桶),帧侧全部可降级
|
2026-10-05 09:11:19 +08:00 |
|
AskaEth
|
29299b190e
|
feat(chat): 忙时并行回合的客户端半边 —— 六态 + 灰态 + (sessionId, turnId) 两级分桶 + 新帧消费(全部可降级)
依据:Cyrene_v2/DevDocs/_specs/忙时并行-规范草案.md(§1 行为 / §2 接口 / §4 主人裁决)。
后端 v0.65 尚未落地这些帧,所以本次的新键与新帧全部按可降级实现:帧不来、字段缺席时,
App 在今天的真后端上行为与改前逐字相同(下面的「降级保证」逐条给了证据)。
一、改了哪些文件
新增(纯逻辑,可单测)
- app/src/main/kotlin/top/yeij/cyrene/domain/chat/ParallelTurns.kt
两级分桶键 TurnBucket(sessionId, turnId) 与 turnBucketOf / closingBucketOf;
六态枚举 TurnPhase 与 turnPhaseOf;灰态收口判据 closesAnswering;
消息↔回合绑定 TurnBinding 及其纯函数(withTurnBegin / withSteeredMessage / closingTurn /
allClosed / bindingOfMessage);路小标文案 legBadgeOf。
- app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt
StreamingLeg(bucket, StreamingState) 与 withLeg / beginLeg / finishingLeg / withoutLeg;
六态在状态上的推导 ChatUiState.phaseOf(消息 / 乐观气泡);
路小标与「跳到提问」的查表 legBadgeForMessage / legBadgeForTurn / jumpIndexForTurn。
修改
- data/api/dto/MessageDto.kt:HistoryMessageDto 增可选 turnId(历史行 / 落库行都用它)。
- data/api/dto/ChatDto.kt:ChatRequest 增可选 parallel(REST 与 WS chat 同形)。
- data/ws/WsOutbound.kt:chat 增可选 parallel、interrupt 增可选 turnId,省略即整键不进线上。
- data/ws/WsEvent.kt:新增 turn_begin / turn.end / steer_ack 三个事件与其载荷;
session_busy / session_idle / pending_drain 增可选 activeTurns,session_interrupted 增
activeTurns + cancelledTurns,pending_ack 增可选 reason。
- ui/chat/ChatViewModel.kt:流式解析器与「本回合见过结构化正文帧」由单值升为按桶的 map/set,
新增「本会话最近见过的 turnId」锚点;消费 turn_begin / steer_ack / turn.end;
助手落库行按其 turnId 收口灰态并只摘掉它自己那一路的流式气泡;排队回执记进 queuedKeys。
- ui/chat/MessageBubble.kt:MessageBubble / PendingBubble 增 phase 与 legBadge,
正文气泡增灰态配色,新增并行路小标(可点即跳);角标文案抽成纯函数 bubbleFooter。
- ui/chat/ChatScreen.kt:按桶取每一路的流式状态并渲染;传入六态与小标;忙碌行加「并行 N 路」。
- 新增 4 个测试文件(共 82 条):ParallelTurnsTest、TurnStreamsTest、
WsEventTurnFramesTest、WsOutboundParallelTest。
二、为什么这么做
- 界面必须按 (sessionId, turnId) 两级分桶(规范 §1.7 第 1 件硬要求):两路并行时两个不同
turnId 的 message.delta / thinking.delta / tool_call 会交错到达,照老代码「一律累加到当前那
一个流式气泡」会把两条回复糊成一条(规范 §1.4 点名的那件事)。现在每一路各有一份解析器、
一份思维链、一份工具卡表、一个流式气泡。
- 灰态(④ 已读、未答)的唯一权威收口事件是 turn.end(规范 §1.2)。本端另外在 stream_end
(既有的「整轮结束」帧,v0.36 起带 turnId)与「同 turnId 的助手落库行」上也收口:
前者在 turn.end 到位之前是唯一能收口的帧,后者是规范给的兜底。三者顺序不定、重复到达都无害
(纯函数幂等)。
- 六态判定与收口判据的不变量都抽成纯函数,可直接单测;判定顺序即证据强度:
被拒 > 已绑定未收口 > 已绑定已收口 > 本地乐观气泡 > 排队回执。
三、字段缺失时的降级保证(本次最要紧的一条)
- 全链路没有一处「turnId 为空就不渲染」。turnBucketOf 在帧壳没给 turnId 时先回落本会话最近
见过的 turnId、再回落匿名桶(turnId 为空串)⇒ 所有无 turnId 的帧落在同一个桶里,
那就是老代码那唯一一个流式气泡。
- turn_begin / steer_ack.turnId / turn.end 不来 ⇒ 一条绑定都建不起来 ⇒ 六态里只可能出现
①②③⑤,灰态根本不会进入;界面与改前逐字相同。
- activeTurns 缺席时不动这个计数(null 是「不知道」,0 是「确实空闲」,两件事不许互相顶替);
pending_ack.reason 缺席就是 null,绝不伪造。
- 上行三个新字段(chat.parallel、interrupt.turnId、ChatRequest.parallel)本端今天一个字节都
不发:真并行要用户显式声明,而声明入口不在本次范围内。服务端照旧走排队 + 回合内 steer。
- 已被单测钉住的证据:
ParallelTurnsTest「所有无 turnId 的帧落进同一个桶」「两个空白 turnId 不算匹配,绝不收口」;
TurnStreamsTest「单路时长度恒为 1」「没有任何 turn 帧时已落库=②、本地气泡=①」;
WsEventTurnFramesTest「老形状的帧解出来新键一律为 null(不是 0、不是空串)」;
WsOutboundParallelTest「省略时那条帧逐字节相同,且不带该键」。
四、等 v0.65 的部分
- turn_begin / steer_ack.turnId / turn.end 的真实到达,历史行的可选 turnId,activeTurns /
pending_ack.reason / cancelledTurns 的真实取值。
- 真并行的触发入口(谁在什么时候把 chat.parallel 置 true)——本次只备好线上形状,不接 UI。
- 仍未决且影响客户端的两项:graph_update 两路互相顶掉(规范未决 10,等 design/19 裁决);
按 turnId 单独核算用量(规范未决 14,可选)。
五、规范里与本端既有定案冲突的一处(如实登记)
规范 §1.1 ⑥「被拒」要求气泡带红色「未送达 · 会话正忙」并可重试;本端被拒(pending_full /
定向 chat_busy)的既有定案是摘掉乐观气泡 + 横幅如实告知 + 正文还给输入栏(可再按一次发送,
幂等键保证不会落成第二条消息)。既有行为由现有测试钉住,本次不改;六态判定里保留了 Rejected
档并由单测直接覆盖,只是本端的现实路径走的是回滚那一支。
验证:assembleDebug 绿 + testDebugUnitTest 绿(0 失败 0 错误 0 跳过)。
|
2026-10-05 09:10:45 +08:00 |
|
AskaEth
|
d0bb43c87c
|
docs(02): 收下后端回执十五(忙时并行规范已定稿 + 主人三条裁决 + 语音切片上线 + Docs 同步中)+ 我方答复:已开工,帧侧全部做成可降级
|
2026-10-05 08:58:44 +08:00 |
|
AskaEth
|
5e6dc76ae8
|
docs(02): 转达主人的第三种「忙时发消息」方案(并行两路)+ 流程要求(开工前先定行为与接口规范,客户端在此之前不动代码)+ 三条 API 文档过期的可核查证据
|
2026-10-05 08:28:58 +08:00 |
|
AskaEth
|
0d50bc82ca
|
docs(02): 收下后端回执十四(IM 文件的 documents 缺口已修 679cee0 / viewer 永久不做 / 语音只差 SILK 样本)+ 我方答复五条
|
2026-10-05 08:26:10 +08:00 |
|