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 |
|
AskaEth
|
ac3f63f8c8
|
merge: W29 —— stream_chunk 改整段语义 + 空壳消息如实说一句 + 分享超长走文件 + 图片 URL 兜 host
|
2026-10-05 08:15:27 +08:00 |
|
AskaEth
|
be1c372d99
|
fix(W29): stream_chunk 语义订正、空壳消息如实说明、分享长正文走文件、图片 URL 兜住 host
一、stream_chunk 由「增量」订正为「整段」(后端回执十三 §1)
后端确认三件事:生产里已无生产者、delta 是整段 res.Text、不会与结构化帧并存。
W26 那版照 message.delta 把 delta 追加进同一气泡是错的——真到达会把同一段内容追加成重影。
改法:同一回合内已经收到过结构化帧(message.start / message.delta)就整帧忽略;否则先用
StreamingContentParser.reset 清缓冲,再把这一整段喂进去,即用它整段替换当前流式气泡的正文。
兜底分支保留(后端明确不建议删:那是未知帧不丢内容的兼容路径)。顺带把「回退路径没有
message.start、所以 freshTurnIfFinished 不触发」这条边界在同一个分支里补上(一行级别的收口)。
二、空壳消息不再渲染成空气泡
主人报的 bug 根因在服务端(微信发来的文件没有挂到消息的 documents 上,content 也是空),
已登记后端;但客户端拿到的确实是一条什么都没有的消息,此前渲染成空气泡,用户只能猜。
新增纯函数 isContentlessUserMessage(role 是 user 且 content 空、无 image、无 documents、
无 thinking 才成立,任何一项有内容都不成立,assistant 一律不成立),成立时在气泡位置改为
一句如实说明「这条消息的内容没有被传过来」:灰底灰字(沿用文件里既有的灰态小字),措辞只陈述
本端确定的事实,不猜原因。判据放渲染层(MessageBubble),因为它纯属显示,服务端说什么就存什么,
不在组装层改 DTO。
三、分享进来的超长正文走文件(主人已拍板)
分享的文本超过现成的 TEXT_AS_DOCUMENT_RUNE_LIMIT 时,落成一份 .txt:写进分享缓存目录、
上传 POST /documents、随 chat.documents 发,正文那一栏留空。判据抽成纯函数
DocumentDelivery.planSharedText(正文是否超阈值 + 附件位是否还有空)。
附件位被分享进来的文件占满(一个回合 5 份)时不做转换:正文照正文发,并在提示里说清
「本想作为文件发送,但附件位已占满」——绝不为了塞下 .txt 挤掉用户明确要发的文件。
落盘失败同样退回内联并说一句。组合与图片的关系:那份 .txt 就落在正文原来所在的那一条上
(第一条带图,其余每条一张)。
明确不动的:用户自己在输入框里打的字那条路,长正文照旧按正文发(devdocs/05 的定案第 3 条),
这两件事不是同一件。
四、历史图片的同源绝对地址按当前 baseUrl 兜住 host(F6①)
POST /upload 的 url 是按请求时的 r.Host 拼的绝对地址且原样落库,换服务器地址后旧图取不回;
后端明确认可客户端做「保留 path、替换 host」。新增纯函数 ImageHostRebase.apply:只认
http(s)://任意host/api/v1/files/一段名字 这一种形态,替换 scheme 与 authority、path 逐字节保留;
第三方 CDN、data:、content:// 与 file://、相对路径、裸文件名、带查询串或百分号编码、多一段路径的
一律原样返回。渲染历史图片前在 ChatScreen 传入 AuthManager.currentBaseUrl,全屏查看拿到的也是
替换后的地址。逆作用写进 KDoc:换了指向不同数据的 host 会 404,那是诚实的失败,比永远取不回好。
不改落库数据、不改上传、不碰 documents。
测试:新增 ImageHostRebaseTest 与 ContentlessMessageTest,DocumentDeliveryTest 补分享长正文的判据;
stream_chunk 那条分支是 VM 里的状态机分支,没有硬抽成纯函数(不为测试拧弯架构),改动写在 KDoc 里。
|
2026-10-05 08:14:33 +08:00 |
|
AskaEth
|
c0e78371cb
|
docs(05): 锚点补记四——W28 共享 socket(含装机验收证据与两个固有代价)+ 假后端测试台 + 一条硬教训(问一句比猜一天值)+ 登录页 IME 结案
|
2026-10-05 08:08:19 +08:00 |
|
AskaEth
|
7b97440973
|
merge: W28 —— 进程内共享一条 ChatSocket(连接数 N+2 → 1),SessionsViewModel 为唯一 owner
|
2026-10-05 08:03:50 +08:00 |
|
AskaEth
|
276b01113b
|
refactor(ws): 进程内共享一条 ChatSocket,访问 N 个会话不再留 N+2 条连接
改了什么
- AppContainer:删掉「每次调用都新建」的 createChatSocket(),改成进程内唯一的 val chatSocket
(by lazy)。三个调用方全部改取它,工厂方法一并删掉——不留「还能再造一份」的入口。
- ChatViewModel / SessionsViewModel / AdminViewModel:原本各自建一条连接,现在都改为订阅同一条。
- 连接的开与关收敛成唯一一处主人:SessionsViewModel 的登录态订阅
(LoggedIn 则 connect()、LoggedOut 则 close())。
* ChatViewModel 不再 connect()(它随切会话就被销毁,不该掌握连接的生死);
* AdminViewModel 不再 connect() / close(),包括原来「viewer 时关掉自己那条连接」那条分支——
这三帧服务端本就 fail-closed,本类另有逐帧角色门,断开连接多挡不掉什么,却会掐掉别的屏幕。
* AdminViewModel 登出仍然只丢日志基线(行为不变)。
- 三个 VM 的 onCleared 一律不再 close(共享连接不能被任何 VM 关闭,否则切会话 / 关管理页会把
主会话流式、抽屉未读、管理页三帧一起掐断)。
- KDoc 同步改:AppContainer(为什么可以共享 / 为什么必须唯一 / 开与关的主人是谁)、ChatSocket
(它不是某个屏幕的私有连接、谁都不许 close;events 的 replay 恒为 0 从「安全」升为「必需」)、
三个 VM 的类注释与 onCleared。
为什么行为不变
- 服务端广播、不按会话订阅:改前 N 条连接各收全部帧,改后一条连接收全部帧,每个 VM 收到的帧集合
逐帧相同——这是本次重构安全的唯一依据。isForSession、各事件分支、重连与退避逻辑、下行帧语义、
UI 一个字都没动。
- baseUrl / token 每次重连现读,connect() 幂等(wantConnection 已置位即返回),所以登出重登、
换账号都不需要重建实例。
- 连接数量:改前访问 N 个会话 = N+2 条连接、N+2 个 OkHttpClient(每个连接自带一个),改后恒为 1 条。
如实记一处非逐帧等价的细节(已核过,无功能影响):改前每条连接各自握手,服务端每次都会回一帧
history;改后只有首次连接会收到。晚于连接建立才创建的 VM 因此少收一帧 history,而该帧对非主会话
经 historyForSession 过滤后本来就是空(只用来收掉 loading),对主会话与 REST 首屏是同一份内容的
冗余来源,且新 VM 的 pending 为空、reconcilePending 是空操作。
验证:assembleDebug + testDebugUnitTest 全绿(891 个用例,0 失败 0 错误 0 跳过)。
|
2026-10-05 08:03:09 +08:00 |
|
AskaEth
|
27d5680d21
|
merge: FB —— 可编程假后端测试台(devtools/fake-backend/,纯 Python 标准库),解锁 after 补洞 / 打断帧 / stream_chunk 三条端到端验证
|
2026-10-05 08:03:03 +08:00 |
|
AskaEth
|
86a2217858
|
feat(devtools): 新增可编程假后端(devtools/fake-backend),用来造「特定服务端行为」的端到端场景
为什么要它
客户端有三类行为只在特定服务端行为下才验得到,而真后端不可编排、造场景的代价高到不可做:
1. after 正向增量补洞:要造「WS 握手只回放最近 50 条、库里却有 120 条」这个洞,用真后端得灌 51 条消息、
每条跑一次模型回合。假后端可以精确、免费、可复现地造出来(--history-replay N)。
2. 打断帧:客户端在「设置=打断 + 会话正忙」时会发 interrupt{scope:"current"}。要验它真的发出去了,
就得看上行帧——假后端把每条上行帧原样记进 out/upstream.jsonl,这是唯一的第一现场。
3. stream_chunk 回退路径:真后端可能永远不走那条路,假后端可以用 --stream-chunk 指定走。
加了什么
- devtools/fake-backend/fake_backend.py:纯 Python 3 标准库(无第三方依赖)。实现了客户端启动到渲染
用得到的那一段 REST(login/refresh/logout、sessions 列表与增删改、messages 分页与搜索、read、
messages 编辑删除、meta/enums、personas、chat)加一个手写 WebSocket(GET /ws/chat?token=…,
握手后立刻发 history 帧)。鉴权头 X-API-Version / Authorization 一律不校验,但会记进 out/http.jsonl
(Authorization 与 ?token 只留 sha256 指纹)。
- 控制端点 /__control/**(不碰 /api/v1/**):灌消息(不调模型)、下发 session_busy/idle/interrupted、
触发一轮回复、pending_full、定向 error、任意帧、踢掉客户端 WS、404 session_not_found、复位、看状态。
目的是让「造场景」与「看结果」都不需要人手点 UI。
- devtools/fake-backend/ws_probe.py:最小 WebSocket 探针(标准库手写握手 + 帧收发 + ping),
用来证明握手返回 101 / 收到 history 帧,并往连接上发任意上行帧对账。
- devtools/fake-backend/README.md:起法(Windows + Git Bash)、客户端怎么指过来(adb reverse +
http://127.0.0.1:18080)、每个场景的可复制命令序列、端点速查、形状出处表、以及「它不做什么」。
边界(README 里也写明了)
不校验鉴权、不落盘成持久数据、不调模型、不是产品的一部分、不实现契约全集(admin 面、附件上传、
observability 推送等一律 404),并且不要拿它当后端行为的事实来源——所有响应形状都是照客户端代码
读出来的(README 第 9 节逐条列出文件:行号),不代表真后端真的这么回。
默认端口 18080,刻意避开真后端的 10942。
形状来源(举几个关键例子):信封 ApiEnvelope.kt:8;鉴权头 ApiClientFactory.kt:115-118;
WS 地址 ChatSocket.kt:287-295;SessionSummaryDto 与排序 SessionDto.kt:16-50 加线上夹具
SessionDtoRealPayloadTest.kt:36-38;after 升序 / before 倒序 / q 与 after 互斥 SessionsApi.kt:71-101;
limit 默认 50 上限 200 MessageRepository.kt:149-150;枚举表逐字取自 MetaDtoRealPayloadTest.kt:24;
404 session_not_found ChatViewModel.kt:1802-1808;stream_chunk 回退 WsEvent.kt:205-228。
验证(本机实跑,输出见提交后的报告)
curl 打通 login/sessions/messages(limit/before/after/q 与三个 400 互斥面)、meta/enums、personas;
ws_probe 握手拿到 101 与匹配的 Sec-WebSocket-Accept,并收到 history 帧;灌 120 条后握手只回放最近 50 条
(msg-000071..msg-000120),用 after 游标正向走两页正好补回 msg-000021..msg-000120、第三页为空;
--stream-chunk 下下发的是 stream_chunk×3 + stream_end(没有 message.* 帧);打断帧原样落进
out/upstream.jsonl;ping/pong 与 HTTP keep-alive 均正常。
|
2026-10-05 08:01:48 +08:00 |
|
AskaEth
|
a0085802dd
|
docs(02): 把「微信文件任何客户端都看不到」提到文档最顶部的 P0 置顶块(主人亲自报的 bug)+ 补一个必须说清的分叉:字节到底落盘了还是只内联进上下文
|
2026-10-05 07:58:20 +08:00 |
|
AskaEth
|
ae49853029
|
docs(02): 登记新需求——IM 渠道的语音消息要能被读到(前端那半我们做,IM 那半请后端比照 IM-1 做 ASR 入站)
|
2026-10-05 07:54:55 +08:00 |
|
AskaEth
|
691b073e32
|
docs(02): 回应后端回执十三(stream_chunk 改整段语义、F6① 客户端兜 host、viewer 摘项)+ 登记一条新 P0:IM 渠道的文件没有挂到消息的 documents 上
|
2026-10-05 07:54:45 +08:00 |
|
AskaEth
|
6c2cc57cdb
|
docs(02): 状态表维护(C1/C3/D4 早已闭环)+ 新增一张「待后端回应」清单
主人早上问「需要后端做的事都写进文档了吗、本地还有没有独立事件」,我借这次盘点把协作文档收口:
1. **状态表维护**:C1(语音轮次不落库)、C3(voice_output 字段)、D4(无未读状态)三行原写
「待确认」,其实**早已解决**——C1/C3 由契约 v0.59 §3.15(语音改走与文本同一条编排路径、
voice_output 补 sessionId/deviceId),D4 由 v0.37(unreadCount + 已读游标,客户端已接入)。
按正文依据打勾,并写明「客户端至今没有语音 UI,所以 C1/C3 我们从未验证过」这一诚实边界。
F6 那行从「待确认」改成「问过、后端尚未回应」,让「谁在等谁」一眼可见。
2. **新增「客户端待后端回应的清单」**(文档末尾):只列我们问过、还没拿到明确答复的七条——
stream_chunk 三点确认 / F6① upload url 绑 host / viewer 凭据路径或确认永远 501 /
并行 tool_use 配对的 P0(他们说在修)/ llm_usage.session_id 接线(同上)/
IM 入站文件的真机验证(只能主人做)/ 以及这次状态表维护本身请他们复核。
每条都写了「要什么」与「我们这边的处置」:例如 F6① 明确**不做绕行**(不猜 base、不重写历史
URL——那会把「服务端说是什么」变成「客户端以为是什么」);viewer 那条给了二选一
(给铸造路径,或明确永远 501,那我们就不再把它当验收项挂着)。
本次提交只动文档。
|
2026-10-05 07:41:27 +08:00 |
|
AskaEth
|
219c50b7c2
|
docs: 自主推进一批的登记(假开关 / 静默丢正文 / 分享自打断 全修完 + 一条口径冲突要主人拍板)
主人睡前说「按主线继续推,或者先按模块检查」,我把两件都做了:先做了一次只读自审
(假开关 / 死代码 / UI 规范 / 文档漂移,四张带「文件:行号」的清单),然后按清单修了一批。
本批全部是「界面上或文档上声称能做到、代码里其实没接线」这一类问题。
devdocs/05:新增「锚点补记三」,写清——
- 本批 5 条落地(W25 打断模式真接线 / W26 stream_chunk 不再静默丢正文、错误横幅可关闭、
分享不再自相打断、两处死代码 / W27 删只被测试养的 API、公式去重、03 四处更正 /
WsEvent KDoc 更正);
- 三个「假开关 / 静默」的真相与各自的修法;
- 两条查完发现不是 bug 的(「抽屉最后消息预览不刷新」与「session_interrupted 丢弃 N 条」),
附证据,免得下次返工;
- 装机验证:错误行的关闭叉验通(x2/x3 截图),以及一条我刻意没验的(登录页 IME ——
要清空 App 数据才能回到登录页,万一登不回去今晚所有装机验证能力一起没了,风险不对称);
- 本批没做的:连接所有权(共享 socket)只写设计要点与七条验收清单,不动手;
DocumentDelivery 那组「正文转文件」API 不删也不接(口径冲突);
- W26 留下的一条边界(回退路径没有 message.start,freshTurnIfFinished 不触发)。
devdocs/02:追加客户端登记三条——
1. 请后端确认 stream_chunk 今天还会不会发、delta 是增量还是整段、会不会与结构化帧并存
(我们按契约的「非流式回退路径」实现,但那条路可能已经名存实亡);
2. 告知我们开始发 interrupt{scope:"current"} 了(窄触发条件、默认档零变化、分享不打断、
以及「两条道无强序」这个我们坦承的边界);
3. 登记「超限走文件」的口径冲突(05 的定案说做的是「用户打的字自动变附件」不做,
主人后来点名要做 —— 两者多半不是一件事),不替主人合并,所以那组 API 原样留着。
本次提交只动文档,无代码改动。
|
2026-10-04 22:40:43 +08:00 |
|
AskaEth
|
85a922c01b
|
docs(ws): 更正 SessionInterrupted.dropped 的过时 KDoc(本端全部路径下它恒为 0)
原来的注释写「当前任务取消、pending 清空,dropped 为丢弃条数」——那是 `interrupt` 只有一种形状
(等价于今天的 `scope:"all"`)时的口径。契约 v0.56 起 `scope:"current"` 只取消在跑回合、**保留**
待处理队列,该档 `dropped` 恒为 0。
而本端「追加队列」档一个 interrupt 都不发、「打断」档发的是 `current`(见 feats/W25,cfdc094),
所以 `dropped` 在客户端的全部现实路径里恒为 0 —— 「已打断,丢弃 N 条」那句话在本端根本不可达。
注释里写明这一点,免得后来人照着一句不可达的文档去实现一个用不上的计数。
只改注释,无行为变化。
|
2026-10-04 22:40:07 +08:00 |
|
AskaEth
|
b7ac6b8853
|
merge: W27 —— 删只被测试养的 availablePages + 图片 base64 上限公式去重 + 03 四处文档更正
|
2026-10-04 22:37:25 +08:00 |
|
AskaEth
|
3c0ed16ee7
|
merge: W26 —— stream_chunk 不再静默丢正文 + 错误横幅可关闭 + 分享不再自相打断 + 两处死代码
|
2026-10-04 22:37:24 +08:00 |
|
AskaEth
|
eacc65acc9
|
fix(chat): 四件可见修复 —— 回退正文不再丢 / 错误横幅可关 / 分享不自杀 / 清两处死码
1) stream_chunk 不再被静默丢掉(整段正文丢失风险)
- ChatViewModel.onEvent 新增 WsEvent.StreamChunk 分支,把 delta 照 MessageDelta 的同一形态喂进
流式解析器。
- 依据:后端契约写明「未映射/无法确定的事件仍走 stream_chunk + stream_end(非流式回退路径)」
(Cyrene_v2/Docs/api-client.md §3.1)。原先它落到 onEvent 末尾的 else -> Unit 被静默吞掉 ——
服务端一旦走进回退路径,整段回复正文一个字都不显示,而且没有任何信号说明正文丢了。
- stream_chunk 不带 index(那是 message.start 的字段),所以不读 index;解析器与 MessageDelta
一样按需创建,按「同一个当前流」处理。
- 未改 WsEvent 的解析形状,只加消费分支。
2) 错误横幅可以关掉了(两个 dismissError 不再是死方法)
- ChatViewModel.dismissError / SessionsViewModel.dismissError 原本全仓零调用方,现各接上关闭
入口,并各补 KDoc 写明边界:清掉的是本端已经显示的那条错误,不代表服务端那边没事;不做自动
消失。
- ChatScreen.kt 的 private Banner 增加可选 onDismiss,有它时右侧挂 MiuixIcons.Basic.Close
(带 contentDescription);只有 state.error 那条传 viewModel::dismissError,分享 / 附件 /
定位那几条如实告知保持原样(它们自己会收走)。
- SessionDrawerContent.kt 的错误行改成行内布局 + 右侧关闭按钮,样式随它已有的行(footnote2、
同一水平内边距、同款 IconButton 灰度);新增参数 onDismissError,由 ChatHomeScreen 接
sessionsViewModel::dismissError。
- 错误内容本身、错误出现的时机、是否自动消失,一概未动。
3) 多图分享不再互相打断
- 根因:W25 把「她正忙时按发送」接到发送路径后,deliver 每次投递前都会发一条 interrupt 帧
(scope=current);而分享 N 张图 = N 次投递,第 2 条起就把前一条刚起的回合取消掉了,
最后只有最后一张被回答。
- 改法:emitAndDeliver 增加 allowInterrupt: Boolean = true(默认值保持现状),deliver 按它决定
是否调 interruptRunningTurn();sendShared 的两条投递都传 false。
- 只有一张图也传 false:分享是「一次把东西倒进去」的批量动作,自己打断自己一定是错的;
「分享从不打断」是一句话,「超过一张才不打断」得先解释那个分界。
- 单条发送(send)的口径一字未动。
4) 两处死代码
- 删掉 ChatViewModel.clearDraftDocuments()(复核为全仓零调用方)。没选「在输入栏加清空全部入口」:
附件芯片本来就每枚带叉、上限只有 5 枚,逐个移除已经覆盖全部场景;输入栏里再加一枚清空按钮是
噪音,而留着一个没人接的公开方法只会让人误以为它是活的。
- 删掉 ChatScreen 的 onLogout 参数(函数体内一次都没用到)与 ChatHomeScreen 的对应传参。
真正的登出路径(CyreneApp -> ChatHomeScreen -> SettingsScreen)原样可用。
验证:assembleDebug 与 testDebugUnitTest 均 BUILD SUCCESSFUL;单测 892 条、0 失败 0 错误、0 跳过
(本次未新增测试:① 的判定是 when 的一个分支、抽不出自然的纯函数,不为测试把架构拧弯)。
|
2026-10-04 22:36:47 +08:00 |
|
AskaEth
|
805d8b530a
|
refactor+docs(W27): 删掉只被测试养着的 AdminAccess.availablePages,让 ChatImageBudget 的公式只留一处,并就地更正 devdocs/03 四处过时表述
清理(零行为变化)
- 删 domain/admin/AdminAccess.kt 的 availablePages():全仓(含 app/src/test)调用点 0 处 ——
main 里只有它自己的声明、测试里 2 处(AdminAccessTest 那 1 条用例一并删掉)。它早已被
ui/admin/AdminHomeScreen.kt 的做法取代:遍历 AdminPage.entries + 每行 canAccess 决定是否禁用。
- ChatImageBudget.SERVER_MAX_CHARS 原来把 base64Chars 的 ((n+2)/3)*4 又内联重写了一遍,
改为 base64Chars(SERVER_MAX_DECODED_BYTES) + PREFIX_ALLOWANCE_CHARS(一处公式、一处真相)。
代价:该常量由 const val 变 val(const 不允许调用函数)。取值逐位不变 = 6990572,由
ChatSendPolicyTest 的独立重算断言与 ChatDtoRealPayloadTest 对服务端真实文案的断言继续钉住。
文档(devdocs/03,一律删除线保留原文 + 更正括注,不动别人的回执段落)
- §4 重连补全:02/A5 的 after 正向增量已接入(loadAfter + ChatViewModel.catchUp,28c3142)。
- §4 忙碌/队列 UI 映射:客户端只发 scope:"current",该档 dropped 恒为 0、all 从不发,
所以「已打断,丢弃 N 条」在今天的客户端里不可达。
- §5 mode 回落:mode 已落库(后端 b686d5f),MessageBubble 按 mode 分支。
- §4 打断模式:补上 W25 接线的依据 cfdc094 / feat/W25-interrupt-scope。
devdocs/05 追加 W27 小节;另记下 data/ws/WsEvent.kt:272 的同类过时 KDoc(不在本轮允许改动的
文件清单里,未动)。
验证:assembleDebug 通过;testDebugUnitTest 891 条 / 0 失败 / 0 错误(改动前 892,删掉 1 条)。
|
2026-10-04 22:35:20 +08:00 |
|
AskaEth
|
e4c6c7c72a
|
merge: W25 —— 把设置页的「打断模式」真正接到发送路径(默认档零行为变化)
|
2026-10-04 22:31:52 +08:00 |
|
AskaEth
|
cfdc094c30
|
fix(chat): 设置项「她正忙时按发送」不再空转 —— 发送前按偏好决定是否先打断
设置页那个开关此前没有任何发送路径读它:interruptMode 全仓只出现在 AppSettings
(定义 / 读写)与 SettingsScreen(渲染文案),于是把它切成「打断」后行为与「追加队列」
一模一样,ChatSocket.sendInterrupt 更是零调用方(死 API)。这正是一直挂在文档
devdocs/02 协同项 A2 上的欠账(后端 v0.56 已支持 scope,客户端没接)。
现在按契约 v0.56 §3.1 接上,判据抽成与发送路由同类位置的纯函数
interruptScopeForSend(mode, busy: Boolean):String?
1. 追加队列(默认档)⇒ 恒定返回 null,一个 interrupt 帧都不发,线上请求与今天逐字节相同;
2. 打断(wireScope = "current")且会话正忙 ⇒ 这条消息之前先发 interrupt{scope:"current"}
(只取消在跑回合、保留待处理队列,队列里的消息按编排器续跑,一条都不丢);
3. 打断但会话空闲 ⇒ 不发(命中空闲会话是 no-op,白发一条帧只多一次往返,还会在 logcat
里制造「这里打断过」的假象)。
「正忙」只认服务端 session_busy 帧给的 busyMode != null,不用 queueDepth——队列深度
非零不等于有回合在跑(pending_ack / pending_drain 也会动它)。
接线点在 ChatViewModel.deliver() 的通道分支之前(app/src/main/kotlin/top/yeij/cyrene/
ui/chat/ChatViewModel.kt)。放在那里而不是 emitAndDeliver 开头是刻意的:上面每一段本端
拒绝(附件超限 / 图片处理失败 / 附件上传失败 / 引用不合法)都会「回滚、什么都不发」,
先打断她的回合再什么都不发是凭空的破坏。每次投递只经过那里一次,所以一次投递最多一条
打断帧,任何后续分支与重试都不会重复发。
偏好每次发送现读(不缓存、不订阅 Flow):「在设置页切成打断、然后立刻发的那一条」正是
本次要修的场景,缓存值要等 Flow 推到才更新,冷启动那一下必然没到——那就等于留了一个
静默不生效的窗口。读失败(IO / 损坏)回落默认档,也就是最少动作的那一档,与 AppSettings
把未知取值解成默认档是同一条纪律。
打断帧发不出去(只有「WS 未连接」一种原因)静默、不阻断本次发送:本次消息的成败与它
无关(REST 通道不看 WS 连接),且一直有既有的回执 / 回滚路径如实上报;反过来「打断失败」
没有对应得上的动作(重试等于把消息再发一遍,正是幂等键在防的事),喊一声只会误导。
于是打断没生效 = 行为退回默认档,是用户看得懂的状态,不是被藏起来的事。
测试:新增 InterruptScopeTest 8 条(两档 × 忙闲四格穷举 + 默认档零行为变化 + 偏好回落
方向),全仓 884 → 892。assembleDebug 与 testDebugUnitTest 全绿(0 失败 0 错误)。
设置页那两条文案原本就与 scope:current 的真实语义相符(「已排队的消息保留,继续依次
回答」),未改一个字。
文档:02 的 A2 状态表、05 的 A2 条目都写着「客户端尚未接入该字段」;03 里「打断模式 →
UI 限制一次只发一条」的前提(interrupt 会清空 pending)也随之失效,三处一并补记更正。
|
2026-10-04 22:29:57 +08:00 |
|
AskaEth
|
b8f0833137
|
docs: 收下后端回执十二 + 登记本轮三条接入结论 + 05 锚点补记二(含两条新教训)
devdocs/02(与后端会话共用的协作文档):
- 收下他们的**回执十二**:knowledge 的根因(参数角色缺席 + 收尾静默分支)与修法、并行
tool_use/tool_result 配对的新 P0(客户端不需要适配)、契约 v0.64 的只读
GET /admin/llm/usage、IM 入站文件「能拿到字节」(真机验证只能由主人做)、
llm_usage.session_id 恒为 NULL。
- 追加**我方登记**:v0.63 那两条(reason / 404)都已接入并推送;after 正向增量的两条口径;
及一条要他们确认的**我方主动口径** —— unreadable 且 mime 是 text/ 时让 reason 压过正文渲染。
- 他们点出的三处旧口径(1049/1110/1309 行)我核过,**都在他们自己的回执段落里**,按同一条规矩
请他们自己更正,我们没有动手。
devdocs/05(我方进度与交接):
- 锚点更新:HEAD、884 测试、本轮 5 条落地、待办重排、家务办完。
- 新增「本轮未验证的」一节:把**验过的**与**没验过的**分开写(404 兜底验通;after 只验到「真请求
打出去了」、补洞端到端仍未验;W24/W23 的设备侧只验到能开的程度)。
- 两条新教训:① 那个「未响应弹窗」是模拟器/宿主机被拖垮(日志里系统自己的
PointerEventDispatcher 卡了 37 秒),uiautomator dump 要省着用;② **一次性告知不能挂在会被销毁的
状态上** —— 404 兜底第一版就是这么悄悄失效的,只有真机看一眼才能发现。
本次提交只动文档,无代码改动。代码侧本轮四个提交(28c3142 / 1794e53 / b5c0b13 / 2d382b1)都已推到
origin/android。
|
2026-10-04 22:22:51 +08:00 |
|
AskaEth
|
b5c0b13210
|
fix(chat): 「会话已删」那句解释必须由不会被销毁的那一层持有
真机验收 404 兜底时发现的真问题:**切换本身是对的,但用户看不到任何解释**。
- logcat 链路完整:`GET /sessions/web:{已删}/messages?limit=50` → `404` →(约 0.9 秒后)
`GET /sessions` → 界面切回主会话。也就是说「退回主会话 + 如实说一句」里的**前半句成了**。
- 但**后半句一个字都没显示**:那句横幅原来挂在 `ChatUiState.sessionGone` 上,而
`onSessionGone` 一被调用,`activeSessionId` 就被收敛回主会话、`ChatScreen` 的 `key` 随之变化,
承载它的那个 `ChatViewModel` 连同状态**立刻被销毁** —— 横幅来不及组合出来就没了。
(这正是「把一次性告知放在会被销毁的状态里」的经典坑,值得单记一条。)
改法:
- `ChatUiState.sessionGone` 保留,但只当**触发信号**(KDoc 已改写说明这一点);
- 文案由 `ChatHomeScreen` 的 `remember` 持有(它活得比会话 VM 久),通过新的
`sessionNotice: String?` 参数注入 `ChatScreen`,仍然走页面上那同一排 `Banner`,
8 秒后自己消失(它描述一次刚发生的动作,不是持续状态);
- `onSessionGone` 里**先**立起告知、**再**刷新列表——顺序反了同样什么都看不到。
验证:装机复测通过,红字横幅如期出现在主会话页顶部
(证据 `app/build/e2e-shots/w7-gone-2.png`),logcat 链路 404 → `GET /sessions` 完整。
assembleDebug 与 testDebugUnitTest 全绿(884 测试 / 0 失败,与改动前同数:本次只动了接线与文案,
没有可新增的纯函数)。
|
2026-10-04 22:20:59 +08:00 |
|
AskaEth
|
1794e53d1c
|
merge: W24 —— 编辑弹层只显示纯文本(原文含标记时如实告知会被替换,绝不静默丢旁白)
|
2026-10-04 22:08:08 +08:00 |
|
AskaEth
|
2d382b11cd
|
merge: W23 —— 接入契约 v0.63 的 documents[].reason(只在下发时原样显示,不做整句判断)
|
2026-10-04 22:08:06 +08:00 |
|
AskaEth
|
66c2291312
|
feat(chat): 编辑框只显示纯文本 —— 真有标记时才剥,且弹层明说会替换掉什么
主人拍板:编辑单条消息时输入框里只放纯文本,不再让用户对着 <p> / <action> / <narration> 改字。
红线是「不许静默丢东西」,所以这次不是「把标记一剥了事」:
- 新增纯 Kotlin 模块 domain/chat/MessageEditText.kt(无 Android / Compose 依赖)。
原文不含已知标签时逐字节恒等:不 trim、不折叠连续空行、不换换行符;含标签时用
domain/parse(项目里唯一的解析器,不另写正则,免得两套口径分叉)按原始片段顺序拼出可见纯文本,
正文与旁白/动作一并保留,气泡之间空一行;一个气泡都解析不出就回落成原文(宁可不剥)。
hasMarkup 只在「保存真的会替换掉什么」时为 true。
- MessageActionState.Edit 新增 markupReplaced;startEdit 只算一次 editableMessageOf
(不放进 state.update 的 lambda 里),draft 拿纯文本、markupReplaced 拿原文带不带标记。
- MessageEditDialog:markupReplaced 为 true 时在计数行下方多一行小字,写明「保存后这条消息只剩
你写的这段文字,原来的旁白与动作会被替换掉」;为 false 时一行都不多显示。计数与保存禁用逻辑不变。
- 保存提交的就是 draft 本身,不把标记猜着装回去:任何回填都要先猜「旁白该插回哪一句之间」,
猜错就是静默移动她说过的话,用户还看不出发生了什么(理由写在 submitEdit 与弹层的 KDoc 里)。
- 新增 MessageEditTextTest(21 例):纯文本恒等(空串、首尾空白、连续三个换行、CRLF、
以及 1 < 2 / <div> / <P> / <p2> 这类「有尖括号但不是标记」的普通文本)、线上真实标记串、
纯旁白、带属性的 p 标签、head/tail 旁白的先后顺序、气泡间只空一行、畸形未闭合不丢字。
为什么这仍然值得做:主会话 64 条 user 消息全是纯文本,所以对今天的真实数据这是一条恒等变换——
它是防御性口径,不是修一个正在发生的 bug;也正因如此,纯文本路径必须逐字节不变。
|
2026-10-04 22:07:40 +08:00 |
|
AskaEth
|
e4915a6904
|
docs(02): 收下后端回执十一(四点确认 + knowledge 缺陷已立案 + IM-1/U1 在实施)
后端会话直接写进了本机 devdocs/02(与主树共用同一份文件),这里把它落成一次提交,
免得长期挂在工作区、也便于将来对时间线。
回执十一的四点:sess-* 不加 404 例外(与我们的答复一致);documents[].reason 他们不会擅自改文案
(要改会同时升契约并回执);404 session_not_found 的用法确认;日期更正已互相认账。
另两条与我们相关的:
- 我们登记的 knowledge 工具失败(E2E-DOC 那条,纯文本也复现)他们已按更正后的口径立案排查,
修复推库后会补一条回执(含根因文件:行)。他们同时要求「无论回合因何结束都落一条可见的
assistant 消息」,边界保留:被 interrupt 取消 / 被 stale gen 取代 / 静默内部流程仍不落库。
- IM-1(各适配器入站内联受支持格式的文件字节)与 U1(llm_usage 落库 + 只读汇总端点
GET /api/v1/admin/llm/usage,后续契约 v0.64)在实施中 —— 后者对我们只有将来做管理页时才算增量。
本次提交只增文档,无代码改动。
|
2026-10-04 22:01:01 +08:00 |
|
AskaEth
|
28c314262e
|
feat(chat): 接上 A5 正向增量 after,并用 404 session_not_found 把「会话已删」如实说出来
改了什么:
- SessionsApi.listMessages 加可选 after(契约 §1 行 6)。before / q / after 三者互斥是服务端的
硬约束,本端三个调用点各只给一个;after 为 null 时 Retrofit 整个省掉这个参数 ⇒ 既有三条路径
的线上请求逐字节不变。
- MessageRepository 加 loadAfter:同一个端点,服务端按【升序】返回(与 before 的倒序相反),
仍统一转成升序的 PagedMessages。顺手去掉 toPagedMessages 参数名里那个已经不对的 newestFirst。
- ChatViewModel:
· 新增 newestKnownAt(从状态现算的 after 游标)与 absorb(只并消息与乐观气泡,不碰
loading / loadingOlder / hasMore / error —— 一次静默补拉不该把上拉位置和错误横幅掀掉)。
· 掉线那一刻记下游标、重新连上时用它做一次正向增量。记录时机必须在「掉线」而不是「重连后」:
重连的 history 帧会把最近 50 条灌进来、游标随即被推到「现在」,那时再取就取不到那个洞了。
「再拉一页最新」永远补不回中间断掉的那一段,这正是 after 存在的理由(02/A5 的欠账)。
· ensureMessageKnown 先用增量(只要新来的那几条),还认不出来才退回整页重拉。
· 循环推进抽成纯函数 loadForwardPages:游标必须严格前进(同毫秒并列且超过一页时推不动,
契约 §A5 明写的已知边界)+ 轮数上限 20,两道防死循环的闸都由单测钉住。
- 404 session_not_found(契约 v0.63):首屏 / 上拉 / 增量三条读路径都判,非主会话就置
ChatUiState.sessionGone;ChatScreen 据此显示一句实话并回调 ChatHomeScreen 重拉会话列表
(refresh 内部会把「指向已删会话」的 activeSessionId 收敛回主会话)。
主会话不置位:那说明部署本身出了问题,该显示的是普通错误横幅,而不是一句假的「已切回主会话」。
为什么:
主人那次「图片凭空消失、别的端也看不到」的真相,就是会话在别处被删掉之后,客户端把它渲染成了
一条空会话 —— 让用户对着空白猜。现在有了机器可读的 404,就能如实说一句并把人带回主会话。
另一半是重连补洞:一直是「再拉一页最新」在顶着,断线期间超过 50 条必丢中间那一段。
验证:assembleDebug + testDebugUnitTest 全绿,846 测试 / 0 失败 / 0 错误。
较基线 831 新增 15 条(MessageAfterRepositoryTest 5 条走真 OkHttp 断言线上 URI;
ChatRecoveryTest 10 条钉住 404 判据与增量循环的两道闸)。
设备侧验证(点开看到那句实话、断线补洞)另行做,不在本次提交的断言范围内。
|
2026-10-04 22:00:55 +08:00 |
|