42 Commits
Author SHA1 Message Date
AskaEth 664fd8dcef merge: W3 — B1 /upload(大图走上传,历史只留 URL) 2026-10-04 17:44:23 +08:00
AskaEth b44e8d96b9 merge: W2 — A2 interrupt scope(传输层 + 设置项) 2026-10-04 17:44:21 +08:00
AskaEth 51ea890684 merge: W1 — D4 未读(抽屉角标 + 已读游标) 2026-10-04 17:44:21 +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 b7806951eb feat(ws): interrupt 支持 scope,并按偏好选「追加队列 / 打断」(协同 A2)
契约 v0.56/A2(api-client §3.1):interrupt{sessionId, scope?},取值域 {"", "all", "current"}——省略 / 空串 / "all" 同义(取消在跑回合 + 清空待处理队列,逐字不变的旧行为),"current" = 只取消在跑回合、保留待处理队列(队列消息按编排器续跑规则继续执行,一条不丢);其余取值服务端定向回 error{bad_request}、不打断任何会话。

- WsOutbound.Interrupt 增可选 scope:null = 整键省略(服务端视同 all),默认帧因此与今天逐字节相同;传输层不校验取值、不补默认、不改写别名(开放取值域由服务端裁决,客户端替它纠正只会换来一次没解释的打断)。
- ChatSocket.sendInterrupt(sessionId, scope = null):把 scope 交给调用方从偏好取值;今天仍无调用方——「打断」触发点在聊天区(属另一轮),本 API 先把传输面备好,KDoc 如实标注。
- 新偏好 InterruptMode(DataStore cyrene_app/interrupt_mode):AppendQueue(默认,省略 scope = all)与 Interrupt(scope:"current");未知/缺省值一律解回默认档,绝不悄悄换成更激进的一档。偏好经 AppPrefs → LocalAppPrefs 下发,聊天区按既有方式读取。
- 设置页新增「她忙时发消息」分组:沿用主题那套 Miuix PreferenceCard + BasicComponent + ✓;两行文案说清唯一差别——忙时那条排队消息还在不在(看不懂效果的偏好比没有更糟)。
- 测试新增 11 条(WsOutboundInterruptTest 6 / InterruptModeTest 5):钉死默认档与旧帧逐字节相同、current 原样透传、"all"/""/未知取值都原样发出、两档不在线上打成同一种形状。工作树 assembleDebug 绿,testDebugUnitTest 283 条 0 失败(基线 272 + 新增 11)。
2026-10-04 17:36:22 +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 d12a920096 fix(ws): 流式帧按 sessionId 收敛,修跨会话串台并退役 A1/D14
服务端从 v0.36 起就在结构化流帧(message.* / thinking.* / tool_call.* /
tool_execution.*)上带了 sessionId + turnId,而本端 WsEvent.parse 把这两个字段
**丢掉了**——所以「同一时刻只有一个会话在流式」这条长期约束(A1/D14)其实是
客户端自己造成的,不是后端限制。

而广播是发给**每条已认证连接**、不按会话订阅;每个 ChatScreen 的 VM 又各建一条
ChatSocket,切会话后旧 socket 与旧 VM 都还活着——于是旧 VM 会拿新会话的 delta
跑自己的流式状态机,思维链/工具卡/正文全线串台。

- data/ws/WsEvent.kt:基类开 open val sessionId(子类按帧壳覆盖),透传到 10 个
  结构化事件类型;新增纯函数 isForSession()——**只排除「明确属于别的会话」的帧**,
  空值视为「无法判定」而放行(与 historyForSession 对空值的处理一致)
- ui/chat/ChatViewModel.kt:onEvent 顶部加一道会话闸;history 帧**刻意豁免**,
  它的归属由帧内每条消息的 sessionId 判定(比帧壳更细,即 2a49c25 的语义)
- data/ws/ChatSocket.kt:改掉那段描述「同一时刻只允许一个会话在流式」的过时 KDoc

顺带核实:**没有任何代码实现过 D14**(切会话时不拦截,sendInterrupt 更是零调用点),
它只是文档假设 + 解析器丢字段的副作用 → 因此 D14 退役、A1 闭环。

测试:194 全绿(178 → +16)。新增 WsEventSessionScopeTest:12 种帧类型的解码
(按真实帧壳形状)、向后兼容(缺字段 / 显式 null)、实抓的 history 帧、isForSession 规则。

未验证项:跨会话修复只由单测 + 后端自带的 ws_hub_stream_scope_test 覆盖,
没有实跑「两个会话同时流式」。
2026-10-04 14:19:24 +08:00
AskaEth 2a49c25e1b fix(chat): WS history 帧按会话过滤,修次级会话串进主会话消息
WS 首帧 history 是**主会话**最近 50 条的回放,但连接是全局的、帧会被投给任意
ChatViewModel;onEvent 此前把它**原样**并进当前消息列表,导致进次级会话会看到
主会话的消息混在一起——是数据串台,不是显示问题。

- data/repository/PagedMessages.kt:新增纯函数 historyForSession(messages, sessionId),
  按**消息自身的 sessionId** 过滤;sessionId 为空的帧视为「无法判定」放行,
  不因缺一个字段就整段丢掉历史(保持既有行为)
- ui/chat/ChatViewModel.kt:history 分支先收敛到本会话再合并

为什么按 sessionId 而不是「只有主会话才收 history」:前者更耐将来——
服务端哪天开始给次级会话回放,这里无需改动。

测试:178 全绿(175 → +3),新增三条:主会话回放不得泄漏进次级会话、
缺 sessionId 的帧放行、过滤不修改入参。
2026-10-04 14:05:38 +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 f5576540b4 feat(auth): 接入 /auth/refresh,不再把登录口令落盘
客户端此前把明文口令存进 EncryptedSharedPreferences,用于过期后的静默重登
(当时后端没有 refresh 端点)。后端现在有 /auth/refresh + /auth/logout 了,
本轮改为存 refresh token,口令在首次成功认证后即被抹掉。

关键约束:服务端的 refresh 是**单次使用 + 原子旋转 + 无宽限期**——同一个 token
第二次使用立刻 invalid_grant;而本端并发 401 是常态(管理概览页两个请求并发、
聊天页 REST 与 WS 同时起步)。因此:

- core/auth/TokenRefresher.kt:单飞刷新 + 代际守卫(等锁期间别人已换好令牌就直接
  复用,绝不用同一个 refresh token 打第二次)+ 终态记忆(被 invalid_grant 拒过的
  token 记下来,一次失效里只打一次注定失败的请求;Transient 不记忆以保留重试)
- 用应用层 Interceptor 而非 OkHttp Authenticator:后者对**任何** 401 都会被调用
  (含不带令牌的登录请求),且由 RetryAndFollowUp 内部循环驱动(上限 20 次跟进),
  持续 401 的令牌会打出刷新风暴。应用层拦截器只在「带了令牌仍 401」时介入,且严格
  重试一次
- 可恢复的 401 不再登出:Transient(网络/5xx)原样交回 401 并保留会话;只有
  Unrecoverable,或「换上刚签发的令牌仍 401」,才判终态
- WS 握手 401:先尝试一次续期再重连,终态才登出
- 登出补调 POST /auth/logout(此前从不调用,服务端 grant 会白活 30 天)
- 主动 skew(60s)刷新 + 兜底重试;刷新请求走不带令牌的客户端,不会递归回本路径

迁移对用户无感、绝不强制重登:启动时若「无 refresh token 但有旧口令」,用旧路径
静默登录一次换到 refresh token,随即 commit() 抹掉口令;离线失败就下次启动再试,
且不清会话。另外鉴权过期时也走一次续期——否则去掉口令会把会话砍到 24h,比改动前
更差。

HttpLoggingInterceptor 保持 BASIC 不动:/auth/refresh 与 /auth/logout 把 refresh
token 放在**请求体**里,再高一级就会把凭据打进 logcat。

测试:153 全绿(115 → +38)。含真实抓取响应体的 DTO 契约测试、TokenRefresher 的
并发/代际/终态单测、bootstrap 迁移单测,以及走真实 OkHttp + Retrofit + 回环
HttpServer 的端到端测试(证明只重试一次)。未新增任何依赖。

未验证项:主动/被动续期路径本身没能真机触发(唯一输入是加密存储里的 expires_at,
shell 写不进去;而 tokenTTL 是 24h 常量),仅由回环端到端测试覆盖。
2026-10-04 13:34:53 +08:00
AskaEth 09a3222b7b feat(enums): 接入 /meta/enums 权威枚举表,删掉客户端硬编码映射
客户端此前自带 platform / mood / mode 三张硬编码标签表,会与服务端漂移——
实测已经漂了:客户端把 `web` 显示成「Web」,权威表里是「网页」。

- data:MetaApi(GET /api/v1/meta/enums)+ MetaDto + MetaRepository,
  经 ApiClientFactory 创建以复用既有 token / 401 处理
- domain/enums/EnumCatalog.kt:标签查找器,**不含任何硬编码兜底表**;
  不在表内的取值一律原样返回(api-client §1.1 的未知值回落红线);
  platformLabelOrNull 供 deviceId 前缀这类「认识/不认识要走不同回落」的调用方用;
  空 id 或空 label 一律不入表(否则角标会显示空白,或查空串凭空借到别人的标签)
- core/prefs/EnumPreferences.kt:last-good 快照,baseUrl+username 指纹闸门,
  换服务器/账号即作废,登出清空
- EnumCatalogViewModel + LocalEnumCatalog:在 CyreneRoot 注入(与 LocalAppPrefs 同套路),
  聊天角标与管理页共读同一份
- 删除 platformLabels / personaModeLabels / moodLabels;MessageSource 与 AdminFormat
  改为查表,并保留各自原有的原样回落

为什么不留硬编码兜底表:没有登录就看不到任何消息,而权威表在登录前后即会拉取,
所以 last-good 快照几乎总是存在;留着硬编码表等于把「漂移」重新引进来,而且是无声的——
旧标签和新鲜标签长得一模一样。

不转换的:trace kind/status、memory scope、thinking.mode、WS session_busy 的 mode 集合——
它们都不在 /meta/enums 内,且原本就已按原样回落。

测试:115 全绿(88 → +27:MetaDtoRealPayload 8 / EnumCatalog 7 / EnumPrefs 3 /
MessageSource 8,AdminFormatTest 改写并 +1),其中 /meta/enums 与登录响应一样
用真实抓取的响应体做夹具。
2026-10-04 13:19:34 +08:00
AskaEth 9f054aad28 fix(admin): 后台思考 conclusion 的 <p>/<action> 标记被原样显示
现象:后台思考页把服务端 conclusion 里的 Web 端标记当纯文本打印,真机截图里
能看到 `<p></p><p></p><p>亲爱的…</p>` 和 `<action>轻轻戳了戳你</action>` 这样的
原始标记,部分记录开头还有一串空 `<p></p>`。

为什么:backthink 的 conclusion 与聊天消息是同一套标记,而 AdminBackthinkScreen
只是把它拼进 body 字符串,从未解析。

改法:复用项目已有的 domain/parse/ChatContentParser.parse()(不新写正则),
在 domain/admin/AdminFormat.kt 加纯函数 conclusionParagraphs()——按
head → 正文 → tail 产出段落、丢弃无有效片段的空段落(这才是消掉空 <p></p> 的关键);
UI 侧新增 AdminRichTextCard,Text 正常渲染,Action/Narration 走聊天页同一套
灰斜体旁白样式(对齐 MessageBubble.AnnotationLine 的既有约定)。
AdminTextCard 行为不变(表头抽成共享的 AdminCardHeader),已回访记忆页确认无回归。

测试:新增 7 例(AdminFormatTest 13→20,用真实抓取的 conclusion 做夹具,并断言
渲染模型里不残留 <p>/</p>/<action>/< 或 > 任何一项),全套 88 例全绿。
2026-10-04 13:02:09 +08:00
AskaEth acff289887 test(admin): 用真实后端响应锁定管理端 DTO 契约
原先 AdminDtoDecodingTest 用的是手写的合成 JSON,等于拿「我以为的形状」自证,
证明不了真实契约。新增 AdminDtoRealPayloadTest,把 5 个管理端点的真实响应体
(登录后从本机 10942 实抓)过一遍生产用的 Json 实例。

顺带把真实数据里几个容易翻车的形状钉死:
- trace.durationNs 必须是 Long(真实 ~1.17e9 ns,超过 Int 上限就炸)——
  真实值恰好塞得进 Int,所以额外用一例 3e9 的边界值才真正证明它是 Long
- 根 span 没有 parentId 键 → null
- 未配置人格的 thinking 键完全不出现 → null(不得变成全零对象)
- persona 的 mode / memoryNamespace 是空串,而非缺键
- backthink.id 是 JSON 字符串("487"),at 是数字(Unix 毫秒)
- props / events 同一个 map 里混着数字与字符串

为什么:C-2 的 DTO 此前只经「编译通过」这一层,从未被真实响应验证过。
真实响应全部一次解出,无需改动生产代码。

测试:新增 7 例(AdminDtoRealPayloadTest),全套 81 例全绿。
2026-10-04 13:02:08 +08:00
AskaEth a7b3452e6d feat(admin): 新增 C-2 管理员只读监控(概览/人格/记忆/后台思考)
侧边栏「管理员监控」进入的独立管理区 + 独立 Scaffold,四页全部只读,
不调用任何 /admin/* 写端点。

- data:AdminApi(5 条只读 GET)+ AdminDto + AdminRepository,
  经 ApiClientFactory 创建以复用既有 token / 401 处理
- domain:AdminAccess.kt 为权限真源(纯函数,便于单测);
  AdminFormat.kt 把 RFC3339 与 Unix 毫秒两种时间形状分开解析
- ui:AdminHomeScreen 做成可加页的壳(加页 = AdminPage 加一条枚举),
  四个子页 + 统一失败态;列表项一律 PreferenceCard + BasicComponent
- RBAC:后端契约要求客户端自行降级(api-client §2.0),按矩阵分档——
  viewer 只能进概览/人格;入口禁用 + ViewModel 短路(不发请求)+
  服务端 403 也渲染「无权限」态,三层 fail-closed;
  任何失败都不渲染成「暂无数据」

为什么:M0–M5 + 设置页 C-1 已完成,C-2 是路线上的下一项;后端 5 个读端点
均已实现(非 501),且手机端后续要走「全功能管理」(后端工作小结 主线②,
凭据前置已完成),故管理区做成可扩展的壳而非只读死胡同。

测试:74 个单测全绿,其中新增 29 个
(AdminAccessTest 9 / AdminFormatTest 13 / AdminDtoDecodingTest 7)
2026-10-03 21:31:09 +08:00
AskaEth 9bc45dc315 fix(ui): PreferenceCard 补 padding import 2026-09-08 23:17:06 +08:00
AskaEth 664fb2d9ed style(ui): PreferenceCard 补左右外边距 12dp 2026-09-08 23:16:23 +08:00
AskaEth cf291b58ab style(ui): 列表项加卡片容器;登出移到设置页底部;按钮改矢量图标
新增 PreferenceCard(BasicComponent 本身无背景/圆角);抽屉去掉登出、新建与删除改图标按钮;设置页返回改 ChevronBackward、底部新增登出图标。
2026-09-08 23:13:00 +08:00
AskaEth df2a26701c style(ui): 列表项改用 Miuix BasicComponent(MD3 圆角卡片)
抽屉的会话行/入口行、设置页的主题选项统一换成 BasicComponent,间距 8dp,选中态用 ✓ 标记。
2026-09-08 22:55:29 +08:00
AskaEth 00856eeaf7 fix(ui): 浅色模式系统栏图标改深色;返回键逐层回退
CyreneRoot 用 WindowInsetsControllerCompat 跟随主题切换系统栏明暗;ChatHomeScreen 为设置页与抽屉各加 BackHandler,避免直接回桌面。
2026-09-08 22:45:20 +08:00
AskaEth 61214a18fa feat(settings): C 起步——App 设置页(主题/旁白/思维链默认展开/关于)
新增 AppSettings(DataStore)与 LocalAppPrefs;CyreneRoot 按偏好决定 Miuix 主题模式并下发偏好;设置页从侧边栏进入,返回回聊天。旁白开关与思维链默认展开已接入渲染层。
2026-09-08 22:41:37 +08:00
AskaEth 91117986e4 feat(chat): 改为文本选择模式,全选仅作用于当前气泡
用 SelectionContainer 包住气泡/旁白/思维链/工具卡的正文,长按可拖选;移除长按整段复制的实现与其工具文件。
2026-09-08 22:38:28 +08:00
AskaEth 33770d87a6 feat(chat): 回车发送 + 长按气泡复制
输入框 imeAction=Send(回车即发送);长按气泡体复制原文到剪贴板并 Toast 提示。
2026-09-08 22:08:52 +08:00
AskaEth 0ea41f5815 fix(chat): 补 LaunchedEffect import 2026-09-08 21:57:43 +08:00
AskaEth 5a5b02f6fb feat(chat): 思维链保留 10 分钟并自动折叠;输入栏下方留白承载忙碌提示
流式结束后把思维链挂到落库消息上,10 分钟后自动清除;ThinkingCard 在 active 变 false 时自动折叠,历史思维链默认折叠。输入栏底部预留 22dp 避开屏幕圆角,顶栏副标题只留连接状态,忙碌提示移到输入栏下方(带左缩进)。
2026-09-08 21:56:55 +08:00
AskaEth 5975827aea fix(chat): Column 同时传 content 与尾随 lambda 导致编译失败 2026-09-08 21:50:54 +08:00
AskaEth 7faa604fd0 feat(chat): M4 思维链卡片 + 工具卡 + 图片
thinking.*/tool_call.*/tool_execution.update 累积进流式状态并渲染为可折叠卡片(思维链默认展开);历史消息的 thinking(来自 REST /messages)同样展示;image 用 Coil 3 加载。
2026-09-08 21:48:40 +08:00
AskaEth 5ac0ea3c3b fix(chat): 输入法弹出时输入框被顶到顶部
窗口默认 adjustUnspecified 会先做 pan,叠加 imePadding 后高度被算两次;改为 adjustResize。
2026-09-08 21:42:36 +08:00
AskaEth 5d71c6a2ab fix(ui): 移除聊天页顶栏登出按钮;抽屉内容避开系统栏
登出已收进侧边栏;抽屉面板保留全高背景,内容用 systemBars inset 避开状态栏/导航栏。
2026-09-08 21:34:10 +08:00
AskaEth e72621db94 feat(sessions): M5 侧边栏抽屉 + 会话列表(切换/新建/删除)
自研 MiuixDrawer(draggable+Animatable,关闭态不拦截触摸);DataStore 持久化会话快照/序号映射/当前会话;SessionsViewModel 先快照后刷新;次级会话删除二次确认,主会话禁删。
2026-09-08 21:29:24 +08:00
AskaEth e04995a84d fix(chat): 新消息自动跟随底部(微信/QQ 式)
reverseLayout 会锚定原可见项,导致新消息插到视口外。改为:滚动结束时记录是否贴底(容差 64px),最新一项变化且处于跟随状态时 animateScrollToItem(0);用户上滑查看历史时不再强制滚动。
2026-09-08 21:16:49 +08:00
AskaEth 67ed0479e4 feat(chat): M3 发送与流式渲染
输入框 + 发送(WS chat 上行,deviceId=voiceassist:cyrene:v1);乐观消息先上屏,message_persisted 到达后按文本归一;message.start/delta/end + stream_end 走流式增量解析,末尾带光标;发送失败回滚文本到输入框。
2026-09-08 21:09:37 +08:00
AskaEth a8a422c4e1 fix(chat): 来源角标改挂到消息组末尾
角标应紧随它标注的那条消息(主人右下、昔涟左下),放在上方会显得在标注下一条。
2026-09-08 21:06:42 +08:00
AskaEth 6323f89b5b fix(chat): 气泡间距 6dp;来源文案按角色区分「来自 / 发送至」
主人发的显示「来自 X」,昔涟发的显示「发送至 X」。
2026-09-08 21:04:32 +08:00
AskaEth 01b5f980a5 fix(chat): 来源角标移到消息组顶部;IM deviceId 显示友好平台名
角标挂底部会视觉贴到下一条消息上;微信等 IM 的 deviceId 形如 <平台名>:<配置id>:<uid>,只取平台名显示「来自微信」。
2026-09-08 21:03:33 +08:00
AskaEth 63fe9a12a6 feat(chat): 接入 <p> 多气泡与 <action>/<narration> 旁白渲染
chat 模式消息按 <p> 拆成多个气泡;<action>/<narration> 解析为灰斜体小字依附气泡上/下;来源角标只在最后一条气泡显示一次。非 chat 模式原样渲染。
2026-09-08 21:01:48 +08:00
AskaEth 171f6ff59a fix(net): 放行明文 HTTP,修复自部署地址报「网络不可用」
Android 9+ 默认禁止 cleartext,OkHttp 抛 UnknownServiceException 被映射为无网。新增 network_security_config.xml(放行 cleartext + 信任用户 CA),并给该异常单独的提示文案。
2026-09-08 20:56:06 +08:00
AskaEth 0587f5a45f chore: devdocs 移出版本控制
设计/协作文档只保留在本地,不再随仓库分发。
2026-09-08 20:50:40 +08:00
AskaEth 253913ad82 chore: 机器特定的构建配置移出仓库
aapt2FromMavenOverride(ARM64 专用绝对路径)与 sdkDownload 移到本机 ~/.gradle/gradle.properties,仓库配置保持跨平台可用。
2026-09-08 20:49:00 +08:00
AskaEth 84de51c7b7 feat: 初始化 Android 客户端(M0 骨架 + M1 登录 + M2 基础模块)
M0:Gradle 9.7.1 / AGP 9.2.1 / Kotlin 2.4 / Miuix 0.9.3,minSdk 33,compileSdk 37。
M1:登录页(服务器地址可配、服务端/本地错误分流、EncryptedSharedPreferences、自动登录与静默重登、401 登出)。
M2 基础模块:消息内容解析器(<p>/<action>/<narration> + 流式增量)、WebSocket 客户端、REST 数据层与分页去重。
45 个单元测试全绿,真机验证通过(登录页 + 启动图标)。
devdocs/ 收录设计文档、前后端协同事项与细化方案。
2026-09-08 20:40:58 +08:00
141 changed files with 14823 additions and 104 deletions

No files matched your search

+5 -28
View File
@@ -1,35 +1,12 @@
# ---> Android *.iml
# Gradle files
.gradle/ .gradle/
build/ build/
# Local configuration file (sdk path, etc)
local.properties local.properties
.idea/
# Log/OS Files .DS_Store
*.log
# Android Studio generated files and folders
captures/ captures/
.externalNativeBuild/ .externalNativeBuild/
.cxx/ .cxx/
*.apk
output.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof
# 本地设计/协作文档,不进仓库
devdocs/
-73
View File
@@ -1,73 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2026 AskaEth
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-3
View File
@@ -1,3 +0,0 @@
# Cyrene-For-Android
昔涟在安卓设备上的载体。
+78
View File
@@ -0,0 +1,78 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "top.yeij.cyrene"
compileSdk = 37
buildToolsVersion = "37.0.0"
defaultConfig {
applicationId = "top.yeij.cyrene"
minSdk = 33
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.foundation)
implementation(libs.miuix.ui)
implementation(libs.miuix.icons)
implementation(libs.miuix.preference)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.datastore.preferences)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit)
}
+10
View File
@@ -0,0 +1,10 @@
# kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers class **$$serializer {
*** descriptor;
}
-keepclasseswithmembers class ** {
kotlinx.serialization.KSerializer serializer(...);
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".app.CyreneApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Cyrene">
<activity
android:name=".app.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,156 @@
package top.yeij.cyrene.app
import android.content.Context
import top.yeij.cyrene.core.auth.AuthManager
import top.yeij.cyrene.core.auth.SessionStore
import top.yeij.cyrene.core.prefs.AppSettings
import top.yeij.cyrene.core.prefs.EnumPreferences
import top.yeij.cyrene.core.prefs.SessionPreferences
import top.yeij.cyrene.data.api.AdminApi
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ChatApi
import top.yeij.cyrene.data.api.MetaApi
import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.api.UploadApi
import top.yeij.cyrene.data.repository.AdminRepository
import top.yeij.cyrene.data.repository.ChatRepository
import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.MetaRepository
import top.yeij.cyrene.data.repository.SessionRepository
import top.yeij.cyrene.data.repository.UploadRepository
import top.yeij.cyrene.data.ws.ChatSocket
/** 手写 DI 容器(D:不引 Hilt)。 */
class AppContainer(context: Context) {
/** 应用上下文:给需要在 ViewModel 里读本地资源(图片解码等)的组件用。 */
val appContext: Context = context.applicationContext
val sessionStore: SessionStore = SessionStore(context.applicationContext)
val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext)
val enumPreferences: EnumPreferences = EnumPreferences(context.applicationContext)
val appSettings: AppSettings = AppSettings(context.applicationContext)
val authManager: AuthManager = AuthManager(sessionStore)
private var cachedBaseUrl: String? = null
private var cachedSessionsApi: SessionsApi? = null
/** 会话/消息 REST 客户端;baseUrl 变化时自动重建。 */
val sessionsApi: SessionsApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedSessionsApi
if (cached != null && cachedBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = SessionsApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedBaseUrl = baseUrl
cachedSessionsApi = created
return created
}
val sessionRepository: SessionRepository get() = SessionRepository(sessionsApi)
val messageRepository: MessageRepository get() = MessageRepository(sessionsApi)
private var cachedChatApi: ChatApi? = null
private var cachedChatBaseUrl: String? = null
/** 发送端点(`POST /api/v1/chat`);与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
val chatApi: ChatApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedChatApi
if (cached != null && cachedChatBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = ChatApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedChatBaseUrl = baseUrl
cachedChatApi = created
return created
}
val chatRepository: ChatRepository get() = ChatRepository(chatApi)
private var cachedUploadApi: UploadApi? = null
private var cachedUploadBaseUrl: String? = null
/**
* 图片上传端点(`POST /api/v1/upload`);与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理)——
* 上传的产物 URL 就是要交给 `POST /api/v1/chat` 的 `image` 字段的,两者必须在同一个后端上。
*/
val uploadApi: UploadApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedUploadApi
if (cached != null && cachedUploadBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = UploadApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedUploadBaseUrl = baseUrl
cachedUploadApi = created
return created
}
val uploadRepository: UploadRepository get() = UploadRepository(uploadApi)
private var cachedAdminApi: AdminApi? = null
private var cachedAdminBaseUrl: String? = null
/** 管理只读监控客户端;与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
val adminApi: AdminApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedAdminApi
if (cached != null && cachedAdminBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = AdminApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedAdminBaseUrl = baseUrl
cachedAdminApi = created
return created
}
val adminRepository: AdminRepository get() = AdminRepository(adminApi)
private var cachedMetaApi: MetaApi? = null
private var cachedMetaBaseUrl: String? = null
/** 枚举权威表客户端;同样与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
val metaApi: MetaApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedMetaApi
if (cached != null && cachedMetaBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = MetaApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedMetaBaseUrl = baseUrl
cachedMetaApi = created
return created
}
val metaRepository: MetaRepository get() = MetaRepository(metaApi)
fun createChatSocket(): ChatSocket = ChatSocket(
baseUrlProvider = { authManager.currentBaseUrl },
tokenProvider = authManager::currentToken,
onAuthRecover = authManager::recoverSocketAuth,
onAuthFailed = authManager::onSessionExpired,
)
}
@@ -0,0 +1,32 @@
package top.yeij.cyrene.app
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.ui.chat.ChatHomeScreen
import top.yeij.cyrene.ui.common.LoadingScreen
import top.yeij.cyrene.ui.login.LoginScreen
@Composable
fun CyreneApp(container: AppContainer) {
val state by container.authManager.state.collectAsState()
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
container.authManager.bootstrap()
}
when (state) {
SessionState.Unknown -> LoadingScreen()
SessionState.LoggedOut -> LoginScreen(container)
is SessionState.LoggedIn -> ChatHomeScreen(
container = container,
// 登出要先把服务端的 refresh 授权吊销掉,所以是挂起的;UI 不因为它在主线程等待。
onLogout = { scope.launch { container.authManager.logout() } },
)
}
}
@@ -0,0 +1,14 @@
package top.yeij.cyrene.app
import android.app.Application
class CyreneApplication : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
}
@@ -0,0 +1,72 @@
package top.yeij.cyrene.app
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import top.yeij.cyrene.core.prefs.AppPrefs
import top.yeij.cyrene.core.prefs.LocalAppPrefs
import top.yeij.cyrene.core.prefs.ThemeMode
import top.yeij.cyrene.core.theme.CyreneTheme
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
/**
* 应用根:读 App 偏好 → 决定 Miuix 主题模式 → 通过 [LocalAppPrefs] / [LocalEnumCatalog] 下发给渲染层,
* 同时把系统栏图标明暗跟着主题走(否则浅色模式下白字白底看不见)。
*/
@Composable
fun CyreneRoot(container: AppContainer) {
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
val enumCatalogViewModel: EnumCatalogViewModel = viewModel(
factory = viewModelFactory { initializer { EnumCatalogViewModel(container) } },
)
val catalog by enumCatalogViewModel.catalog.collectAsState()
val darkTheme = when (prefs.themeMode) {
ThemeMode.System -> isSystemInDarkTheme()
ThemeMode.Light -> false
ThemeMode.Dark -> true
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = view.context.findActivity()?.window ?: return@SideEffect
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
}
}
CompositionLocalProvider(
LocalAppPrefs provides prefs,
LocalEnumCatalog provides catalog,
) {
CyreneTheme(mode = prefs.themeMode.toColorSchemeMode()) {
CyreneApp(container)
}
}
}
private fun ThemeMode.toColorSchemeMode(): ColorSchemeMode = when (this) {
ThemeMode.System -> ColorSchemeMode.System
ThemeMode.Light -> ColorSchemeMode.Light
ThemeMode.Dark -> ColorSchemeMode.Dark
}
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
@@ -0,0 +1,53 @@
package top.yeij.cyrene.app
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.domain.enums.EnumCatalog
/**
* 枚举权威表(api-client §1.1)的应用级持有者:先用本地快照渲染,再拉一次权威表。
*
* - 登录 / 静默重登后拉取;**拉取失败保持现状**(快照或原样 id),绝不因此清空角标;
* - 快照按 `baseUrl + username` 指纹认领:换服务器/账号即作废;
* - 登出时落盘快照与内存标签一并清掉(此时界面只剩登录页,不会闪空角标)。
*/
class EnumCatalogViewModel(private val container: AppContainer) : ViewModel() {
private val _catalog = MutableStateFlow(EnumCatalog.Empty)
val catalog: StateFlow<EnumCatalog> = _catalog.asStateFlow()
init {
viewModelScope.launch {
container.authManager.state.collect { state ->
if (state == SessionState.LoggedOut) {
container.enumPreferences.clear()
_catalog.value = EnumCatalog.Empty
return@collect
}
val fingerprint = fingerprint()
// 先用快照渲染(没有就原样显示 id),拉取不阻塞首帧
val snapshot = container.enumPreferences.data.first()
_catalog.value = EnumCatalog.of(snapshot.tableFor(fingerprint))
if (state is SessionState.LoggedIn) refresh(fingerprint)
}
}
}
private fun refresh(fingerprint: String) {
viewModelScope.launch {
container.metaRepository.enums().onSuccess { table ->
container.enumPreferences.save(table, fingerprint)
_catalog.value = EnumCatalog.of(table)
}
}
}
private fun fingerprint(): String =
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
}
@@ -0,0 +1,16 @@
package top.yeij.cyrene.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val container = (application as CyreneApplication).container
setContent {
CyreneRoot(container)
}
}
}
@@ -0,0 +1,254 @@
package top.yeij.cyrene.core.auth
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiErrorEnvelope
import top.yeij.cyrene.data.api.CyreneApi
import top.yeij.cyrene.data.api.dto.LoginRequest
import top.yeij.cyrene.data.api.dto.LogoutRequest
import top.yeij.cyrene.data.api.dto.RefreshRequest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
sealed interface SessionState {
data object Unknown : SessionState
data object LoggedOut : SessionState
data class LoggedIn(val baseUrl: String, val role: String) : SessionState
}
/**
* 会话生命周期:登录、静默恢复、续期、登出。
*
* B2 起密码**不再落盘**:长期凭据是 refresh token,访问令牌过期时用它续期,
* 续期由 [TokenRefresher] 单飞协调(并发 401 只会真的刷一次——服务端轮换无宽限期)。
*
* @param apiFactory 认证接口的构造方式。默认走真实 Retrofit;单测注入假实现即可覆盖
* 登录 / 迁移 / 登出路径,既不需要网络也不需要新增依赖。
*/
class AuthManager(
private val store: SessionStorage,
private val apiFactory: (String) -> CyreneApi = { ApiClientFactory.create(baseUrl = it) },
) {
private val _state = MutableStateFlow<SessionState>(SessionState.Unknown)
val state: StateFlow<SessionState> = _state.asStateFlow()
@Volatile
var currentBaseUrl: String = ""
private set
private val refresher = TokenRefresher(store, ::refreshCall)
/** 不带令牌的认证客户端(登录 / 刷新 / 登出),按 baseUrl 缓存。可被 OkHttp 线程读到。 */
@Volatile
private var authApi: CyreneApi? = null
@Volatile
private var authApiBaseUrl: String? = null
/**
* 401 处置:能续期就用新令牌重试一次,明确被拒才判终态。
* 暂时性失败(网络 / 5xx)**绝不清会话**——见 B2 §6。
*/
val unauthorizedPolicy: UnauthorizedPolicy = object : UnauthorizedPolicy {
override fun renew(usedToken: String): AuthRecovery = refresher.renew(usedToken)
override fun onSessionEnded() = onSessionExpired()
}
/** tokenProvider 与 WS 握手用:临近过期时阻塞地先换令牌。 */
fun currentToken(): String? = refresher.ensureToken()
/**
* WS 握手 401 的一次性恢复:换一次令牌让上层重连。
* 与 401 拦截器共用同一条单飞路径,因此 REST 与 WS 同时失效也只会打一次刷新。
*/
fun recoverSocketAuth(): AuthRecovery = refresher.renew(store.load().token)
/**
* 启动时恢复会话(D12 + B2 迁移)。顺序即优先级:
*
* 1. **迁移**:没有 refresh token 却还留着旧版的明文密码 → 用旧凭据静默登录一次换 refresh token,
* 成功后立刻把它抹掉;失败(例如离线)保留原值、**不登出**,下次启动再试。
* 2. 已经有 refresh token → 任何残留的明文密码立刻抹掉。
* 3. 访问令牌过期 → 用 refresh token 续期。密码没了以后,这是唯一的静默恢复途径;
* 没有了它,隔一天再打开就要重新输密码(比旧版还差)。
* 4. 仍然拿不到有效令牌 → 回登录页(username 仍预填)。
*/
suspend fun bootstrap() = withContext(Dispatchers.IO) {
var session = store.load()
currentBaseUrl = session.baseUrl
val legacyUser = session.username?.takeIf(String::isNotBlank)
val legacyPassword = session.password?.takeIf(String::isNotBlank)
if (session.refreshToken.isNullOrBlank() && legacyUser != null && legacyPassword != null) {
doLogin(session.baseUrl, legacyUser, legacyPassword)
session = store.load()
} else if (!session.password.isNullOrBlank()) {
store.purgePassword()
session = store.load()
}
if (!session.hasValidToken() && !session.refreshToken.isNullOrBlank()) {
refresher.renew(session.token)
session = store.load()
}
_state.value = if (session.hasValidToken()) {
SessionState.LoggedIn(session.baseUrl, session.role.orEmpty())
} else {
SessionState.LoggedOut
}
}
suspend fun login(rawBaseUrl: String, username: String, password: String): CyreneError? =
withContext(Dispatchers.IO) {
doLogin(ApiClientFactory.normalizeBaseUrl(rawBaseUrl), username, password)
}
/**
* 主动登出:先尽力吊销服务端的 refresh 授权(否则本地「登出」之后那条授权还能活 30 天),
* 再抹掉本地一切。吊销失败、超时、离线都不阻断本地清理,所以离线登出依然干净。
*/
suspend fun logout() = withContext(Dispatchers.IO) {
val refreshToken = store.load().refreshToken?.takeIf(String::isNotBlank)
if (refreshToken != null) {
// 吊销请求不能拖住登出:给一个短上限,超时就当离线处理。
withTimeoutOrNull(REVOKE_TIMEOUT_MS) {
try {
authApi(sessionBaseUrl()).logout(LogoutRequest(refreshToken))
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// 尽力而为:离线 / 服务端报错也必须把本地清干净。
}
}
}
store.clearAll()
authApi = null
authApiBaseUrl = null
_state.value = SessionState.LoggedOut
}
/** 会话终结(refresh 不可能或明确被拒):清令牌回登录页;username 保留以便预填。 */
fun onSessionExpired() {
store.clearSession()
_state.value = SessionState.LoggedOut
}
private suspend fun doLogin(url: String, username: String, password: String): CyreneError? {
return try {
val response = authApi(url).login(LoginRequest(username, password))
val data = response.body()?.data
when {
response.isSuccessful && data != null -> {
store.saveLogin(url, data.token, data.expiresAt, data.role, username, data.refreshToken)
// B2 红线:不再写明文密码。拿到长期凭据后,把历史遗留的那一份也一并抹掉。
if (!data.refreshToken.isNullOrBlank()) store.purgePassword()
currentBaseUrl = url
_state.value = SessionState.LoggedIn(url, data.role)
null
}
response.isSuccessful -> CyreneError.Local(CyreneError.LocalKind.Parse)
else -> {
val parsed = parseError(response.errorBody()?.string())
if (response.code() == HTTP_UNAUTHORIZED) {
CyreneError.Unauthorized(parsed?.code.orEmpty(), parsed?.message.orEmpty())
} else {
CyreneError.Server(
code = parsed?.code.orEmpty(),
message = parsed?.message.orEmpty(),
httpStatus = response.code(),
)
}
}
}
} catch (e: Exception) {
mapException(e)
}
}
/**
* [TokenRefresher] 注入的阻塞刷新调用。
*
* 它跑在 OkHttp 的调用线程上(拦截器 / tokenProvider),所以这里用 `runBlocking` 就地驱动
* Retrofit 的挂起接口——那正是此刻需要的语义:同步等结果。
*
* 客户端**不带令牌**,镜像 [doLogin]:既不会让过期令牌搭车,也不会把刷新的 401 送回
* 本类的过期处理路径(那两个客户端用的是 [NoUnauthorizedPolicy])。
*/
private fun refreshCall(refreshToken: String): RefreshCall {
val baseUrl = sessionBaseUrl()
if (baseUrl.isBlank()) return RefreshCall.Failed
return try {
val response = runBlocking { authApi(baseUrl).refresh(RefreshRequest(refreshToken)) }
val data = response.body()?.data
when {
response.isSuccessful && data != null ->
RefreshCall.Ok(data.token, data.expiresAt, data.refreshToken)
// 200 但解不动:不拿会话去赌,当暂时性失败。
response.isSuccessful -> RefreshCall.Failed
// 该接口唯一的 401 就是 invalid_grant(未知 / 已吊销 / 已过期 / 已被用掉)。
response.code() == HTTP_UNAUTHORIZED -> RefreshCall.Rejected
else -> RefreshCall.Failed
}
} catch (_: Exception) {
RefreshCall.Failed
}
}
private fun authApi(baseUrl: String): CyreneApi {
val cached = authApi
if (cached != null && authApiBaseUrl == baseUrl) return cached
val created = apiFactory(baseUrl)
authApi = created
authApiBaseUrl = baseUrl
return created
}
private fun sessionBaseUrl(): String = currentBaseUrl.ifBlank { store.load().baseUrl }
private fun StoredSession.hasValidToken(): Boolean =
!token.isNullOrBlank() && expiresAt * 1000 > System.currentTimeMillis()
private fun parseError(body: String?) = body
?.takeIf { it.isNotBlank() }
?.let {
runCatching {
ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
}.getOrNull()
}
private fun mapException(e: Throwable): CyreneError = when (e) {
is UnknownHostException -> CyreneError.Local(CyreneError.LocalKind.Dns)
is SSLException -> CyreneError.Local(CyreneError.LocalKind.Tls)
is SocketTimeoutException -> CyreneError.Local(CyreneError.LocalKind.Timeout)
is ConnectException -> CyreneError.Local(CyreneError.LocalKind.ConnectFailed)
is java.net.UnknownServiceException -> CyreneError.Local(CyreneError.LocalKind.CleartextBlocked)
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
}
private companion object {
const val HTTP_UNAUTHORIZED = 401
/** 吊销授权的最长等待:到点就当离线,本地照样清干净。 */
const val REVOKE_TIMEOUT_MS = 3_000L
}
}
@@ -0,0 +1,137 @@
package top.yeij.cyrene.core.auth
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
data class StoredSession(
val baseUrl: String,
val token: String?,
val expiresAt: Long,
val role: String?,
val username: String?,
/** 长期凭据(B2);B2 之前登录的安装没有这一项,因此可能为 null。 */
val refreshToken: String?,
/**
* **遗留字段**:B2 之前的版本把登录密码落在这里。
* 只读用于一次性迁移,绝不再写入;见 [SessionStorage.purgePassword]。
*/
val password: String?,
)
/**
* 会话存储契约。[SessionStore] 是唯一的生产实现(EncryptedSharedPreferences);
* 单元测试用内存假实现顶替,避免把 Android 的加密存储拖进 JVM 测试。
*/
interface SessionStorage {
fun load(): StoredSession
/** 登录成功:整份会话落盘。**不写密码**(B2 红线)。 */
fun saveLogin(
baseUrl: String,
token: String,
expiresAt: Long,
role: String,
username: String,
refreshToken: String?,
)
/**
* 只替换令牌三元组,保留 baseUrl / username / role。
* [refreshToken] 为 null 表示沿用原值——`AUTH_REFRESH_ROTATE_DISABLED=true` 时服务端不下发新的,
* 此时旧凭据仍然有效,绝不能丢掉。
*/
fun updateTokens(token: String, expiresAt: Long, refreshToken: String?)
/**
* 确定性抹掉历史遗留的明文密码。
* 用 `commit()` 而不是 `apply()`:这是红线清除,必须在下一次读取之前就落到磁盘上。
*/
fun purgePassword()
/** 会话失效:清 token 与 refresh token,保留 username 以便登录页预填。 */
fun clearSession()
/** 主动登出:整份清空。 */
fun clearAll()
}
/** JWT 与登录凭据的加密存储(红线:不落明文密码、不打日志)。 */
class SessionStore(context: Context) : SessionStorage {
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
FILE_NAME,
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
override fun load(): StoredSession = StoredSession(
baseUrl = prefs.getString(KEY_BASE_URL, "").orEmpty(),
token = prefs.getString(KEY_TOKEN, null),
expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L),
role = prefs.getString(KEY_ROLE, null),
username = prefs.getString(KEY_USERNAME, null),
refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null),
// 只读:迁移路径靠它判断「这是升级前的安装」,不再写入。
password = prefs.getString(KEY_PASSWORD, null),
)
override fun saveLogin(
baseUrl: String,
token: String,
expiresAt: Long,
role: String,
username: String,
refreshToken: String?,
) {
val editor = prefs.edit()
.putString(KEY_BASE_URL, baseUrl)
.putString(KEY_TOKEN, token)
.putLong(KEY_EXPIRES_AT, expiresAt)
.putString(KEY_ROLE, role)
.putString(KEY_USERNAME, username)
if (!refreshToken.isNullOrBlank()) editor.putString(KEY_REFRESH_TOKEN, refreshToken)
editor.apply()
}
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
val editor = prefs.edit()
.putString(KEY_TOKEN, token)
.putLong(KEY_EXPIRES_AT, expiresAt)
// 轮换关闭时服务端不下发新凭据,旧的那一份继续有效,必须保留。
if (!refreshToken.isNullOrBlank()) editor.putString(KEY_REFRESH_TOKEN, refreshToken)
editor.apply()
}
override fun purgePassword() {
prefs.edit().remove(KEY_PASSWORD).commit()
}
override fun clearSession() {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_EXPIRES_AT)
.remove(KEY_ROLE)
.remove(KEY_REFRESH_TOKEN)
.apply()
}
override fun clearAll() {
prefs.edit().clear().apply()
}
private companion object {
const val FILE_NAME = "cyrene_session"
const val KEY_BASE_URL = "base_url"
const val KEY_TOKEN = "token"
const val KEY_EXPIRES_AT = "expires_at"
const val KEY_ROLE = "role"
const val KEY_USERNAME = "username"
const val KEY_REFRESH_TOKEN = "refresh_token"
const val KEY_PASSWORD = "password"
}
}
@@ -0,0 +1,132 @@
package top.yeij.cyrene.core.auth
/** 一次令牌续期的结果。 */
sealed interface AuthRecovery {
/** 已拿到可用的访问令牌:可能是刚签发的,也可能是别的线程刚换好的。 */
data class Renewed(val token: String) : AuthRecovery
/**
* 无法续期:本地没有 refresh token,或被服务端以 `invalid_grant` 拒绝
* (未知 / 已吊销 / 已过期 / 已被用掉,服务端把这四种情况折叠成一个码)。
* 这是终态——必须重新登录。
*/
data object Unrecoverable : AuthRecovery
/** 暂时性失败:网络不通、5xx、响应解不动。会话可能仍然有效,稍后再试。 */
data object Transient : AuthRecovery
}
/**
* [TokenRefresher] 注入的刷新调用结果。
* 把 HTTP 细节挡在协调逻辑之外,单飞与代际守卫因此可以纯 JVM 单测。
*/
sealed interface RefreshCall {
/** 200:新的访问令牌;[refreshToken] 为 null 表示服务端关闭了轮换、旧凭据继续有效。 */
data class Ok(val token: String, val expiresAt: Long, val refreshToken: String?) : RefreshCall
/** `401 invalid_grant`。 */
data object Rejected : RefreshCall
/** 网络 / 5xx / 解析失败。 */
data object Failed : RefreshCall
}
/**
* 单飞(single-flight)刷新协调器。
*
* ## 为什么必须单飞
* 服务端 `RefreshStore.Rotate` 是**原子且无宽限期**的:同一个 refresh token 被第二次使用会立刻
* 拿到 `invalid_grant`(防重放)。而本端并发 401 是常态——管理概览页两个请求并发、聊天页 REST 与
* WS 同时起步。所以同一时刻只允许一个线程真正发刷新请求,其余线程在锁上等待后复用结果。
*
* ## 代际守卫
* 拿到锁之后**重新读一次存储**:如果已存的访问令牌既不是自己手里那份([renew] 的 `staleToken`)、
* 又还没到过期点,说明等在锁上的这段时间里别的线程已经换好了令牌——直接复用,绝不刷第二次。
*
* ## 线程模型
* 全部逻辑跑在**调用方线程**上(OkHttp 的调用线程 / WS 重连协程),用 JVM 监视器锁串行化。
* 因此这里没有、也不能用协程 `Mutex`——调用点根本没有 suspend 上下文。
* 真实的刷新请求由调用方用**不带令牌**的客户端发出(见 `AuthManager.refreshCall`),
* 不会递归回本类。
*
* @param store 会话读写;实现必须让写入立刻对下一次 [SessionStorage.load] 可见。
* @param call 真正发刷新请求的阻塞调用。
* @param now 当前毫秒时间戳,可注入以便单测。
*/
class TokenRefresher(
private val store: SessionStorage,
private val call: (refreshToken: String) -> RefreshCall,
private val now: () -> Long = System::currentTimeMillis,
) {
private val lock = Any()
/**
* 已经确定无效的那个 refresh token。`invalid_grant` 对该值而言是**永久**结论,
* 记下来可以让同一次失效里的其它并发 401 直接得到终态,不必各打一次注定失败的请求。
* 值本身随机且每次轮换都变,因此不需要额外失效逻辑。
*/
private var rejectedRefreshToken: String? = null
/** 距过期不足 [SKEW_MS] 就算「该提前换令牌了」。 */
fun isNearExpiry(session: StoredSession): Boolean =
session.expiresAt * 1000 - now() <= SKEW_MS
/**
* 阻塞地给出一个当下可用的访问令牌;临近过期时先走一次单飞刷新。
* 供 OkHttp 的 tokenProvider 与 WS 握手使用(每次重连都会重新读)。
*
* 刷新失败时**仍然返回手里那份令牌**——它可能还没真的过期,401 交给兜底路径处理;
* 在这里返回 null 会让整条请求链断掉,比让服务端来裁决更糟。
*/
fun ensureToken(): String? {
val session = store.load()
val token = session.token?.takeIf(String::isNotBlank) ?: return null
if (!isNearExpiry(session)) return token
return when (val recovery = renew(token)) {
is AuthRecovery.Renewed -> recovery.token
AuthRecovery.Unrecoverable, AuthRecovery.Transient -> token
}
}
/**
* 单飞刷新:同一时刻只有一个线程真正发请求,其余线程在锁上等待并复用结果。
*
* @param staleToken 触发续期的那次请求实际携带的令牌;null 表示只知道「手上没有可用的令牌」。
*/
fun renew(staleToken: String?): AuthRecovery = synchronized(lock) {
val session = store.load()
// 代际守卫:等在锁上的这段时间里,别的线程已经换好了令牌 → 复用,绝不用同一个 refresh token 打第二次。
val current = session.token
if (!current.isNullOrBlank() && current != staleToken && !isNearExpiry(session)) {
return@synchronized AuthRecovery.Renewed(current)
}
val refreshToken = session.refreshToken?.takeIf(String::isNotBlank)
?: return@synchronized AuthRecovery.Unrecoverable
if (refreshToken == rejectedRefreshToken) return@synchronized AuthRecovery.Unrecoverable
when (val result = call(refreshToken)) {
is RefreshCall.Ok -> {
rejectedRefreshToken = null
store.updateTokens(result.token, result.expiresAt, result.refreshToken)
AuthRecovery.Renewed(result.token)
}
RefreshCall.Rejected -> {
rejectedRefreshToken = refreshToken
AuthRecovery.Unrecoverable
}
RefreshCall.Failed -> AuthRecovery.Transient
}
}
private companion object {
/** 提前量:24h 的访问令牌留 60s 余量,避免踩着过期边界发请求。 */
const val SKEW_MS = 60_000L
}
}
@@ -0,0 +1,29 @@
package top.yeij.cyrene.core.auth
/**
* 「带了令牌仍然 401」的处置策略。
*
* 由 `ApiClientFactory` 的拦截器在 **OkHttp 的调用线程上同步调用**,所以实现必须是
* 阻塞且线程安全的——那里没有 suspend 上下文,协程 `Mutex` 帮不上忙。
*/
interface UnauthorizedPolicy {
/**
* 尝试把被服务端拒绝的令牌换成可用的新令牌。
*
* @param usedToken 触发这次 401 的请求实际携带的访问令牌。
* @return [AuthRecovery.Renewed] 时调用方会用新令牌重试一次;另外两种都不重试。
*/
fun renew(usedToken: String): AuthRecovery
/** 终态:续期不可能(没有 refresh token)或明确被拒(`invalid_grant`)——必须重新登录。 */
fun onSessionEnded()
}
/** 不带令牌的客户端(登录 / 刷新 / 登出)用:永不续期,也永不判终态。 */
object NoUnauthorizedPolicy : UnauthorizedPolicy {
override fun renew(usedToken: String): AuthRecovery = AuthRecovery.Unrecoverable
override fun onSessionEnded() = Unit
}
@@ -0,0 +1,53 @@
package top.yeij.cyrene.core.error
/**
* 统一错误模型:区分「服务端有响应」与「本地/网络层失败」。
* 约定(S1.1):已建立连接时用服务端 `error.message`,本地错误由客户端负责文案。
*/
sealed interface CyreneError {
val userMessage: String
data class Server(
val code: String,
val message: String,
val httpStatus: Int,
) : CyreneError {
override val userMessage: String
get() = message.ifBlank { "服务端错误(HTTP $httpStatus)" }
}
data class Unauthorized(
val code: String,
val message: String,
) : CyreneError {
override val userMessage: String
get() = message.ifBlank { "用户名或密码不正确" }
}
data class Local(val kind: LocalKind) : CyreneError {
override val userMessage: String
get() = when (kind) {
LocalKind.InvalidUrl -> "服务器地址格式不对,示例:http://192.168.1.10:8080"
LocalKind.NoNetwork -> "网络不可用,请检查连接"
LocalKind.Dns -> "找不到服务器,请检查服务器地址"
LocalKind.Tls -> "证书校验失败(自签证书暂不支持)"
LocalKind.Timeout -> "连接超时,服务器没有响应"
LocalKind.ConnectFailed -> "无法连接到服务器"
LocalKind.CleartextBlocked -> "系统禁止明文 HTTP 访问"
LocalKind.Parse -> "服务器返回的数据无法识别"
LocalKind.Unknown -> "发生未知错误"
}
}
enum class LocalKind {
InvalidUrl,
NoNetwork,
Dns,
Tls,
Timeout,
ConnectFailed,
CleartextBlocked,
Parse,
Unknown,
}
}
@@ -0,0 +1,106 @@
package top.yeij.cyrene.core.prefs
import android.content.Context
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.appDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_app")
enum class ThemeMode { System, Light, Dark }
/**
* 她正忙时按发送的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`)。
*
* @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **整键省略**
* (服务端视同 `"all"`,与加入 `scope` 之前那条帧逐字节相同)。
*/
enum class InterruptMode(val wireScope: String?) {
/**
* 追加队列(默认):忙时只把消息排进队列,不打断在跑回合。
*
* 该模式**不发** `interrupt`;一旦因别的触发点要发,就用省略 `scope` 的旧形状
* (服务端 = `all`:取消在跑回合 + 清空待处理队列)——这是与旧版逐字节兼容的那一档。
*/
AppendQueue(null),
/**
* 打断:只取消在跑回合、**保留**待处理队列(`scope:"current"`)。
* 队列里的消息按编排器正常续跑规则继续执行,一条都不丢。
*/
Interrupt("current"),
}
/**
* App 级偏好(非机密)。
*
* @param themeMode 主题深浅
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
* @param thinkingExpanded 思维链卡片默认是否展开
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
*/
data class AppPrefs(
val themeMode: ThemeMode = ThemeMode.System,
val showNarration: Boolean = true,
val thinkingExpanded: Boolean = true,
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
)
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
val LocalAppPrefs = staticCompositionLocalOf { AppPrefs() }
class AppSettings(context: Context) {
private val store: DataStore<Preferences> = context.applicationContext.appDataStore
val data: Flow<AppPrefs> = store.data.map { prefs ->
AppPrefs(
themeMode = prefs[KEY_THEME_MODE].toThemeMode(),
showNarration = prefs[KEY_SHOW_NARRATION] ?: true,
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
interruptMode = prefs[KEY_INTERRUPT_MODE].toInterruptMode(),
)
}
suspend fun setThemeMode(mode: ThemeMode) {
store.edit { it[KEY_THEME_MODE] = mode.name }
}
suspend fun setShowNarration(enabled: Boolean) {
store.edit { it[KEY_SHOW_NARRATION] = enabled }
}
suspend fun setThinkingExpanded(enabled: Boolean) {
store.edit { it[KEY_THINKING_EXPANDED] = enabled }
}
suspend fun setInterruptMode(mode: InterruptMode) {
store.edit { it[KEY_INTERRUPT_MODE] = mode.name }
}
private fun String?.toThemeMode(): ThemeMode = when (this) {
ThemeMode.Light.name -> ThemeMode.Light
ThemeMode.Dark.name -> ThemeMode.Dark
else -> ThemeMode.System
}
/** 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。 */
private fun String?.toInterruptMode(): InterruptMode = when (this) {
InterruptMode.Interrupt.name -> InterruptMode.Interrupt
else -> InterruptMode.AppendQueue
}
private companion object {
val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode")
}
}
@@ -0,0 +1,63 @@
package top.yeij.cyrene.core.prefs
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.dto.EnumTableDto
private val Context.enumDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_enums")
/**
* 枚举权威表的本地回退快照(§1.1)。
*
* @param table 上次成功拉取的权威表;拉取失败时先用它渲染(绝不因此清空角标)
* @param fingerprint `baseUrl + username`:换服务器/账号后快照作废
*/
data class EnumPrefs(
val table: EnumTableDto? = null,
val fingerprint: String = "",
) {
/** 指纹匹配才认这份快照——绝不把别的服务器/账号的标签拿来用。 */
fun tableFor(fingerprint: String): EnumTableDto? = table?.takeIf { this.fingerprint == fingerprint }
}
class EnumPreferences(context: Context) {
private val json = ApiClientFactory.json
private val store: DataStore<Preferences> = context.applicationContext.enumDataStore
val data: Flow<EnumPrefs> = store.data.map { prefs ->
EnumPrefs(
table = prefs[KEY_TABLE].decodeTable(),
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
)
}
suspend fun save(table: EnumTableDto, fingerprint: String) {
store.edit { prefs ->
prefs[KEY_TABLE] = json.encodeToString(EnumTableDto.serializer(), table)
prefs[KEY_FINGERPRINT] = fingerprint
}
}
/** 登出 / 切换服务器时清空。 */
suspend fun clear() {
store.edit { it.clear() }
}
private fun String?.decodeTable(): EnumTableDto? = this
?.let { runCatching { json.decodeFromString(EnumTableDto.serializer(), it) }.getOrNull() }
private companion object {
val KEY_TABLE = stringPreferencesKey("enum_table")
val KEY_FINGERPRINT = stringPreferencesKey("enum_fingerprint")
}
}
@@ -0,0 +1,95 @@
package top.yeij.cyrene.core.prefs
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.ListSerializer
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_sessions")
/**
* 会话列表的本地回退快照与显示态(§3.1)。
*
* @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染
* @param snapshotAt 快照时间(用于「同步于 N 分钟前」)
* @param fingerprint `baseUrl + username`;换服务器/账号后快照作废
* @param activeSessionId 上次停留的会话
*/
data class SessionPrefs(
val snapshot: List<SessionSummaryDto> = emptyList(),
val snapshotAt: Long = 0L,
val fingerprint: String = "",
val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
) {
/** 指纹匹配才认这份快照——绝不把别的服务器/账号的会话列表拿来用。 */
fun snapshotFor(fingerprint: String): List<SessionSummaryDto> =
snapshot.takeIf { this.fingerprint == fingerprint }.orEmpty()
}
class SessionPreferences(context: Context) {
private val json = ApiClientFactory.json
private val store: DataStore<Preferences> = context.applicationContext.sessionDataStore
val data: Flow<SessionPrefs> = store.data.map { prefs ->
SessionPrefs(
snapshot = prefs[KEY_SNAPSHOT].decodeSessions(),
snapshotAt = prefs[KEY_SNAPSHOT_AT] ?: 0L,
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
activeSessionId = prefs[KEY_ACTIVE] ?: SessionSummaryDto.MAIN_SESSION_ID,
)
}
/** 拉取成功:整体替换快照(不做 merge,避免已删除的会话变成幽灵)。 */
suspend fun saveSnapshot(sessions: List<SessionSummaryDto>, fingerprint: String) {
store.edit { prefs ->
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, sessions)
prefs[KEY_SNAPSHOT_AT] = System.currentTimeMillis()
prefs[KEY_FINGERPRINT] = fingerprint
}
}
suspend fun setActive(sessionId: String) {
store.edit { prefs -> prefs[KEY_ACTIVE] = sessionId }
}
/** 删除会话:必要时把 active 退回主会话,并同步从快照里剔除。 */
suspend fun removeSession(sessionId: String) {
store.edit { prefs ->
if (prefs[KEY_ACTIVE] == sessionId) {
prefs[KEY_ACTIVE] = SessionSummaryDto.MAIN_SESSION_ID
}
val remaining = prefs[KEY_SNAPSHOT].decodeSessions().filterNot { it.id == sessionId }
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, remaining)
}
}
/** 登出时清空:快照、指纹、active 一起走,下次登录不会先闪一份旧列表。 */
suspend fun clear() {
store.edit { it.clear() }
}
private fun String?.decodeSessions(): List<SessionSummaryDto> = this
?.let { runCatching { json.decodeFromString(SessionsSerializer, it) }.getOrNull() }
.orEmpty()
private companion object {
val KEY_SNAPSHOT = stringPreferencesKey("sessions_snapshot")
val KEY_SNAPSHOT_AT = longPreferencesKey("sessions_snapshot_at")
val KEY_FINGERPRINT = stringPreferencesKey("sessions_fingerprint")
val KEY_ACTIVE = stringPreferencesKey("active_session_id")
val SessionsSerializer = ListSerializer(SessionSummaryDto.serializer())
}
}
@@ -0,0 +1,16 @@
package top.yeij.cyrene.core.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController
@Composable
fun CyreneTheme(
mode: ColorSchemeMode = ColorSchemeMode.System,
content: @Composable () -> Unit,
) {
val controller = remember(mode) { ThemeController(mode) }
MiuixTheme(controller = controller, content = content)
}
@@ -0,0 +1,39 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
/**
* 管理只读监控端点(api-client §2,全部 `GET`,无写操作)。
*
* RBAC 由服务端兜底(`viewer` 对 ②③ 档一律 403),客户端按 §2.0 权限矩阵**自行降级**,
* 见 [top.yeij.cyrene.domain.admin.AdminAccess];这里只负责取数,不做权限判断。
*/
interface AdminApi {
/** 网关请求指标;`qps` 为近 1 秒窗口计数。 */
@GET("api/v1/admin/observability/metrics")
suspend fun metrics(): Response<ApiEnvelope<AdminMetricsDto>>
/** 最近 trace span,服务端按时间倒序(最新在前);`limit` 缺省 50、上限 500。 */
@GET("api/v1/admin/observability/trace")
suspend fun trace(@Query("limit") limit: Int? = null): Response<ApiEnvelope<List<AdminTraceSpanDto>>>
/** 人格列表(含 admin 专属 `thinking`)。 */
@GET("api/v1/admin/personas")
suspend fun personas(): Response<ApiEnvelope<List<PersonaSummaryDto>>>
/** 私人记忆正文;`viewer` 会拿到 403。 */
@GET("api/v1/admin/memories")
suspend fun memories(): Response<ApiEnvelope<List<MemorySummaryDto>>>
/** 后台思考记录;`viewer` 会拿到 403。 */
@GET("api/v1/admin/backthink")
suspend fun backthink(): Response<ApiEnvelope<List<AdminBackthinkItemDto>>>
}
@@ -0,0 +1,122 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.BuildConfig
import top.yeij.cyrene.core.auth.AuthRecovery
import top.yeij.cyrene.core.auth.NoUnauthorizedPolicy
import top.yeij.cyrene.core.auth.UnauthorizedPolicy
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
object ApiClientFactory {
val json: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
/** 登录 / 刷新 / 登出接口(不注入 token、不参与续期)。 */
fun create(
baseUrl: String,
tokenProvider: () -> String? = { null },
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, unauthorizedPolicy)
/** 通用入口:按需构建任意 Retrofit service。 */
fun <T : Any> create(
baseUrl: String,
service: Class<T>,
tokenProvider: () -> String? = { null },
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
): T {
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.addInterceptor(AuthInterceptor(tokenProvider, unauthorizedPolicy))
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(
HttpLoggingInterceptor().apply {
// BASIC 是上限:`/auth/refresh` 与 `/auth/logout` 把 refresh token 放在**请求体**里,
// 再高一级就会把凭据打进 logcat。
level = HttpLoggingInterceptor.Level.BASIC
redactHeader("Authorization")
},
)
}
}
.build()
return Retrofit.Builder()
.baseUrl(normalizeBaseUrl(baseUrl))
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(service)
}
/** Retrofit 要求 baseUrl 以 `/` 结尾;用户没写 scheme 时补 `http://`。 */
fun normalizeBaseUrl(raw: String): String {
var url = raw.trim()
if (url.isEmpty()) return url
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "http://$url"
}
if (!url.endsWith("/")) url += "/"
return url
}
/**
* 注入 `X-API-Version` 与 `Authorization`,并兜住「**带了令牌仍然** 401」:
* 先向策略要一次新令牌,拿到就用它**重试一次**;明确被拒才判终态。
*
* 为什么是应用层拦截器而不是 OkHttp `Authenticator`:
* `Authenticator` 对**任何** 401 都会被调用(包括不带令牌的登录请求),而且它由
* RetryAndFollowUp 在内部循环里驱动,重试次数(上限 20 次)不受本类控制——一个持续 401 的
* 令牌会打出一串刷新风暴。应用层拦截器只在带令牌时介入,且重试严格一次
* (OkHttp 允许应用层拦截器多次 `proceed()`,`calls != 1` 的检查只对网络拦截器生效)。
*/
private class AuthInterceptor(
private val tokenProvider: () -> String?,
private val unauthorizedPolicy: UnauthorizedPolicy,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider()
var response = chain.proceed(chain.request().withAuth(token))
// 只有「带了 token 仍 401」才算会话被拒;登录/刷新本身不带 token,不受影响。
if (response.code != HTTP_UNAUTHORIZED || token == null) return response
when (val recovery = unauthorizedPolicy.renew(token)) {
is AuthRecovery.Renewed -> {
response.close()
response = chain.proceed(chain.request().withAuth(recovery.token))
// 换上刚签发的令牌仍然 401:服务端根本不接受这个账号,再刷也没有意义,判终态。
if (response.code == HTTP_UNAUTHORIZED) unauthorizedPolicy.onSessionEnded()
}
AuthRecovery.Unrecoverable -> unauthorizedPolicy.onSessionEnded()
// 网络 / 5xx:会话可能还好着,原样把 401 交回去,绝不清会话。
AuthRecovery.Transient -> Unit
}
return response
}
private fun Request.withAuth(token: String?): Request = newBuilder()
.header("X-API-Version", "1")
.apply { if (token != null) header("Authorization", "Bearer $token") }
.build()
}
private const val HTTP_UNAUTHORIZED = 401
}
@@ -0,0 +1,21 @@
package top.yeij.cyrene.data.api
import kotlinx.serialization.Serializable
/** 后端统一信封:成功 `{data}`,失败 `{error:{code,message}}`。 */
@Serializable
data class ApiEnvelope<T>(
val data: T? = null,
val error: ApiError? = null,
)
@Serializable
data class ApiErrorEnvelope(
val error: ApiError? = null,
)
@Serializable
data class ApiError(
val code: String = "",
val message: String = "",
)
@@ -0,0 +1,76 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.core.error.CyreneError
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.SerializationException
import retrofit2.Response
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
/**
* 统一错误映射(S1.1):已建连接用服务端 `error`,本地异常按类型分类。
* 供所有 REST 仓库复用;不要在各个仓库里各写一份。
*/
object ApiErrorMapper {
/** 有 HTTP 响应:401 → [CyreneError.Unauthorized],其余 → [CyreneError.Server]。 */
fun fromHttp(httpStatus: Int, errorBody: String?): CyreneError {
val error = parseErrorEnvelope(errorBody)
val code = error?.code.orEmpty()
val message = error?.message.orEmpty()
return if (httpStatus == 401) {
CyreneError.Unauthorized(code = code, message = message)
} else {
CyreneError.Server(code = code, message = message, httpStatus = httpStatus)
}
}
/** 本地/网络异常 → [CyreneError.Local]。 */
fun fromThrowable(throwable: Throwable): CyreneError = when (throwable) {
is UnknownHostException -> CyreneError.Local(CyreneError.LocalKind.Dns)
is SSLException -> CyreneError.Local(CyreneError.LocalKind.Tls)
is SocketTimeoutException -> CyreneError.Local(CyreneError.LocalKind.Timeout)
is ConnectException -> CyreneError.Local(CyreneError.LocalKind.ConnectFailed)
is SerializationException -> CyreneError.Local(CyreneError.LocalKind.Parse)
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
}
/** 解析 `{"error":{code,message}}`;非信封 / 空白 / 解析失败均返回 null。 */
fun parseErrorEnvelope(errorBody: String?): ApiError? = errorBody
?.takeIf { it.isNotBlank() }
?.let {
runCatching {
ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
}.getOrNull()
}
}
/** 把 [CyreneError] 塞进 Kotlin `Result` 的载体,便于 `Result<T>` 统一传递失败。 */
class CyreneException(val error: CyreneError) : Exception(error.userMessage)
/**
* 执行一次信封调用并把结果收敛成 `Result<T>`:
* 成功且 `data != null` → success;成功但 `data == null` → Parse;否则走 [ApiErrorMapper]。
*/
suspend fun <T : Any> apiCall(block: suspend () -> Response<ApiEnvelope<T>>): Result<T> =
try {
val response = block()
val data = response.body()?.data
when {
response.isSuccessful && data != null -> Result.success(data)
response.isSuccessful -> Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
else -> Result.failure(
CyreneException(
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
),
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
}
@@ -0,0 +1,23 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.ChatAckDto
import top.yeij.cyrene.data.api.dto.ChatRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
/**
* 发送一条消息(`POST /api/v1/chat`,api-client §1 行 3)。权限 `owner|admin`。
*
* 与会话只读的 [SessionsApi]、管理面 [AdminApi] 分开:这是**写**路径,且它的失败面比只读端点
* 复杂得多(`202` / `409` / `500` / `503` 都是编排器的真实回执,不是「请求错了」),
* 放在一起会让只读端点的调用方以为错误处理是同一种。
*
* 返回原始 [Response](而不是直接返回信封):状态码本身是语义的一部分——
* `202` 排队 与 `409` 排队满 必须能被调用方分开,折进 `Result` 前就先丢了。
*/
interface ChatApi {
@POST("api/v1/chat")
suspend fun sendChat(@Body body: ChatRequest): Response<ApiEnvelope<ChatAckDto>>
}
@@ -0,0 +1,23 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.LoginRequest
import top.yeij.cyrene.data.api.dto.LoginResponse
import top.yeij.cyrene.data.api.dto.LogoutRequest
import top.yeij.cyrene.data.api.dto.LogoutResponse
import top.yeij.cyrene.data.api.dto.RefreshRequest
import top.yeij.cyrene.data.api.dto.RefreshResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
interface CyreneApi {
@POST("api/v1/auth/login")
suspend fun login(@Body body: LoginRequest): Response<ApiEnvelope<LoginResponse>>
@POST("api/v1/auth/refresh")
suspend fun refresh(@Body body: RefreshRequest): Response<ApiEnvelope<RefreshResponse>>
@POST("api/v1/auth/logout")
suspend fun logout(@Body body: LogoutRequest): Response<ApiEnvelope<LogoutResponse>>
}
@@ -0,0 +1,16 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.EnumTableDto
import retrofit2.Response
import retrofit2.http.GET
/**
* 共享枚举权威表端点(api-client §1.1 / api-reference §3.7)。
* 任意已认证角色可读(与 `GET /sessions` 同级),无 token 回 `401`;
* 纯内存投影,没有其它错误码。按本工程惯例单独成接口。
*/
interface MetaApi {
@GET("api/v1/meta/enums")
suspend fun enums(): Response<ApiEnvelope<EnumTableDto>>
}
@@ -0,0 +1,78 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.CreateSessionResponse
import top.yeij.cyrene.data.api.dto.DeleteSessionResponse
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/**
* 会话与消息相关端点(api-reference §3.2/§3.4/§3.5/§3.6)。
* 与 M1 的 `CyreneApi` 分开,避免互相污染。
*/
interface SessionsApi {
@GET("api/v1/sessions")
suspend fun listSessions(): Response<ApiEnvelope<List<SessionSummaryDto>>>
/** 无 body;服务端返回 `201 {data:{sessionId}}`。 */
@POST("api/v1/sessions")
suspend fun createSession(): Response<ApiEnvelope<CreateSessionResponse>>
/** 主会话 `web` 删除会返回 `403 forbidden`。 */
@DELETE("api/v1/sessions/{id}")
suspend fun deleteSession(@Path("id") sessionId: String): Response<ApiEnvelope<DeleteSessionResponse>>
/**
* 重命名 / 置顶(`writeAuth`:owner 或 admin)。主会话与次级会话都能改。
*
* - 请求体至少要带一个字段;`{}` → 400。字段缺席即保留原值。
* - `title` 会被 trim;空/全空白 → 400 `title is required`(措辞误导,实际含义是
* **无法把标题清回自动命名**);超过 80 个字符 → 400。
* - 两个字段同时给出时**先改名再置顶**:置顶失败时改名已经落库,不能当成「什么都没发生」。
* - 响应是局部形状,见 [UpdateSessionResponse]。
*/
@PATCH("api/v1/sessions/{id}")
suspend fun updateSession(
@Path("id") sessionId: String,
@Body body: UpdateSessionRequest,
): Response<ApiEnvelope<UpdateSessionResponse>>
/**
* 消息热分页,服务端按 `createdAt` 倒序(newest first)返回。
* @param limit 默认 50、上限 200(由 [top.yeij.cyrene.data.repository.MessageRepository] 钳制)
* @param before 上一页最旧消息的 `createdAt`(Unix 毫秒);null = 最新一页
* @param platform 来源平台过滤;null = 全量
*/
@GET("api/v1/sessions/{id}/messages")
suspend fun listMessages(
@Path("id") sessionId: String,
@Query("limit") limit: Int? = null,
@Query("before") before: Long? = null,
@Query("platform") platform: String? = null,
): Response<ApiEnvelope<List<HistoryMessageDto>>>
/**
* 推进已读游标(D4,契约 v0.37 `api-reference §3.6b`)。
*
* `before` 缺席 = 「现在」;游标**只前进**,所以重复 / 乱序 / 重试都幂等安全。
* 错误面:`before` 非法 → `400`、未知会话(含隔离 `sess-xxx`)→ `404`、
* 未注入 HistoryStore → `501`。成功后服务端会广播 `session_read`(§3.9)。
*/
@POST("api/v1/sessions/{id}/read")
suspend fun markRead(
@Path("id") sessionId: String,
@Body body: ReadSessionRequest,
): Response<ApiEnvelope<ReadSessionResponse>>
}
@@ -0,0 +1,33 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.UploadResponse
import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
/**
* 图片上传端点(`POST /api/v1/upload`,api-client §1 行 13)。权限 `owner|admin`。
*
* **上传本身不是目的**:服务端把 `chat.image` 字符串**原样落库**,于是每一页历史与每一次
* WS `history` 回放都要把整段 base64 再传一遍(协同 G3)。上传一次、消息里只留 URL,
* 是这条链路上唯一能把「历史重量」永久压下去的手段。
*
* 与 [ChatApi] 分开:它的请求体是 multipart(不是 JSON)、错误面也完全不同
* (`413 payload_too_large` / `415 unsupported_media_type` 是它的日常回执)。
*
* 返回原始 [Response] 而不是直接折成 `Result`:与其它端点一样由仓库层统一走
* [ApiErrorMapper],状态码在这里**不承载额外语义**(`413`/`415` 的机器可读区分在
* `error.code` 里,所以仓库能用 [apiCall])。
*/
interface UploadApi {
/**
* @param file **部件名必须是 `file`**(服务端 `r.FormFile("file")`):字段名错了会回
* `400 {error:{code:"bad_request",message:"file is required"}}`(已对着在线后端实测)。
*/
@Multipart
@POST("api/v1/upload")
suspend fun upload(@Part file: MultipartBody.Part): Response<ApiEnvelope<UploadResponse>>
}
@@ -0,0 +1,107 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* `GET /api/v1/admin/observability/metrics`(api-client §2「可观测性契约」)。
* 后端不统计 token / 成本,因此这里也没有对应字段——不伪造指标。
*/
@Serializable
data class AdminMetricsDto(
val totalRequests: Long = 0,
val qps: Double = 0.0,
val avgLatencyMs: Double = 0.0,
val maxLatencyMs: Long = 0,
val perMethod: Map<String, Long> = emptyMap(),
val perPath: Map<String, Long> = emptyMap(),
val statusCounts: Map<String, Long> = emptyMap(),
val llmCalls: Long = 0,
val subagentEvents: Long = 0,
)
/**
* `GET /api/v1/admin/observability/trace?limit=N` 单个 span。
* `kind` / `status` 是开放集合,保留原始字符串(不建枚举)。
* `startedAt` / `endedAt` 为 RFC3339;`props` / `events` 为任意结构,原样透传。
*/
@Serializable
data class AdminTraceSpanDto(
val id: String,
val parentId: String? = null,
val name: String = "",
val kind: String = "",
val status: String = "",
val startedAt: String = "",
val endedAt: String? = null,
val durationNs: Long = 0,
val props: Map<String, JsonElement>? = null,
val events: List<AdminTraceEventDto>? = null,
)
/** `trace.events[]` 一项;`time` 为 RFC3339(纳秒精度)。 */
@Serializable
data class AdminTraceEventDto(
val time: String = "",
val type: String = "",
val props: Map<String, JsonElement>? = null,
)
/**
* 人格摘要(用户侧 `GET /api/v1/personas` 与 admin 侧 `GET /api/v1/admin/personas` 同形)。
* admin 侧多一个 [thinking];未配置该人格时**该键完全不出现**,故为 nullable。
*/
@Serializable
data class PersonaSummaryDto(
val id: String,
val name: String = "",
val character: String = "",
val isDefault: Boolean = false,
val mode: String = "",
val memoryNamespace: String = "",
val avatar: String? = null,
val chatBackground: ChatBackgroundDto? = null,
val mood: String? = null,
val thinking: PersonaThinkingDto? = null,
)
@Serializable
data class ChatBackgroundDto(
val landscape: String? = null,
val portrait: String? = null,
)
/**
* 人格 `thinking` 读侧形状(v0.43)。
* [showToUser] 是**三态**:`null` = 未配置(保持现行为),true/false = 显式选择;
* 不得把 null 拍平成 false。
*/
@Serializable
data class PersonaThinkingDto(
val budgetTokens: Int = 0,
val effort: String = "",
val showToUser: Boolean? = null,
val mode: String = "",
)
/** `GET /api/v1/admin/memories` 一条记忆;`scope` 为开放集合(`user|persona|project`)。 */
@Serializable
data class MemorySummaryDto(
val id: String,
val scope: String = "",
val text: String = "",
)
/**
* `GET /api/v1/admin/backthink` 一条后台思考记录。
* [at] 是 **Unix 毫秒**(与 trace 的 RFC3339 不同源,切勿共用解析)。
*/
@Serializable
data class AdminBackthinkItemDto(
val id: String,
val trigger: String = "",
val conclusion: String? = null,
val shouldNotify: Boolean = false,
val pushed: Boolean = false,
val at: Long = 0,
)
@@ -0,0 +1,59 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `POST /api/v1/chat` 请求体(契约 v0.56,api-client §1 行 3)。
*
* 刻意只带这一个端点要的字段——**不复用整条会话对象**:请求体越小,「WS 帧 4096 字节上限」
* 的反面(REST 请求体没有长度上限)就越不会被无关字段吃掉,抓包时也一眼能看出这一轮发了什么。
*
* 字段语义:
* - [text] / [image] **至少一项非空**,两项都空服务端回 `400 bad_request`;
* - [image] 是 base64 `data:` URI 或 http(s) URL,**解码后上限 5 MiB**
* (超限 `400 image_too_large`,文案指路 `POST /api/v1/upload`);
* - [clientMsgId] 是客户端自带的幂等键:同一会话内**同一个非空值只承认第一条**,
* 重复到达回 `200 {data:{duplicate:true,messageId,sessionId}}` 且不落库、不起回合;
* - [stream] 必须为 `true`(本端**不用** `stream:false`:它是同步的,服务端没有写超时,
* 客户端 60s 读超时会先超时、而回合还在跑——重发就是第二次对话)。
*
* ⚠️ 本项目 `ApiClientFactory.json` 用 kotlinx 默认的 `encodeDefaults = false`:
* 取值等于默认值的字段**整键省略**。因此**纯图片消息**(`text == ""`)线上不会带 `text` 键——
* 服务端 Go 侧零值同为空串,语义完全等价(已按此实现,见 [ChatDtoRealPayloadTest])。
*/
@Serializable
data class ChatRequest(
val sessionId: String? = null,
val text: String = "",
val image: String? = null,
val deviceId: String? = null,
val mode: String? = null,
val stream: Boolean? = null,
val clientMsgId: String? = null,
)
/**
* `POST /api/v1/chat` 的**受理回执**(`data` 载荷)。v0.56 起响应面不再是「任何情况都 200」,
* 四种判定各有形状,因此这里**全部字段可空**、按组合判读,绝不假设某字段恒在:
*
* | 判定 | HTTP | 载荷 |
* |---|---|---|
* | 已起回合 | 200 | `{stream:true}` |
* | 正忙已续接排队 | 202 | `{stream:true,queued:true,queueDepth:n}` |
* | 幂等键重复 | 200 | `{duplicate:true,messageId,sessionId}` |
*
* 其余三种(`409 chat_busy` / `500 chat_unavailable` / `503 chat_dispatch_failed`)是
* `{error:{code,message}}` 信封,由 [top.yeij.cyrene.data.repository.ChatRepository] 走错误路径分类。
*
* 不开枚举:服务端还有别的判定会在将来加进来,未知组合一律按「已受理但形状不认识」处理,
* 不因为不认识一个布尔而把整轮当失败。
*/
@Serializable
data class ChatAckDto(
val stream: Boolean? = null,
val queued: Boolean? = null,
val queueDepth: Int? = null,
val duplicate: Boolean? = null,
val messageId: String? = null,
val sessionId: String? = null,
)
@@ -0,0 +1,59 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
@Serializable
data class LoginRequest(
val username: String,
val password: String,
)
@Serializable
data class LoginResponse(
val token: String,
/** **Unix 秒**(不是毫秒):`time.Now().Add(24h).Unix()`。 */
val expiresAt: Long,
val role: String,
/** B2 的长期凭据;服务端没接线 refresh 存储时整个键缺失。 */
val refreshToken: String? = null,
)
/** `POST /api/v1/auth/refresh` 的请求体(公开接口,凭据就在请求体里)。 */
@Serializable
data class RefreshRequest(
val refreshToken: String,
)
/**
* `POST /api/v1/auth/refresh` 的成功载荷。
*
* - [refreshToken] 在默认(一次性轮换)模式下是**新的**凭据,旧的同一笔交易里作废;
* `AUTH_REFRESH_ROTATE_DISABLED=true` 时服务端不下发,此时旧凭据继续有效——所以这里必须可空,
* 调用方也要在缺失时保留原值。
* - [role] 官方文档漏写,但实现一定会返回;给默认值以免文档形状的响应解不动。
*/
@Serializable
data class RefreshResponse(
val token: String,
/** **Unix 秒**。 */
val expiresAt: Long,
val role: String = "",
val refreshToken: String? = null,
)
/** `POST /api/v1/auth/logout` 的请求体。 */
@Serializable
data class LogoutRequest(
val refreshToken: String,
)
/**
* `/auth/logout` 的响应体。文档与实现互相矛盾(`{"ok":true,"revokedCount":n}` / `{"revokedCount":n}`
* 对上真实信封里的 `{"revoked":bool}`),而本端**只看 HTTP 2xx**——所以每个字段都给默认值,
* 三种形状都能宽容解出,解不动也不影响登出。
*/
@Serializable
data class LogoutResponse(
val revoked: Boolean = false,
val revokedCount: Int = 0,
)
@@ -0,0 +1,31 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `GET /api/v1/sessions/{id}/messages` 单条消息(api-reference §3.4)。
* 服务端按 `createdAt` 倒序返回,渲染前需 reverse / merge。
*/
@Serializable
data class HistoryMessageDto(
val id: String,
val sessionId: String,
val role: String,
val content: String = "",
val mode: String? = null,
val mood: String? = null,
val image: String? = null,
val thinking: String? = null,
val platform: String? = null,
val deviceId: String? = null,
val senderName: String? = null,
val adapterId: String? = null,
/**
* 客户端幂等键(契约 v0.56 §3.13,`omitempty`):带 `clientMsgId` 发出的 user 消息会被
* 服务端**原样回填**,`GET /messages` / WS `history` / `message_persisted` 都能看到。
* 客户端据此把本地乐观气泡对齐到服务端真相——取代「靠文本相等」的脆弱匹配。
* 空 = 这条消息没有键(老客户端发的、IM 镜像进来的、v0.56 之前的历史行)。
*/
val clientMsgId: String? = null,
val createdAt: Long,
)
@@ -0,0 +1,29 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `GET /api/v1/meta/enums` 表内一项(api-client §1.1)。
* [id] 是**唯一可比对**的值(契约 id 为小写、大小写敏感);[label] 只是显示默认值,
* 自带 i18n 的客户端可以覆盖。
*/
@Serializable
data class EnumItemDto(
val id: String = "",
val label: String = "",
)
/**
* `platform` / `mood` / `mode` 的共享枚举权威表——客户端**不必硬编码**即可拿到稳定映射。
*
* 字段全部带默认值:只下发部分表(或 `null`)时仍能解码,缺失的那一类按「表内无此项」
* 走原样回落,而不是让整次解码失败。
* [unknown] 是机器可读的未知值回落约定,当前恒为 `"display-raw"`(原样显示,见 §1.1 红线)。
*/
@Serializable
data class EnumTableDto(
val platforms: List<EnumItemDto> = emptyList(),
val moods: List<EnumItemDto> = emptyList(),
val modes: List<EnumItemDto> = emptyList(),
val unknown: String = "",
)
@@ -0,0 +1,117 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `GET /api/v1/sessions` 持久化会话摘要(api-reference §3.2 A)。
*
* 主会话 `id` 恒为 `"web"`、`type = "main"`,但**不恒在首位**:
* 服务端排序是 `pinned DESC, type = 'main' DESC, created_at ASC`,
* 被置顶的次级会话合法地排在主会话前面。
*
* [title] 在会话还没有任何用户消息时**整个键缺席**(自动命名只在那时写入)——
* 这是正常状态,不是异常;渲染时用客户端兜底标题。
*/
@Serializable
data class SessionSummaryDto(
val id: String,
val type: String,
val title: String? = null,
val createdAt: Long,
val pinned: Boolean = false,
/** 会话还没有消息时**整个键缺席**。 */
val lastMessage: SessionLastMessageDto? = null,
/**
* 未读条数(D4,契约 v0.37 起**恒下发**):`createdAt >` 该会话已读游标的消息条数,
* `0` = 无未读。
*
* 有默认值是为了**旧快照仍能解码**:[top.yeij.cyrene.core.prefs.SessionPreferences]
* 把整个列表原样持久化成 JSON,非默认值会让升级后第一次启动的整份快照解码失败,
* 临时失效(列表先空、等 REST 回来)——那正是本应用最不该出现的表现。
* 默认 `0` 的口径也是安全的:它只会让角标少显示一会儿,`Get /sessions` 一到就被纠正。
*/
val unreadCount: Int = 0,
) {
val isMain: Boolean
get() = type == TYPE_MAIN || id == MAIN_SESSION_ID
companion object {
const val MAIN_SESSION_ID = "web"
const val TYPE_MAIN = "main"
const val TYPE_SUB = "sub"
}
}
/** `POST /api/v1/sessions` → `201 {"data":{"sessionId":"web:<uuid>"}}`。 */
@Serializable
data class CreateSessionResponse(
val sessionId: String,
)
/**
* 会话最后一条消息的摘要(服务端 `historyLastMessage`)。
*
* [content] 已被服务端折叠空白、截断到 60 个字符 + `…`,但**保留原始的 `<p>` / `<action>` 标记**:
* 直接拿来显示会露出标签,渲染前必须剥掉。
*/
@Serializable
data class SessionLastMessageDto(
val role: String,
val content: String = "",
val createdAt: Long,
)
/**
* `PATCH /api/v1/sessions/{id}` 的请求体。
*
* 服务端逐字段用指针判断:**字段缺席即保留原值**,没有「省略即清空」的陷阱;
* 因此这里也必须可空,并且不能被序列化成显式 `null`——
* 生产 `Json` 的 `explicitNulls = false` 正是保证这一点的地方。
*
* **绝不能加 `personaId`**:服务端对任何值的 `personaId` 都先判 400。
*/
@Serializable
data class UpdateSessionRequest(
val title: String? = null,
val pinned: Boolean? = null,
)
/**
* `PATCH /api/v1/sessions/{id}` 的响应是**局部**形状(`{id, title?}` 和/或 `{id, pinned?}`),
* 不是完整的会话行——绝不能拿它当列表行用。
*/
@Serializable
data class UpdateSessionResponse(
val id: String = "",
val title: String? = null,
val pinned: Boolean? = null,
)
/** `DELETE /api/v1/sessions/{id}` → `200 {"data":{"deleted":"<id>"}}`。 */
@Serializable
data class DeleteSessionResponse(
val deleted: String,
)
/**
* `POST /api/v1/sessions/{id}/read`(D4)的请求体:可选地把已读游标推进到 `before`。
*
* - **键缺席 = 「现在」**(「我看到的都算已读」)。生产 `Json` 的 `explicitNulls = false`
* 保证 `before = null` 时整个键缺席,序列化出来是 `{}`——服务端明确接受空对象
* (`api-reference §3.6b`:**空 body / `{}` = 现在**)。
* - 服务端对 `0` / 负数 / 非数字一律 `400`,所以**绝不能**把 `0` 发上去:调用方拿到
* 非正的「没有时间戳」时必须折成缺席(null),而不是原样透传。
* - 游标**只前进**(SQL `MAX(read_at, before)`),重复 / 乱序 / 重试都幂等安全。
*/
@Serializable
data class ReadSessionRequest(
val before: Long? = null,
)
/** `POST /api/v1/sessions/{id}/read` → `200 {"data":{"sessionId":"<id>","readAt":<毫秒>}}`。 */
@Serializable
data class ReadSessionResponse(
val sessionId: String = "",
/** 推进**后**的游标(毫秒 Unix 时间戳,与该会话消息的 `createdAt` 同单位)。 */
val readAt: Long = 0L,
)
@@ -0,0 +1,31 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `POST /api/v1/upload` 的成功载荷(api-client §1 行 13,v0.24 起的 B1 端点)。
*
* 线上形状(`internal/services/gateway/upload.go` 的 `uploadResponse`,字段名逐字如此):
* ```
* 201 {"data":{"url":"http://192.168.1.10:10942/api/v1/files/<32hex>.png","size":1234,"mime":"image/png","name":"<32hex>.png"}}
* ```
*
* 语义要点:
* - [url] 是**绝对同源地址**(`scheme://host/api/v1/files/<name>`),**可直接作 `chat.image`**:
* 视觉层只把 `http(s)://` 开头当 URL,裸路径会被当成 base64 —— 所以这个字段必须按 URL 校验,
* 不能原样塞回去(见 `UploadRepository`);
* - [name] 是服务端生成的 **32 位小写 hex + 白名单扩展名**,客户端原始文件名**被丢弃**(不可信输入);
* - [size] 字节数与 [mime] 都是**服务端嗅探/统计的结果**,不是客户端声明的——客户端传的
* `Content-Type` 一律不被采信(`sniffImageMIME`)。
*
* 全部字段带默认值:解码器是生产同一份 `Json`(`ignoreUnknownKeys` / `coerceInputValues`),
* 将来服务端加键不会让整次上传变成解析失败。**缺 `url`(或 `url` 不是绝对 http(s))仍要判失败**——
* 那是「上传成功但拿不到可用地址」,由仓库层拦,绝不把空串/路径当 URL 发出去。
*/
@Serializable
data class UploadResponse(
val url: String = "",
val size: Long = 0,
val mime: String = "",
val name: String = "",
)
@@ -0,0 +1,133 @@
package top.yeij.cyrene.data.image
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Base64
import top.yeij.cyrene.domain.chat.ChatImageBudget
import java.io.ByteArrayOutputStream
/**
* 一次重编码的产物。
*
* [dataUri] 与 [bytes] 是**同一张图**的两种形态:内联(base64 `data:` URI)用前者,
* 上传(`POST /api/v1/upload`)用后者。上传时**不能**从 [dataUri] 反解一遍——那既多一次
* 解码与分配,也让「上传的字节」和「内联的字节」有漂移的余地。
*/
class PreparedImage(
val dataUri: String,
val bytes: ByteArray,
val mime: String,
)
/**
* 把用户选的本地图片压成可以内联发送的 `data:` URI(**发送前的必经一步**)。
*
* 为什么要重编码而不是直接读原始字节:服务端把 `image` 字符串**原样落库**,
* 每一页历史与每一次 WS `history` 回放都要重传它(协同文档 G3)。所以这里
* 无论用户选了什么,出口都保证 ≤ [ChatImageBudget.MAX_DECODED_BYTES]:
*
* 1. 先按边界解码拿原尺寸 → 等比缩到最长边 [ChatImageBudget.MAX_DIMENSION_PX];
* 2. 编码(有 alpha 用 PNG,否则用 JPEG);
* 3. 仍超预算 → **尺寸减半重来**(最多 [MAX_ATTEMPTS] 档);
* 4. 出口再按服务端的**字符串长度**上限复核一次(4/3 膨胀 + 前缀余量)。
*
* 任何一步失败(读不到 / 解不出 / 编不出 / 压不到预算内)都返回 `null`——由调用方回滚并报错,
* **绝不**退回「原图 base64」:那既会撞服务端 5 MiB 硬顶,也会把历史背成几 MB。
*
* 产物同时给出 `data:` URI 与**同一份**字节:**内联还是上传由调用方按体积定**
* (见 `ChatImageDelivery`)——本对象只管「编到预算内」,通道选择是另一件事,而且它需要
* 编完之后的真实字节数才谈得上。
*
* 预算数学在 [ChatImageBudget](纯函数,可单测);本对象只剩「必须真机才有」的解码与编码。
*/
object ChatImagePreparer {
private const val JPEG_QUALITY = 80
/** 缩不动就减半重来的次数上限(1 MiB 预算下 1280px 起步一般 1–2 次就够)。 */
private const val MAX_ATTEMPTS = 4
/**
* @return 可内联发送的 `data:` URI 与可上传的同一份字节;失败返回 `null`。
* 必须在后台线程调用(解码 + 缩放 + 编码都是重活)。
*/
fun prepare(context: Context, uri: Uri): PreparedImage? {
val raw = readBytes(context, uri) ?: return null
val decoded = decodeScaled(raw) ?: return null
return try {
encodeWithinBudget(decoded)
} finally {
decoded.recycle()
}
}
private fun readBytes(context: Context, uri: Uri): ByteArray? =
runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
/** 按**目标尺寸**两步解码:先读边界算 `inSampleSize`(省内存),再精确缩放到目标。 */
private fun decodeScaled(raw: ByteArray): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(raw, 0, raw.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val target = ChatImageBudget.scaledSize(bounds.outWidth, bounds.outHeight)
val options = BitmapFactory.Options().apply {
inSampleSize = sampleSizeFor(bounds.outWidth, bounds.outHeight, target.width, target.height)
}
val sampled = runCatching { BitmapFactory.decodeByteArray(raw, 0, raw.size, options) }.getOrNull()
?: return null
if (sampled.width == target.width && sampled.height == target.height) return sampled
val scaled = runCatching {
Bitmap.createScaledBitmap(sampled, target.width, target.height, true)
}.getOrNull() ?: return null
if (scaled !== sampled) sampled.recycle()
return scaled
}
/** 在不小于目标尺寸的前提下,取最大的 2 的幂作为采样步长。 */
private fun sampleSizeFor(width: Int, height: Int, targetWidth: Int, targetHeight: Int): Int {
var sample = 1
while (width / (sample * 2) >= targetWidth && height / (sample * 2) >= targetHeight) {
sample *= 2
}
return sample
}
/** 逐档降尺寸,直到编码后字节数落进预算;都落不进返回 `null`。 */
private fun encodeWithinBudget(source: Bitmap): PreparedImage? {
var width = source.width
var height = source.height
repeat(MAX_ATTEMPTS) { attempt ->
val frame = if (attempt == 0) source else scale(source, width, height) ?: return null
// 有 alpha 的图走 JPEG 会把透明区压成黑块,所以这类图保持 PNG(无损、体积更大)。
val mime = if (frame.hasAlpha()) MIME_PNG else MIME_JPEG
val bytes = compress(frame, mime)
if (frame !== source) frame.recycle()
if (ChatImageBudget.withinTarget(bytes.size)) {
val dataUri = ChatImageBudget.dataUri(mime, Base64.encodeToString(bytes, Base64.NO_WRAP))
if (!ChatImageBudget.withinServerLimit(dataUri.length)) return null
return PreparedImage(dataUri = dataUri, bytes = bytes, mime = mime)
}
width = (width / 2).coerceAtLeast(1)
height = (height / 2).coerceAtLeast(1)
}
return null
}
private fun scale(source: Bitmap, width: Int, height: Int): Bitmap? =
runCatching { Bitmap.createScaledBitmap(source, width, height, true) }.getOrNull()
private fun compress(bitmap: Bitmap, mime: String): ByteArray {
val format = if (mime == MIME_PNG) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG
val out = ByteArrayOutputStream()
bitmap.compress(format, JPEG_QUALITY, out)
return out.toByteArray()
}
private const val MIME_JPEG = "image/jpeg"
private const val MIME_PNG = "image/png"
}
@@ -0,0 +1,52 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.api.AdminApi
import top.yeij.cyrene.data.api.apiCall
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 管理只读监控取数层(api-client §2)。
* 权限判断不在这里:仓库只管取数,403 由调用方按 [top.yeij.cyrene.domain.admin.AdminFailure] 收敛。
*/
class AdminRepository(
private val api: AdminApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun metrics(): Result<AdminMetricsDto> = withContext(dispatcher) {
apiCall { api.metrics() }
}
/** `GET /observability/trace`;`limit` 与服务端对齐,钳制在 [1, [MAX_TRACE_LIMIT]]。 */
suspend fun trace(limit: Int = DEFAULT_TRACE_LIMIT): Result<List<AdminTraceSpanDto>> =
withContext(dispatcher) {
apiCall { api.trace(limit.coerceIn(1, MAX_TRACE_LIMIT)) }
}
suspend fun personas(): Result<List<PersonaSummaryDto>> = withContext(dispatcher) {
apiCall { api.personas() }
}
suspend fun memories(): Result<List<MemorySummaryDto>> = withContext(dispatcher) {
apiCall { api.memories() }
}
suspend fun backthink(): Result<List<AdminBackthinkItemDto>> = withContext(dispatcher) {
apiCall { api.backthink() }
}
companion object {
/** trace 拉取条数:够看近期活动,又不会把一屏塞满。 */
const val DEFAULT_TRACE_LIMIT = 100
/** 服务端 `?limit=` 上限。 */
const val MAX_TRACE_LIMIT = 500
}
}
@@ -0,0 +1,102 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiErrorMapper
import top.yeij.cyrene.data.api.ChatApi
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.ChatAckDto
import top.yeij.cyrene.data.api.dto.ChatRequest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* `POST /api/v1/chat` 的受理判定(契约 v0.56)。
*
* 它是一个**密封结果**而不是 `Result.failure`:v0.56 起「会话正忙」与「排队已满」不再是异常,
* 而是编排器的正常回执(修复前 handler 提前 `return 200`,这两种状态**永远不可能出现**)。
* 把它们和网络错误混在同一条失败流里,调用方就只能靠字符串猜——这正是本类型要消除的东西。
*/
sealed interface ChatAck {
/** `200 {stream:true}`:回合已起,内容帧走**已连接的 WS**。 */
data object Accepted : ChatAck
/** `202 {stream:true,queued:true,queueDepth:n}`:会话正忙,这条已续接排队(**已落库**)。 */
data class Queued(val queueDepth: Int) : ChatAck
/** `200 {duplicate:true,messageId,sessionId}`:幂等键重复,服务端**没有**再落库 / 再起回合。 */
data class Duplicate(val messageId: String?, val sessionId: String?) : ChatAck
/** `409 {error:{code:"chat_busy"}}`:待处理队列已满,这条**没有被受理、没有落库**。 */
data object QueueFull : ChatAck
/**
* `500 chat_unavailable` / `503 chat_dispatch_failed`:服务端接不住这一轮。
* [code] 保留原始错误码——两者的区别对排查有用,对用户文案无差别。
*/
data class Unavailable(val code: String, val message: String) : ChatAck
/** 其余明确的拒绝(`400 bad_request` / `400 image_too_large` / 未知码)。 */
data class Rejected(val code: String, val message: String) : ChatAck
}
/**
* 发送一条消息(REST 通道)。
*
* 与别的仓库不同的一点:**非 2xx 里有一半是业务态而不是失败**(见 [ChatAck]),
* 所以这里刻意**不使用** [top.yeij.cyrene.data.api.apiCall]——它把 4xx/5xx 一律折进
* `Result.failure`、且只回 `data` 丢掉状态码,于是 `202` 与 `409` 的差别在折进去的那一刻
* 就没了。信封解析与错误映射仍走 [ApiErrorMapper],口径与其它端点一致。
*
* `Result.failure` 只留给**真正发不出去**的情况:网络 / 超时 / 解析不出来 / 401。
*/
class ChatRepository(
private val api: ChatApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun send(request: ChatRequest): Result<ChatAck> = withContext(dispatcher) {
try {
val response = api.sendChat(request)
val data = response.body()?.data
when {
response.isSuccessful && data != null -> Result.success(ackOf(data))
// 2xx 却没有 data:与 apiCall 同一口径,判解析失败而不是「已受理」。
response.isSuccessful -> Result.failure(parseFailure())
else -> {
val error = ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string())
val ack = serviceAck(error)
if (ack != null) Result.success(ack) else Result.failure(CyreneException(error))
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
}
}
private fun ackOf(data: ChatAckDto): ChatAck = when {
data.duplicate == true -> ChatAck.Duplicate(data.messageId, data.sessionId)
data.queued == true -> ChatAck.Queued(data.queueDepth ?: 0)
// `{stream:true}` 以及将来新增的未知组合:受理了、形状不认识 → 按已受理处理。
else -> ChatAck.Accepted
}
/** 非 2xx 里属于**编排器真实回执**的部分;返回 null = 真失败(401 或未知服务端错误)。 */
private fun serviceAck(error: CyreneError): ChatAck? {
if (error is CyreneError.Unauthorized) return null
val code = (error as? CyreneError.Server)?.code.orEmpty()
val message = error.userMessage
return when (code) {
"chat_busy" -> ChatAck.QueueFull
"chat_unavailable", "chat_dispatch_failed" -> ChatAck.Unavailable(code, message)
else -> ChatAck.Rejected(code, message)
}
}
private fun parseFailure() =
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse))
}
@@ -0,0 +1,61 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.api.apiCall
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 会话消息分页获取层。服务端按倒序返回,这里统一转成升序的 [PagedMessages]。
* 去重 / 合并请用 [mergeMessages]。
*/
class MessageRepository(
private val api: SessionsApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* 拉一页消息。
*
* @param before 上一页最旧消息的 `createdAt`(用 [PagedMessages.oldestCreatedAt]);
* null = 最新一页
* @param platform 来源平台过滤;null = 全量(Web 端共享主会话语义)
*/
suspend fun loadPage(
sessionId: String,
limit: Int = DEFAULT_PAGE_SIZE,
before: Long? = null,
platform: String? = null,
): Result<PagedMessages> = withContext(dispatcher) {
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
apiCall { api.listMessages(sessionId, pageSize, before, platform) }
.map { raw -> toPagedMessages(raw, pageSize) }
}
/** 首屏 / 刷新:最新一页。 */
suspend fun loadLatest(
sessionId: String,
limit: Int = DEFAULT_PAGE_SIZE,
platform: String? = null,
): Result<PagedMessages> = loadPage(sessionId, limit, before = null, platform = platform)
companion object {
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 200
}
}
private fun toPagedMessages(
rawNewestFirst: List<HistoryMessageDto>,
pageSize: Int,
): PagedMessages {
val ascending = rawNewestFirst.sortedWith(MessageOrder)
return PagedMessages(
messages = ascending,
hasMore = rawNewestFirst.size >= pageSize,
oldestCreatedAt = ascending.firstOrNull()?.createdAt,
newestCreatedAt = ascending.lastOrNull()?.createdAt,
)
}
@@ -0,0 +1,22 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import top.yeij.cyrene.data.api.MetaApi
import top.yeij.cyrene.data.api.apiCall
import top.yeij.cyrene.data.api.dto.EnumTableDto
/**
* 枚举权威表取数层(api-client §1.1)。
* 仓库不持有持久化状态:调用方失败时保留上次快照(见 [top.yeij.cyrene.core.prefs.EnumPreferences])。
*/
class MetaRepository(
private val api: MetaApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun enums(): Result<EnumTableDto> = withContext(dispatcher) {
apiCall { api.enums() }
}
}
@@ -0,0 +1,58 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
/**
* 一页消息(已由仓库转成 **升序**,可直接追加渲染)。
*
* @param hasMore 该页是否可能还有更旧的消息(`原始页大小 == 请求页大小` 的保守判断)
* @param oldestCreatedAt 本页最旧一条的 `createdAt`,作为下一页的 `before` 游标
* @param newestCreatedAt 本页最新一条的 `createdAt`
*/
data class PagedMessages(
val messages: List<HistoryMessageDto>,
val hasMore: Boolean,
val oldestCreatedAt: Long?,
val newestCreatedAt: Long?,
)
/** 消息排序:`createdAt` 升序,同毫秒用 `id` 兜底,保证稳定可复现。 */
internal val MessageOrder: Comparator<HistoryMessageDto> = compareBy<HistoryMessageDto>({ it.createdAt }, { it.id })
/**
* 纯函数合并:`existing`(本地已加载,任意序)+ `incoming`(服务端新到的一页,倒序)
* → 按 `id` 去重、按 `createdAt` 升序(同毫秒用 `id` 兜底)的结果。
*
* 冲突时 **incoming 胜出**(§3.2「服务端优先」)。不修改入参,返回新列表。
* 时间复杂度 O((n+m) log(n+m)),空间 O(n+m)。
*/
fun mergeMessages(
existing: List<HistoryMessageDto>,
incoming: List<HistoryMessageDto>,
): List<HistoryMessageDto> {
if (incoming.isEmpty()) return existing.sortedWith(MessageOrder)
if (existing.isEmpty()) return incoming.sortedWith(MessageOrder)
val byId = LinkedHashMap<String, HistoryMessageDto>(existing.size + incoming.size)
existing.forEach { byId[it.id] = it }
incoming.forEach { byId[it.id] = it }
return byId.values.sortedWith(MessageOrder)
}
/**
* 把一帧 `history` 收敛到**本会话**。
*
* WS 首帧 `history` 是**主会话**最近 50 条的回放,但连接是全局的、帧会被投给任意
* `ChatViewModel`——不过滤就会把主会话的消息混进次级会话。
*
* 判定以**消息自身的 `sessionId`** 为准,而不是「只有主会话才收 history」:前者更耐将来,
* 服务端哪天开始给次级会话回放,这里无需改动。
*
* `sessionId` 为空的帧视为「无法判定」**放行**——不因缺一个字段就整段丢掉历史(保持既有行为)。
*
* 纯函数,不修改入参。
*/
fun historyForSession(
messages: List<HistoryMessageDto>,
sessionId: String,
): List<HistoryMessageDto> = messages.filter { it.sessionId.isBlank() || it.sessionId == sessionId }
@@ -0,0 +1,68 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.api.apiCall
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 会话列表 / 新建 / 重命名置顶 / 删除的数据获取层。
*
* 列表策略(§3.1):调用方拿到结果后**整体替换**本地快照,不做 merge;
* 失败时调用方保留旧快照。仓库本身不持有任何持久化状态。
*/
class SessionRepository(
private val api: SessionsApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* `GET /sessions`,原样返回服务端顺序——**不做客户端排序**:
* `pinned DESC, type='main' DESC, created_at ASC`,置顶的次级会话合法地排在主会话前面。
*/
suspend fun listSessions(): Result<List<SessionSummaryDto>> = withContext(dispatcher) {
apiCall { api.listSessions() }
}
/** `POST /sessions`,成功返回新建的 `sessionId`(形如 `web:<uuid>`)。 */
suspend fun createSession(): Result<String> = withContext(dispatcher) {
apiCall { api.createSession() }.map { it.sessionId }
}
/**
* `PATCH /sessions/{id}`:只发本次要改的字段,缺席的字段服务端保持原值
* (所以 `null` 是「不改」,不是「清空」——服务端也无法把标题清回自动命名)。
*/
suspend fun updateSession(
sessionId: String,
title: String? = null,
pinned: Boolean? = null,
): Result<UpdateSessionResponse> = withContext(dispatcher) {
apiCall { api.updateSession(sessionId, UpdateSessionRequest(title = title, pinned = pinned)) }
}
/** `DELETE /sessions/{id}`,成功返回被删除的会话 id;主会话会以 403 失败。 */
suspend fun deleteSession(sessionId: String): Result<String> = withContext(dispatcher) {
apiCall { api.deleteSession(sessionId) }.map { it.deleted }
}
/**
* `POST /sessions/{id}/read`(D4):推进该会话的已读游标,成功返回**推进后**的游标。
*
* @param before 毫秒时间戳;**null = 现在**(「我看到的都算已读」)。服务端对 `0` / 负数
* 一律 `400`,所以调用方必须把「没有时间戳」表达成 null 而不是 0。
*
* 这是**幂等**的(服务端 `MAX(read_at, before)`,只前进),失败也没有副作用——
* 唯一真源仍是服务端游标,漏标一次会在下一次 `GET /sessions` 自然暴露、再被纠正。
*/
suspend fun markRead(sessionId: String, before: Long? = null): Result<ReadSessionResponse> =
withContext(dispatcher) {
apiCall { api.markRead(sessionId, ReadSessionRequest(before = before)) }
}
}
@@ -0,0 +1,112 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.UploadApi
import top.yeij.cyrene.data.api.apiCall
/**
* 一次成功上传的可用形态。
*
* 调用方(`ChatViewModel`)只需要 [url],其余三个字段由服务端生成、留着排查用:
* [size] 是服务端统计的字节数、[mime] 是**服务端嗅探**出的类型(不是客户端声明的)、
* [name] 是服务端存储名(客户端原始文件名已被丢弃)。
*/
data class UploadedImage(
val url: String,
val size: Long,
val mime: String,
val name: String,
)
/**
* 图片上传取数层(`POST /api/v1/upload`)。
*
* ## 为什么这台机器存在
* 服务端把 `chat.image` 字符串**原样落库**(api-client §1 行 3 + 协同 G3):内联的 base64
* 会在**每一页历史**与**每一次 WS `history` 回放**里重传一遍。上传一次、消息里只留一个
* URL,是唯一能让历史不再随图片长大的路。
*
* ## 错误语义(这里是本仓库与只读仓库的差别)
* 用 [apiCall]:它的状态码**不承载额外语义**——`413 payload_too_large` 与
* `415 unsupported_media_type` 的机器可读区分都在 `error.code` 里(已对着在线后端实测,
* 见 `UploadDtoRealPayloadTest`),不像 `POST /api/v1/chat` 那样必须靠状态码分 `202` / `409`。
* 于是网络 / 超时 / `401` / 服务端拒绝一律折进 `Result.failure`,调用方按 `code` 或
* [CyreneError] 的类型分支。
*
* ## 两处**客户端**必须自己把关的地方(服务端帮不了)
* 1. **`url` 必须是绝对 http(s) 地址**:`chat.image` 的视觉层只把 `http(s)://` 当 URL
* (`vision.ParseImageRef` 的判据就是 `strings.HasPrefix(src, "http://")`),裸路径
* (如 `/api/v1/files/x.png`)会被当成 **base64** 载荷。拿到这种 url 还照发,就是把这一轮
* 丢进「图片解析失败」的黑洞——所以这里直接判解析失败,宁可当场重试。
* 2. **`url` 不能为空**:`201` 却没有可用地址 = 上传了但拿不到东西,同样判失败,**绝不**
* 退回内联 base64(那正是本任务要消灭的成本)。
*/
class UploadRepository(
private val api: UploadApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* 上传一张**已重编码**的图片,返回可作 `chat.image` 的绝对 URL。
*
* [bytes] 与 [mime] 必须来自同一次编码(`ChatImagePreparer` 的产物):
* 服务端按**字节嗅探**判类型,声明的 `Content-Type` 只作参考,两者不一致时以字节为准。
*/
suspend fun uploadImage(bytes: ByteArray, mime: String): Result<UploadedImage> =
withContext(dispatcher) {
apiCall { api.upload(partOf(bytes, mime)) }.fold(
onSuccess = { data ->
val url = absoluteHttpUrl(data.url)
if (url == null) {
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
} else {
Result.success(
UploadedImage(url = url, size = data.size, mime = data.mime, name = data.name),
)
}
},
onFailure = { Result.failure(it) },
)
}
/**
* multipart 部件。
*
* **部件名固定 `file`**(服务端 `FormFile("file")`)。文件名也**必须给**:Go 的
* `mime/multipart` 只把带 `filename` 参数的部件当**文件**,不带的会落进普通表单值、
* 于是 `FormFile` 找不到它。扩展名与服务端白名单一致(服务端丢弃原始名,这里只是让
* 线上的部件看起来像一张真图,中间件不会因为怪名字做多余判断)。
*/
private fun partOf(bytes: ByteArray, mime: String): MultipartBody.Part =
MultipartBody.Part.createFormData(
name = UPLOAD_FIELD_NAME,
filename = fileNameFor(mime),
body = bytes.toRequestBody(mime.toMediaTypeOrNull()),
)
private fun fileNameFor(mime: String): String = "upload." + when (mime) {
MIME_PNG -> "png"
MIME_GIF -> "gif"
MIME_WEBP -> "webp"
MIME_BMP -> "bmp"
else -> "jpg"
}
/** 只认绝对 http(s) 地址,且**大小写敏感**(与服务端 `ParseImageRef` 的判据同一口径:放宽只会放行服务端不认的东西)。 */
private fun absoluteHttpUrl(raw: String): String? = raw.trim()
.takeIf { it.startsWith("http://") || it.startsWith("https://") }
}
private const val UPLOAD_FIELD_NAME = "file"
private const val MIME_PNG = "image/png"
private const val MIME_GIF = "image/gif"
private const val MIME_WEBP = "image/webp"
private const val MIME_BMP = "image/bmp"
@@ -0,0 +1,319 @@
package top.yeij.cyrene.data.ws
import top.yeij.cyrene.core.auth.AuthRecovery
import top.yeij.cyrene.data.api.ApiClientFactory
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.TimeUnit
import kotlin.random.Random
/**
* Cyrene 实时通道客户端(`GET /ws/chat?token=<JWT>`,api-reference §6)。
*
* ## 用法
* ```
* val socket = ChatSocket(baseUrlProvider = { cfg.baseUrl }, tokenProvider = { auth.token },
* onAuthFailed = { auth.logout() })
* socket.connect()
* // 收集 socket.events,观察 socket.state
* socket.sendChat(sessionId = "web", text = "你好", deviceId = deviceId)
* socket.close()
* ```
*
* ## 会话归属
* 服务端自 v0.36 起在**每个结构化流帧的帧壳**上带 `sessionId`,本类把它原样透传
* (见 [WsEvent.sessionId]),不补值。服务端广播**不按会话订阅**:同一条连接会收到**所有**
* 会话的帧,因此收集 [events] 的上层**必须**按 [WsEvent.isForSession] 收敛到自己的会话。
*
* ## 心跳与重连
* - OkHttp `pingInterval` 30s(服务端 ping 周期 54s / pong 等待 60s,客户端 30s 足够)。
* - 服务端**没有 resume / 补发**:重连成功后由上层用新连接的 `history` 帧 + REST 补差去重。
* - 退避 1s/2s/4s/8s/16s/30s(上限 30s,含抖动)。
* - 握手返回 **HTTP 401** → 先向 [onAuthRecover] 要一次续期(B2):换到新令牌就**立刻重连一次**;
* 明确被拒或重连后仍 401 → [WsState.Offline] 并触发 [onAuthFailed];暂时性失败走普通退避。
*
* ## 坏帧
* 每帧解析都走 [WsEvent.parse],非 JSON / 未知 `type` / 结构不匹配一律丢弃,不会中断连接。
*
* @param baseUrlProvider 服务器根地址(如 `http://host:8080`),每次重连时重新读取。
* @param tokenProvider 当前 JWT,每次重连时重新读取(续期后无需重建实例)。
* @param onAuthRecover 握手 401 时的一次性兜底,见 [AuthRecovery]。
* @param onAuthFailed 终态回调(令牌被拒且无法续期);在协程内调用,注意切主线程。
* @param client 可注入自定义 OkHttpClient(会 `newBuilder` 派生,保留其拦截器);缺省新建。
*/
class ChatSocket(
private val baseUrlProvider: () -> String?,
private val tokenProvider: () -> String?,
private val onAuthRecover: () -> AuthRecovery = { AuthRecovery.Unrecoverable },
private val onAuthFailed: () -> Unit = {},
client: OkHttpClient? = null,
) {
private val _state = MutableStateFlow(WsState.Idle)
/** 连接状态机;见 [WsState]。 */
val state: StateFlow<WsState> = _state.asStateFlow()
private val _events = MutableSharedFlow<WsEvent>(
replay = 0,
extraBufferCapacity = EVENT_BUFFER,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/**
* 下行事件流。
*
* `replay = 0`:重放旧 delta 会让新订阅者重复渲染,会话状态应由 `history` / REST 重建。
* `extraBufferCapacity = 512 + DROP_OLDEST`:发送方是 OkHttp 读线程,不能阻塞;溢出时丢最旧的
* 增量帧(服务端发送缓冲 32 本就会丢帧,`stream_end` / `history` 仍可兜底)。
*/
val events: SharedFlow<WsEvent> = _events.asSharedFlow()
private val wsClient: OkHttpClient = (client ?: OkHttpClient()).newBuilder()
.pingInterval(PING_INTERVAL_SECONDS, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
@Volatile
private var wantConnection: Boolean = false
@Volatile
private var webSocket: WebSocket? = null
@Volatile
private var attempt: Int = 0
@Volatile
private var sessionJob: Job? = null
/** 本次 [connect] 是否已经用续期后的令牌重连过一次——避免握手 401 打成死循环。 */
@Volatile
private var authRetried: Boolean = false
private var scope: CoroutineScope? = null
/** 开始连接(幂等:已在连接 / 重连中时忽略)。 */
fun connect() {
if (wantConnection) return
wantConnection = true
attempt = 0
authRetried = false
sessionJob = ensureScope().launch { runConnectionLoop() }
}
/** 主动断开并停掉重连;之后可再次 [connect]。 */
fun close() {
wantConnection = false
val socket = webSocket
webSocket = null
socket?.close(CLOSE_CODE_NORMAL, CLOSE_REASON_CLIENT)
sessionJob?.cancel()
sessionJob = null
_state.value = WsState.Idle
scope?.cancel()
scope = null
}
/**
* 发送 `chat`(api-reference §6.3)。
* @param clientMsgId 幂等键(契约 v0.56 §3.13);重复到达服务端回 `chat_duplicate`,不落库。
* @return 未连接或写失败返回 `false`(本层不做离线队列,由上层标红重试)。
*/
fun sendChat(
sessionId: String?,
text: String,
deviceId: String?,
clientMsgId: String? = null,
): Boolean = send(
WsOutbound.Chat(
text = text,
sessionId = sessionId,
deviceId = deviceId,
clientMsgId = clientMsgId,
),
)
/**
* 发送 `interrupt`(api-client §3.1;契约 v0.56/A2 起支持 `scope`)。
*
* @param scope 打断范围:`null` = **省略该键**(服务端视同 `"all"`:取消在跑回合 + 清空待处理队列,
* 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。
* 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。
*
* **今天没有任何调用方**:聊天的「打断」触发点属聊天区那一轮,本 API 先把传输面备好——
* 调用方从 App 偏好(`InterruptMode`)取 `scope` 后传进来即可。
*/
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
private fun send(outbound: WsOutbound): Boolean {
if (_state.value != WsState.Connected) return false
val socket = webSocket ?: return false
return runCatching { socket.send(outbound.toJson()) }.getOrDefault(false)
}
private suspend fun runConnectionLoop() {
try {
while (wantConnection) {
when (val end = runSession()) {
SessionEnd.ConfigMissing -> {
wantConnection = false
_state.value = WsState.Offline
}
SessionEnd.AuthFailed -> {
if (!recoverAuth()) break
}
SessionEnd.Closed -> {
if (!wantConnection) break
_state.value = WsState.Reconnecting
delay(backoffDelayMs(attempt))
attempt += 1
}
}
}
} catch (_: CancellationException) {
// close() 主动取消,正常退出。
}
}
/**
* 握手被拒(HTTP 401)后的恢复(B2):
* - [AuthRecovery.Renewed]:已换到新令牌,**立刻重连一次**(`tokenProvider` 每次重连都会重新读);
* - [AuthRecovery.Unrecoverable]:令牌被服务端拒绝且无法续期 → 终态,触发 [onAuthFailed];
* - [AuthRecovery.Transient]:暂时续不上(网络 / 5xx),会话保留,走普通退避重连。
*
* @return 连接循环是否继续。
*/
private suspend fun recoverAuth(): Boolean {
if (authRetried) return endWithAuthFailure()
return when (onAuthRecover()) {
is AuthRecovery.Renewed -> {
authRetried = true
attempt = 0
_state.value = WsState.Reconnecting
true
}
AuthRecovery.Transient -> {
_state.value = WsState.Reconnecting
delay(backoffDelayMs(attempt))
attempt += 1
true
}
AuthRecovery.Unrecoverable -> endWithAuthFailure()
}
}
private fun endWithAuthFailure(): Boolean {
wantConnection = false
_state.value = WsState.Offline
onAuthFailed()
return false
}
/** 建立一次连接并挂起,直到它关闭 / 失败 / 被取消。 */
private suspend fun runSession(): SessionEnd {
val url = buildUrl() ?: return SessionEnd.ConfigMissing
_state.value = if (attempt == 0) WsState.Connecting else WsState.Reconnecting
val finished = CompletableDeferred<SessionEnd>()
val socket = wsClient.newWebSocket(
Request.Builder().url(url).build(),
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
attempt = 0
_state.value = WsState.Connected
}
override fun onMessage(webSocket: WebSocket, text: String) {
// 未知 type / 坏帧由 parse 返回 null,静默丢弃。
WsEvent.parse(text)?.let { _events.tryEmit(it) }
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
finished.complete(SessionEnd.Closed)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
val end = if (isAuthFailure(response, t)) SessionEnd.AuthFailed else SessionEnd.Closed
finished.complete(end)
}
},
)
webSocket = socket
return try {
finished.await()
} finally {
if (webSocket === socket) webSocket = null
socket.cancel()
}
}
/**
* 握手鉴权失败是**握手前**的 HTTP 401(不是 WS close),OkHttp 会把它随 [onFailure] 的
* `response` 一起回调;个别路径拿不到 response 时再退化为消息文本匹配。
*/
private fun isAuthFailure(response: Response?, t: Throwable): Boolean {
if (response?.code == 401) return true
return t.message?.contains("401") == true
}
private fun buildUrl(): HttpUrl? {
val token = tokenProvider()?.takeIf { it.isNotBlank() } ?: return null
val base = baseUrlProvider()?.takeIf { it.isNotBlank() } ?: return null
val httpUrl = ApiClientFactory.normalizeBaseUrl(base).toHttpUrlOrNull() ?: return null
return httpUrl.newBuilder()
.addPathSegments("ws/chat")
.addQueryParameter("token", token)
.build()
}
/** 1s/2s/4s/8s/16s/30s,上限 30s;加 0~25% 抖动避免同时重连。 */
private fun backoffDelayMs(attempt: Int): Long {
val shift = attempt.coerceIn(0, MAX_BACKOFF_SHIFT)
val base = (INITIAL_BACKOFF_MS shl shift).coerceAtMost(MAX_BACKOFF_MS)
val jitter = Random.nextLong(base / JITTER_DIVISOR + 1)
return (base + jitter).coerceAtMost(MAX_BACKOFF_MS)
}
private fun ensureScope(): CoroutineScope =
scope ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
private enum class SessionEnd { Closed, AuthFailed, ConfigMissing }
private companion object {
const val PING_INTERVAL_SECONDS = 30L
const val CONNECT_TIMEOUT_SECONDS = 10L
const val INITIAL_BACKOFF_MS = 1_000L
const val MAX_BACKOFF_MS = 30_000L
const val MAX_BACKOFF_SHIFT = 5
const val JITTER_DIVISOR = 4L
const val EVENT_BUFFER = 512
const val CLOSE_CODE_NORMAL = 1000
const val CLOSE_REASON_CLIENT = "client closed"
}
}
@@ -0,0 +1,621 @@
package top.yeij.cyrene.data.ws
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
/**
* 下行事件(服务端 → 客户端)。
*
* 帧壳:`{type, turnId?, seq?, sessionId?, data?}`(api-reference §6.2),
* 只有 `type` 必填,`data` 的形状由 `type` 决定。字段名逐字对齐 api-reference §6.4。
*
* ## 会话归属
* 服务端自 **v0.36** 起给**每个结构化流帧的帧壳**都打上 `sessionId` + `turnId` + `streamId`
* (打戳处 `internal/runtime/loop/stream.go`,上帧处 `gateway/ws_hub_turn.go` 的
* `broadcastStream`),`stream_end` / `stream_chunk` / 状态帧同样带 `SessionID` + `TurnID`。
* 本层把帧壳上的 `sessionId` **原样透传**到每个事件类([WsEvent.sessionId]),**不补值**。
*
* 服务端广播**不按会话订阅**(hub 把帧发给每条已认证连接),因此「这一帧属于哪个会话」只能由
* 客户端按 `sessionId` 收敛——见 [isForSession]。该字段可能为 `null`(帧壳省略,或该类型本就
* 无会话归属),此时是「无法判定」而非「不属于本会话」,上层应放行并自行兜底。
*
* ## 未知事件与坏帧
* [parse] 对未知 `type`、非 JSON、结构不匹配的帧一律返回 `null`(安全忽略),绝不抛异常。
*/
sealed class WsEvent {
abstract val type: String
/** 一轮交换的关联 id(`turn-<hex>`);OnStream 结构化流路径不带。 */
open val turnId: String? = null
/** hub 级单调递增序号,供排序关联流式突发。 */
open val seq: Long? = null
/**
* 本帧归属的会话(帧壳上的 `sessionId`,原样透传)。
* `null` = 帧壳没给,**不代表不属于本会话**——见 [isForSession]。
*/
open val sessionId: String? = null
/** 连接首帧:主会话最近 50 条历史(`thinking` 已被服务端剥离)。 */
data class History(
val messages: List<HistoryMessageDto>,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "history"
}
/** 正文段开始。 */
data class MessageStart(
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "message.start"
}
/** 正文 token 增量(累加到当前正文段)。 */
data class MessageDelta(
val delta: String,
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "message.delta"
}
/** 正文段结束(不结束整轮,整轮由 [StreamEnd] 收尾)。 */
data class MessageEnd(
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "message.end"
}
/** 思维链段开始。 */
data class ThinkingStart(
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "thinking.start"
}
/** 思维链 token 增量。 */
data class ThinkingDelta(
val delta: String,
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "thinking.delta"
}
/** 思维链段结束。 */
data class ThinkingEnd(
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "thinking.end"
}
/** 工具调用中间态开始。 */
data class ToolCallStart(
val id: String,
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "tool_call.start"
}
/** 工具调用参数增量(如 `input_json_delta`)。 */
data class ToolCallDelta(
val id: String,
val index: Int,
val delta: String,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "tool_call.delta"
}
/** 工具调用中间态结束。 */
data class ToolCallEnd(
val id: String,
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "tool_call.end"
}
/**
* 工具**进入执行**(后端契约 v0.50 `tool_execution` 三事件之一)。
*
* 这三帧**不属于模型流**(`ws_bridge.go:143`):`data` 里既没有 `index` 也没有 `streamId`,
* 关联键是 provider 的 tool call id([id],与同轮 `tool_call.start/delta/end` 同源;
* provider 没给 id 时该键整个省略,即 `null`)。[phase] 是细粒度执行阶段,取值与流程图
* tool 节点 status 同一套枚举(`running` / `done` / `error`),**不与 `delta` 混用**。
*/
data class ToolExecutionStart(
val tool: String,
val phase: String,
val id: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "tool_execution.start"
}
/**
* 工具**运行中**的进度 / 局部输出,一次执行可多帧(契约 v0.50)。
*
* 诚实边界:wire 映射已实现,但后端工具分发器**暂无中途进度上报源**,loop 今天不产出该帧
* (`start` / `end` 有真实生产者)。此处按契约完整解码,生产者落地后无需再改客户端。
*/
data class ToolExecutionUpdate(
val tool: String,
val phase: String,
val delta: String = "",
val id: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "tool_execution.update"
}
/** 工具执行**完成或失败**(契约 v0.50):`phase:"error"` 即失败。 */
data class ToolExecutionEnd(
val tool: String,
val phase: String,
val id: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "tool_execution.end"
}
/** 向后兼容整段文本(仅非流式回退路径;流式路径不 emit)。 */
data class StreamChunk(
val delta: String,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "stream_chunk"
}
/**
* 一轮结束。`stopReason` 取值未穷举(`stop` / `idle` / `error` / …),用字符串承载;
* `error` 形状未定义(字符串或对象都可能),原样以 [JsonElement] 暴露。
*/
data class StreamEnd(
val stopReason: String,
val steps: Int? = null,
val error: JsonElement? = null,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "stream_end"
}
/** 会话进入忙碌态。`mode` ∈ `idle` / `thinking` / `executing` / `subagent`。 */
data class SessionBusy(
override val sessionId: String,
val mode: String,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "session_busy"
}
/** 忙时新消息已入队(「收到,稍后回」)。 */
data class PendingAck(
override val sessionId: String,
val mode: String,
val queueDepth: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "pending_ack"
}
/** 忙完开始消费下一条待处理消息。 */
data class PendingDrain(
override val sessionId: String,
val mode: String,
val queueDepth: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "pending_drain"
}
/** 会话回到空闲、队列清空。 */
data class SessionIdle(
override val sessionId: String,
val mode: String,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "session_idle"
}
/** 用户打断:当前任务取消、pending 清空,`dropped` 为丢弃条数。 */
data class SessionInterrupted(
override val sessionId: String,
val mode: String,
val dropped: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "session_interrupted"
}
/** 单会话 pending 达上限(`max`,默认 5),拒绝入队。 */
data class PendingFull(
override val sessionId: String,
val mode: String,
val max: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "pending_full"
}
/**
* `clientMsgId` 重复到达的明确回执(契约 v0.56 §3.13,纯新增帧)。
*
* 语义 = **这条消息早已被受理过**:服务端**不落库、不起回合、不发任何内容帧**
* (它由编排器的状态通道下发,与 `session_busy` 同一出口,因此帧壳带 `sessionId`、
* 不带 `turnId` / `seq`)。[messageId] 是**既有那条用户消息的 id**,
* 客户端据此把本地乐观气泡对齐到服务端真相。
*
* 广播是发给**所有** owner/admin 连接的(hub 无按会话订阅),没发过该
* [clientMsgId] 的客户端按值自行忽略即可。
*/
data class ChatDuplicate(
override val sessionId: String,
val clientMsgId: String,
val messageId: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "chat_duplicate"
}
/**
* 已读游标推进(D4,契约 v0.37,api-client §3.9):
* `{type:"session_read", sessionId, data:{readAt}}`——**载荷里没有会话 id**,
* 会话归属**只**来自帧壳(服务端 `POST /sessions/{id}/read` 成功后广播)。
*
* 它是那 7 个「不含内容的状态机信号」之一(发给**所有**已认证连接,含 `viewer`),
* 与 `message_persisted` 同一条全连接广播路径,故同样要按 [isForSession] 收敛。
* [readAt] 是**推进后**的游标(毫秒,与该会话消息的 `createdAt` 同单位);
* data 缺席时回落 `0`——游标实际不可能为 0,于是「坏帧」天然退化成**什么也不改**
* (见 `SessionItem.withReadCursor`:只有 `lastMessageAt <= readAt` 才清零)。
*/
data class SessionRead(
override val sessionId: String,
val readAt: Long,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "session_read"
}
/** 主会话(`web` / `web:<uuid>`)消息落库后广播。 */
data class MessagePersisted(
val message: HistoryMessageDto?,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "message_persisted"
}
/** 隔离会话(`sess-xxx`,群聊 / 陌生人)消息落库后广播,供 IM 适配器日志页。 */
data class ChannelPersisted(
val message: HistoryMessageDto?,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "channel_persisted"
}
/** 语音合成回复(定向回请求连接)。 */
data class VoiceOutput(
val audio: String?,
val format: String?,
val text: String?,
val reply: String?,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "voice_output"
}
/** 自主主动推送(仅 owner/admin 连接收到)。 */
data class ProactiveMessage(
val platform: String?,
val channelId: String?,
val channelType: String?,
val content: String,
val contentType: String?,
val sender: String?,
val timestamp: Long?,
val messageId: String?,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "proactive_message"
}
/** 服务端错误(非法 JSON / 未知类型 / 语音·子代理·任务错误等)。 */
data class Error(
val message: String,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "error"
}
companion object {
/**
* 解析一帧文本。**永不抛异常**:非 JSON、缺 `type`、未知 `type`、`data` 结构不匹配
* 都返回 `null`,由调用方安全忽略。
*/
fun parse(raw: String): WsEvent? {
val root = runCatching { ApiClientFactory.json.parseToJsonElement(raw) }
.getOrNull() as? JsonObject ?: return null
val type = root["type"].asStringOrNull() ?: return null
val turnId = root["turnId"].asStringOrNull()
val seq = root["seq"].asStringOrNull()?.toLongOrNull()
val sessionId = root["sessionId"].asStringOrNull()
val data = root["data"]?.takeIf { it !is JsonNull }
return runCatching { dispatch(type, turnId, seq, sessionId, data) }.getOrNull()
}
private fun dispatch(
type: String,
turnId: String?,
seq: Long?,
sessionId: String?,
data: JsonElement?,
): WsEvent? = dispatchStreaming(type, turnId, seq, sessionId, data)
?: dispatchSession(type, turnId, seq, sessionId, data)
private fun dispatchStreaming(
type: String,
turnId: String?,
seq: Long?,
sessionId: String?,
data: JsonElement?,
): WsEvent? = when (type) {
"history" -> History(decode<HistoryData>(data).messages, turnId, seq, sessionId)
"message.start" -> MessageStart(decode<IndexData>(data).index, turnId, seq, sessionId)
"message.delta" -> decode<DeltaIndexData>(data)
.let { MessageDelta(it.delta, it.index, turnId, seq, sessionId) }
"message.end" -> MessageEnd(decode<IndexData>(data).index, turnId, seq, sessionId)
"thinking.start" -> ThinkingStart(decode<IndexData>(data).index, turnId, seq, sessionId)
"thinking.delta" -> decode<DeltaIndexData>(data)
.let { ThinkingDelta(it.delta, it.index, turnId, seq, sessionId) }
"thinking.end" -> ThinkingEnd(decode<IndexData>(data).index, turnId, seq, sessionId)
"tool_call.start" -> decode<ToolCallIdData>(data)
.let { ToolCallStart(it.id, it.index, turnId, seq, sessionId) }
"tool_call.delta" -> decode<ToolCallData>(data)
.let { ToolCallDelta(it.id, it.index, it.delta, turnId, seq, sessionId) }
"tool_call.end" -> decode<ToolCallIdData>(data)
.let { ToolCallEnd(it.id, it.index, turnId, seq, sessionId) }
"tool_execution.start" -> decode<ToolExecutionData>(data)
.let { ToolExecutionStart(it.tool, it.phase, it.id, turnId, seq, sessionId) }
"tool_execution.update" -> decode<ToolExecutionData>(data)
.let { ToolExecutionUpdate(it.tool, it.phase, it.delta, it.id, turnId, seq, sessionId) }
"tool_execution.end" -> decode<ToolExecutionData>(data)
.let { ToolExecutionEnd(it.tool, it.phase, it.id, turnId, seq, sessionId) }
"stream_chunk" -> StreamChunk(decode<StreamChunkData>(data).delta, turnId, seq, sessionId)
"stream_end" -> decode<StreamEndData>(data)
.let { StreamEnd(it.stopReason, it.steps, it.error, turnId, seq, sessionId) }
else -> null
}
private fun dispatchSession(
type: String,
turnId: String?,
seq: Long?,
sessionId: String?,
data: JsonElement?,
): WsEvent? = when (type) {
"session_busy" -> decode<SessionModeData>(data)
.let { SessionBusy(resolveSessionId(sessionId, it.sessionId), it.mode, turnId, seq) }
"pending_ack" -> decode<PendingData>(data)
.let { PendingAck(resolveSessionId(sessionId, it.sessionId), it.mode, it.queueDepth, turnId, seq) }
"pending_drain" -> decode<PendingData>(data)
.let { PendingDrain(resolveSessionId(sessionId, it.sessionId), it.mode, it.queueDepth, turnId, seq) }
"session_idle" -> decode<SessionModeData>(data)
.let { SessionIdle(resolveSessionId(sessionId, it.sessionId), it.mode, turnId, seq) }
"session_interrupted" -> decode<InterruptedData>(data)
.let { SessionInterrupted(resolveSessionId(sessionId, it.sessionId), it.mode, it.dropped, turnId, seq) }
"pending_full" -> decode<PendingFullData>(data)
.let { PendingFull(resolveSessionId(sessionId, it.sessionId), it.mode, it.max, turnId, seq) }
// 幂等回执(v0.56 §3.13):帧壳带 sessionId,data 内也带一份(两者取有值者)。
"chat_duplicate" -> decode<ChatDuplicateData>(data)
.let {
ChatDuplicate(
resolveSessionId(sessionId, it.sessionId),
it.clientMsgId,
it.messageId,
turnId,
seq,
)
}
// 已读游标(D4 §3.9):会话归属只在帧壳上(data 只有 readAt)。
"session_read" -> decode<SessionReadData>(data)
.let { SessionRead(resolveSessionId(sessionId, ""), it.readAt, turnId, seq) }
"message_persisted" -> decode<PersistedData>(data)
.let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
"channel_persisted" -> decode<PersistedData>(data)
.let { ChannelPersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
"voice_output" -> decode<VoiceOutputData>(data)
.let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) }
"proactive_message" -> decode<ProactiveData>(data).let {
ProactiveMessage(
it.platform, it.channelId, it.channelType, it.content, it.contentType,
it.sender, it.timestamp, it.messageId, turnId, seq, sessionId,
)
}
"error" -> Error(decode<ErrorData>(data).message, turnId, seq, sessionId)
else -> null
}
private fun resolveSessionId(shell: String?, fromData: String): String =
fromData.ifBlank { shell ?: "" }
private inline fun <reified T> decode(data: JsonElement?): T =
ApiClientFactory.json.decodeFromJsonElement<T>(data ?: JsonObject(emptyMap()))
}
}
/**
* 这一帧是否属于 [sessionId] 这个会话(纯函数)。
*
* 服务端广播不按会话订阅,**每条**连接都会收到**每个**会话的帧,所以调用方必须先过这一闸,
* 否则别的会话的 `message.delta` 会被自己的流式状态机吃掉。
*
* 判定原则是**只排除「明确属于别的会话」的帧**:
* - 帧壳上的 `sessionId` 为空(`null` / 空白)→ **放行**。空值是「无法判定」而不是「不属于本会话」,
* 丢掉会让旧服务端(或不带该字段的帧类型)整类事件消失——这与
* [top.yeij.cyrene.data.repository.historyForSession] 对空 `sessionId` 的处理一致。
* - 有值且相等 → 放行;有值且不等 → 丢弃。
*
* 不修改入参,无副作用。
*/
fun WsEvent.isForSession(sessionId: String): Boolean {
val frameSession = this.sessionId
return frameSession.isNullOrBlank() || frameSession == sessionId
}
private fun JsonElement?.asStringOrNull(): String? {
val primitive = this as? JsonPrimitive ?: return null
if (primitive is JsonNull) return null
return primitive.content
}
@Serializable
private data class IndexData(val index: Int = 0)
@Serializable
private data class DeltaIndexData(val delta: String = "", val index: Int = 0)
@Serializable
private data class ToolCallIdData(val id: String = "", val index: Int = 0)
@Serializable
private data class ToolCallData(val id: String = "", val index: Int = 0, val delta: String = "")
/**
* `tool_execution.*` 的载荷(后端 `ws_bridge.go:toolExecutionData`):`tool` + `phase` 恒在,
* `id` 在 provider 未给时**整个键省略**,`delta` 只在 `update` 且非空时出现。
* 刻意**不复用** [ToolCallData]:那是 `tool_call.*` 的形状,真的有 `index`;这三帧没有,
* 也没有 `streamId`(它们不属于某一次模型流)。
*/
@Serializable
private data class ToolExecutionData(
val tool: String = "",
val phase: String = "",
val id: String? = null,
val delta: String = "",
)
@Serializable
private data class HistoryData(val messages: List<HistoryMessageDto> = emptyList())
@Serializable
private data class StreamChunkData(val delta: String = "")
@Serializable
private data class StreamEndData(
val stopReason: String = "",
val steps: Int? = null,
val error: JsonElement? = null,
)
@Serializable
private data class SessionModeData(val sessionId: String = "", val mode: String = "")
@Serializable
private data class PendingData(val sessionId: String = "", val mode: String = "", val queueDepth: Int = 0)
@Serializable
private data class InterruptedData(val sessionId: String = "", val mode: String = "", val dropped: Int = 0)
@Serializable
private data class PendingFullData(val sessionId: String = "", val mode: String = "", val max: Int = 0)
/** `chat_duplicate` 载荷(v0.56 §3.13):`{sessionId, clientMsgId, messageId}`。 */
@Serializable
private data class ChatDuplicateData(
val sessionId: String = "",
val clientMsgId: String = "",
val messageId: String? = null,
)
/** `session_read` 载荷(api-client §3.9):`{readAt}`——毫秒游标,**不带**会话 id。 */
@Serializable
private data class SessionReadData(val readAt: Long = 0L)
@Serializable
private data class PersistedData(val message: HistoryMessageDto? = null)
@Serializable
private data class VoiceOutputData(
val audio: String? = null,
val format: String? = null,
val text: String? = null,
val reply: String? = null,
)
@Serializable
private data class ProactiveData(
val platform: String? = null,
val channelId: String? = null,
val channelType: String? = null,
val content: String = "",
val contentType: String? = null,
val sender: String? = null,
val timestamp: Long? = null,
val messageId: String? = null,
)
@Serializable
private data class ErrorData(val message: String = "")
@@ -0,0 +1,135 @@
package top.yeij.cyrene.data.ws
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* 上行事件(客户端 → 服务端)。
*
* 帧壳:`{type, reqId?, data?}`(api-reference §6.2)。字段名逐字对齐 §6.3:
* `chat` / `interrupt` / `voice_input` / `mode_switch` / `subagent_ask` / `task_output_query`。
*
* 序列化用 [buildJsonObject] 显式拼装,而不是 `@Serializable` 数据类,原因:
* 上行帧是「信封 + 变长 data」,这样能保证 `type` 与 `data` 的嵌套结构不随类字段变化,
* 且可选字段为 `null` 时整键省略(与后端 `omitempty` 语义一致)。
*/
sealed interface WsOutbound {
/** 事件类型,即帧壳的 `type`。 */
val type: String
/** 请求关联 id;服务端当前不回填(api-reference §6.3),预留。 */
val reqId: String? get() = null
/** `data` 负载;返回 `null` 时省略该键。 */
fun data(): JsonElement?
/** 编码成一条文本帧。 */
fun toJson(): String = buildJsonObject {
put("type", type)
reqId?.let { put("reqId", it) }
data()?.let { put("data", it) }
}.toString()
/** `chat`:`{sessionId?, text, image?, deviceId?, clientMsgId?}`;text+image 至少一项。 */
data class Chat(
val text: String,
val sessionId: String? = null,
val deviceId: String? = null,
val image: String? = null,
/**
* 客户端幂等键(契约 v0.56 §3.13):同一会话内同一个非空值只承认第一条,
* 重复到达服务端回下行帧 `chat_duplicate`、**不落库、不起回合**。省略 = 老行为逐字不变。
*/
val clientMsgId: String? = null,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "chat"
override fun data(): JsonElement = buildJsonObject {
sessionId?.let { put("sessionId", it) }
put("text", text)
image?.let { put("image", it) }
deviceId?.let { put("deviceId", it) }
clientMsgId?.let { put("clientMsgId", it) }
}
}
/** `interrupt`:`{sessionId, scope?}`。命中空闲会话为 no-op。 */
data class Interrupt(
val sessionId: String,
/**
* 打断范围(契约 v0.56/A2,api-client §3.1):取值域 `{"", "all", "current"}`。
* `null` = **整键省略**——服务端视同 `"all"`(取消在跑回合 + 清空待处理队列),
* 也就是加入该字段之前那条帧**逐字节相同**;`"current"` = 只取消在跑回合、**保留**待处理队列。
* 其它取值服务端定向回 `error{bad_request}`(不打断任何会话)——故本层**原样透传**,
* 不做取值校验、不静默回退成默认(服务端的开放取值域由服务端裁决)。
*/
val scope: String? = null,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "interrupt"
override fun data(): JsonElement = buildJsonObject {
put("sessionId", sessionId)
scope?.let { put("scope", it) }
}
}
/** `voice_input`:`{audio?, format?, text?, sessionId?}`。 */
data class VoiceInput(
val audio: String? = null,
val format: String? = null,
val text: String? = null,
val sessionId: String? = null,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "voice_input"
override fun data(): JsonElement = buildJsonObject {
audio?.let { put("audio", it) }
format?.let { put("format", it) }
text?.let { put("text", it) }
sessionId?.let { put("sessionId", it) }
}
}
/** `mode_switch`:服务端原样广播回显,负载形状不限,这里发 `{mode}`。 */
data class ModeSwitch(
val mode: String,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "mode_switch"
override fun data(): JsonElement = buildJsonObject { put("mode", mode) }
}
/** `subagent_ask`:`{id, mode?, n?, query?}`。`mode` ∈ `""/full/tail/keyword`。 */
data class SubagentAsk(
val id: String,
val mode: String? = null,
val n: Int? = null,
val query: String? = null,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "subagent_ask"
override fun data(): JsonElement = buildJsonObject {
put("id", id)
mode?.let { put("mode", it) }
n?.let { put("n", it) }
query?.let { put("query", it) }
}
}
/** `task_output_query`:`{taskId}`。 */
data class TaskOutputQuery(
val taskId: String,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "task_output_query"
override fun data(): JsonElement = buildJsonObject { put("taskId", taskId) }
}
}
@@ -0,0 +1,25 @@
package top.yeij.cyrene.data.ws
/**
* 实时通道连接状态。
*
* 迁移:`Idle → Connecting → Connected → Reconnecting(退避) → Offline`。
* `Offline` 只在**不可恢复**的情况下出现(握手 401 / 缺少 token 或服务器地址),
* 需要上层重新登录或配置服务器后再调 [ChatSocket.connect]。
*/
enum class WsState {
/** 未启动:初始态,或主动 [ChatSocket.close] 之后。 */
Idle,
/** 首次握手进行中。 */
Connecting,
/** 已连接,可收发帧。 */
Connected,
/** 连接断开,正在按退避策略等待重连。 */
Reconnecting,
/** 不可恢复(握手 401 / 缺少 token 或地址),不会自动重连。 */
Offline,
}
@@ -0,0 +1,60 @@
package top.yeij.cyrene.domain.admin
/**
* 管理侧权限矩阵(api-client §2.0)的**客户端镜像**。
*
* 后端契约明确要求客户端自行降级(`Docs/api-client.md`:「客户端需按本矩阵自行降级」):
* 不该读的页**不发请求**,而不是发出去等 403。这里的判断是纯函数,便于单测。
*/
/**
* 管理页可见性分档,按权限递增。
*
* - [Monitor]:api-client §2.0 的①档,`owner`/`admin`/`viewer` 均可读(只读非敏感投影)。
* - [OwnerAdmin]:②档,仅 `owner`/`admin`(含他人数据 / 隐私正文),`viewer` 一律 403。
*/
enum class AdminTier(val rank: Int) {
Monitor(0),
OwnerAdmin(1),
}
/** 已接线角色,`rank` 与后端 `owner>admin>viewer` 递增一致。 */
enum class AdminRole(val rank: Int) {
Viewer(0),
Admin(1),
Owner(2),
;
companion object {
/**
* 角色名解析。大小写 / 首尾空白不敏感;**未知或空值按 [Viewer] 处理**——
* 与服务端一样 fail-closed:宁可少给可见面,也不去撞预期中的 403。
*/
fun of(raw: String?): AdminRole = when (raw?.trim()?.lowercase()) {
"owner" -> Owner
"admin" -> Admin
else -> Viewer
}
}
}
/**
* 管理页清单:只声明「有哪些页 + 需要哪一档权限」。
* 入口页、ViewModel 与各页标题都按本清单驱动,新增管理模块只需在此追加一项。
*/
enum class AdminPage(
val title: String,
val summary: String,
val required: AdminTier,
) {
Overview("概览", "请求指标与最近 trace", AdminTier.Monitor),
Personas("人格", "角色目录与思考旋钮", AdminTier.Monitor),
Memories("记忆", "记忆正文(scope / 内容)", AdminTier.OwnerAdmin),
Backthink("后台思考", "后台思考与主动推送记录", AdminTier.OwnerAdmin),
}
/** 当前角色能否使用该页;不能则 UI 禁用入口、ViewModel 直接给「无权限」。 */
fun canAccess(role: AdminRole, page: AdminPage): Boolean = role.rank >= page.required.rank
/** 当前角色可用的页,顺序与 [AdminPage] 声明一致。 */
fun availablePages(role: AdminRole): List<AdminPage> = AdminPage.entries.filter { canAccess(role, it) }
@@ -0,0 +1,26 @@
package top.yeij.cyrene.domain.admin
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
/**
* 管理页取数失败。**「无权限」必须与其它失败分开**:UI 要能给出「无权限」状态,
* 而不是把 403 当成空列表渲染成「暂无数据」。
*/
sealed interface AdminFailure {
/** HTTP 403(角色不够)或 401(会话失效)。 */
data object Forbidden : AdminFailure
data class Other(val message: String) : AdminFailure
}
/** 把仓库层的 `Result` 失败收敛成 [AdminFailure];成功时返回 null。 */
fun Throwable.toAdminFailure(): AdminFailure {
val error = (this as? CyreneException)?.error
return when {
error is CyreneError.Unauthorized -> AdminFailure.Forbidden
error is CyreneError.Server && error.httpStatus == 403 -> AdminFailure.Forbidden
error != null -> AdminFailure.Other(error.userMessage)
else -> AdminFailure.Other("操作失败,请稍后重试")
}
}
@@ -0,0 +1,137 @@
package top.yeij.cyrene.domain.admin
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import top.yeij.cyrene.domain.enums.EnumCatalog
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.parse
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
private val LocalTime = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
/**
* RFC3339(trace 的 `startedAt` / `endedAt`、trace event 的 `time`)→ 本地时间文本。
* 秒精度串与纳秒精度串都要吃得下;解析不了就**原样返回**,绝不静默清空。
*/
fun formatRfc3339(raw: String?): String? {
val text = raw?.takeIf { it.isNotBlank() } ?: return null
return runCatching {
OffsetDateTime.parse(text).atZoneSameInstant(ZoneId.systemDefault()).format(LocalTime)
}.getOrDefault(text)
}
/**
* Unix 毫秒(backthink 的 `at`)→ 本地时间文本。
* 与 [formatRfc3339] 是**两套输入**,刻意不共用解析路径(api-client §1 的两种时间形状)。
*/
fun formatEpochMillis(millis: Long): String =
Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).format(LocalTime)
/** 纳秒时长 → 按量级挑单位。 */
fun formatNanos(nanos: Long): String = when {
nanos < 1_000 -> "$nanos ns"
nanos < 1_000_000 -> String.format(Locale.ROOT, "%.1f µs", nanos / 1_000.0)
nanos < 1_000_000_000 -> String.format(Locale.ROOT, "%.1f ms", nanos / 1_000_000.0)
else -> String.format(Locale.ROOT, "%.2f s", nanos / 1_000_000_000.0)
}
/**
* 计数表(`perMethod` / `perPath` / `statusCounts`)→ 一行文本,按次数倒序。
* 键名一律原样保留(开放集合,不做白名单过滤,也不截断条目)。
*/
fun formatCounts(counts: Map<String, Long>): String =
counts.entries
.sortedWith(compareByDescending<Map.Entry<String, Long>> { it.value }.thenBy { it.key })
.joinToString(" · ") { (key, value) -> "$key $value" }
/** 后端用 float64 表达计数 / 毫秒,整数时抹掉无意义的小数位。 */
fun formatDouble(value: Double, decimals: Int = 2): String =
BigDecimal(value).setScale(decimals, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
/** 任意 `props` 对象 → 紧凑 JSON 文本,原样展示(不裁剪、不丢键)。 */
fun formatProps(props: Map<String, JsonElement>?): String? =
props?.takeIf { it.isNotEmpty() }?.let { JsonObject(it).toString() }
private val traceKindLabels = mapOf(
"llm_call" to "LLM 调用",
"tool_call" to "工具调用",
"orchestration" to "编排",
"state_transition" to "状态迁移",
"subagent" to "子代理",
"memory" to "记忆",
"guardrail" to "护栏",
)
private val traceStatusLabels = mapOf(
"ok" to "成功",
"error" to "失败",
)
private val memoryScopeLabels = mapOf(
"user" to "用户",
"persona" to "人格",
"project" to "项目",
)
/**
* 未知取值回落约定(api-client §1 红线):不在表中的取值**原样返回**——
* 不丢弃、不报错、不替换占位符。服务端新增取值属非破坏性变更。
*/
fun traceKindLabel(raw: String): String = traceKindLabels[raw] ?: raw
fun traceStatusLabel(raw: String): String = traceStatusLabels[raw] ?: raw
/**
* `mode` / `mood` 的标签来自 `/meta/enums` 的权威表([catalog]),客户端不再硬编码——
* 表里没有的取值同样原样返回([EnumCatalog] 负责该回落)。
* `trace` / `memory` 那几类**不在**该端点的表内,各自保留本地映射(见上)。
*/
fun personaModeLabel(raw: String, catalog: EnumCatalog): String = catalog.modeLabel(raw)
fun memoryScopeLabel(raw: String): String = memoryScopeLabels[raw] ?: raw
fun moodLabel(raw: String, catalog: EnumCatalog): String = catalog.moodLabel(raw)
/** 三态展示:null(未配置)与 true/false 是**三种不同**状态,不能合并。 */
fun triStateLabel(value: Boolean?): String = when (value) {
null -> "未配置"
true -> "显示"
false -> "不显示"
}
/** 空白字段的展示兜底:只处理「没有值」,不参与未知取值的回落。 */
fun orDash(raw: String?): String = raw?.takeIf { it.isNotBlank() } ?: "-"
/**
* 后台思考 `conclusion` 的一段:段内按渲染顺序排列的片段(正文 / 动作 / 旁白)。
* 段与段之间的间距由 UI 决定;段内片段顺序即原文顺序。
*/
data class ConclusionParagraph(val parts: List<ContentPart>)
/**
* `conclusion` → 有序渲染模型,供 UI 逐段渲染。
*
* 该字段承载的是**与聊天消息同一套 Web 端 markup**(真实样本里有连写的空 `<p></p>`、
* `<action>` 等),所以这里**不新增任何 markup 规则**,一律委托 [parse]:
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签按普通文本回落。
* 空段(`<p></p>` 连写产生的空白)不产出模型项——否则界面上会出现成片空行。
* 段内片段顺序取**渲染顺序**:`head` → 正文 → `tail`(与聊天页气泡上/下方旁白的排布一致)。
* `null` / 纯空白输入返回空列表。
*/
fun conclusionParagraphs(raw: String?): List<ConclusionParagraph> {
val text = raw?.takeIf { it.isNotBlank() } ?: return emptyList()
return parse(text).mapNotNull { bubble ->
val parts = buildList {
addAll(bubble.head)
if (bubble.body.isNotEmpty()) add(ContentPart.Text(bubble.body))
addAll(bubble.tail)
}
parts.takeIf { list -> list.any { it.text.isNotBlank() } }?.let(::ConclusionParagraph)
}
}
@@ -0,0 +1,72 @@
package top.yeij.cyrene.domain.chat
/** 等比缩放后的目标尺寸(px)。 */
data class ScaledSize(val width: Int, val height: Int)
/**
* 发送前图片的**体积预算**与尺寸规划(纯函数,编码器与测试共用;不含任何 Android 依赖)。
*
* ## 为什么必须缩放 / 重编码
* 服务端把 `image` 字符串**原样落库**(api-client §1 行 3 + 协同文档 G3),于是
* `GET /sessions/{id}/messages` 的**每一页**、WS 握手 `history` 的**每一次回放**
* 都要把整段 base64 再传一遍。所以「发一张 8 MB 的原图」= 以后每次翻历史都重传 11 MB。
* 客户端预算是这个成本的唯一闸门,**不是优化**。
*
* 两层上限:
* - [MAX_DECODED_BYTES] = 1 MiB:**本端的**目标(缩放 + 重编码后必须 ≤ 它)——
* 服务端 5 MiB 只管「收不收」,管不了「历史有多重」;
* - [SERVER_MAX_DECODED_BYTES] = 5 MiB:服务端硬顶(解码后),超限 `400 image_too_large`。
* 它只作为**最后一道兜底检查**:真要走上传(`POST /api/v1/upload`)时再放宽。
*
* 校验按**字符串长度**([SERVER_MAX_CHARS])而不是解码字节数——与服务端入口层同一口径
* (`chatImageCharsFor`),这样两端不可能漂移。
*/
object ChatImageBudget {
/** 服务端 `image` 解码后上限:5 MiB(与 `POST /api/v1/upload` 同口径)。 */
const val SERVER_MAX_DECODED_BYTES = 5 * 1024 * 1024
/** `data:` URI 前缀(`data:image/jpeg;base64,`)的字符余量,与服务端同一取值。 */
const val PREFIX_ALLOWANCE_CHARS = 64
/** 服务端 `image` 字符串长度上限(= base64 膨胀 4/3 + 前缀余量)。 */
const val SERVER_MAX_CHARS =
((SERVER_MAX_DECODED_BYTES + 2) / 3) * 4 + PREFIX_ALLOWANCE_CHARS
/** 本端目标:重编码后的**字节**上限,远低于服务端硬顶(见类注释)。 */
const val MAX_DECODED_BYTES = 1 * 1024 * 1024
/**
* 最长边的目标像素。1280 与 provider 自己入模型前的缩放口径(约 1300×1300)同量级,
* 所以再放大不会有任何可见收益——省的是库与历史的字节,不是 token。
*/
const val MAX_DIMENSION_PX = 1280
/** [byteCount] 字节 base64 编码后的字符数(每 3 字节 → 4 字符,末组补满)。 */
fun base64Chars(byteCount: Int): Int = ((byteCount + 2) / 3) * 4
/** 字符串长度是否在服务端上限内(入口层同一判据:`<=` 才算通过)。 */
fun withinServerLimit(dataUriChars: Int): Boolean = dataUriChars <= SERVER_MAX_CHARS
/** 编码后字节数是否已落到本端目标内。 */
fun withinTarget(byteCount: Int): Boolean = byteCount <= MAX_DECODED_BYTES
/**
* 等比缩放到最长边不超过 [maxDimension];**只缩不放**(小图保持原尺寸),
* 边长至少 1 px(极端细长图缩放后可能算成 0)。
*/
fun scaledSize(width: Int, height: Int, maxDimension: Int = MAX_DIMENSION_PX): ScaledSize {
val w = width.coerceAtLeast(1)
val h = height.coerceAtLeast(1)
val longest = maxOf(w, h)
if (longest <= maxDimension) return ScaledSize(w, h)
val ratio = maxDimension.toDouble() / longest
return ScaledSize(
width = (w * ratio).toInt().coerceAtLeast(1),
height = (h * ratio).toInt().coerceAtLeast(1),
)
}
/** 拼 `data:` URI([mime] 形如 `image/jpeg`,[base64] 为单行 base64)。 */
fun dataUri(mime: String, base64: String): String = "data:$mime;base64,$base64"
}
@@ -0,0 +1,51 @@
package top.yeij.cyrene.domain.chat
/** 一张**已重编码**的图片该怎么送上去。 */
enum class ImageDelivery {
/** 直接把 `data:` URI 塞进 `chat.image`:一次请求,但字节永久留在历史里。 */
Inline,
/** 先 `POST /api/v1/upload` 换 URL,再把 URL 当 `chat.image`:多一次往返,历史里只留几十字节。 */
Upload,
}
/**
* 内联(base64)与上传(URL)的分界(纯函数,VM 与测试共用)。
*
* ## 这是一笔什么账
* 服务端把 `chat.image` 字符串**原样落库**,所以两边的成本不是「发一次」而是「发多少次」:
*
* | | 发出时 | 之后每一次历史取数 |
* |---|---|---|
* | 内联 | 请求体里带 base64(4/3 × B 字符) | **再带一遍**(REST 每页、WS `history` 每次回放、每次上拉) |
* | 上传 | 多**一次** HTTP 往返;字节同样只传一遍 | 只剩那个 URL(几十字节) |
*
* 也就是说:上传不是「省一次流量」,是**用一次往返买断此后所有的历史重量**。
*
* ## 阈值:64 KiB([INLINE_MAX_BYTES],含等于)
* 重编码后的字节数 `B`,内联的代价是每次历史取数再多 `4/3 × B`:
* - `B = 64 KiB` → 每次取数 +85 KB。一个会话里 20 张这种图,每次冷启动 / 重连回放就要多传
* ≥1.7 MB(1 Mbps 下约 14 秒纯图片流量),而那次上传往返在局域网是毫秒、在移动网也就是
* 一两百毫秒——**从第二次取数起就稳赚**。
* - `B ≈ 32 KiB`(缩略图、小截图)→ 每次取数 +43 KB,省下的量与一次往返本身的量级相当,
* 而多出来的一次请求**必然**多一个失败面(上传失败就得让用户重发,内联不会)。
* 这种量级下「一次请求发完」更划算,所以留在内联。
*
* 阈值取整到 64 KiB 而不是更细的数,是因为它本来就是个成本量级的判断,不是服务端限制:
* 服务端的两个 5 MiB 上限(`chat.image` 与 `/upload`)离这里都还差两个数量级,
* **本决策与「装不装得下」无关**(重编码出口已保证 ≤ [ChatImageBudget.MAX_DECODED_BYTES])。
*/
object ChatImageDelivery {
/**
* 内联与上传的分界:**重编码后**的字节数(含等于 → 内联)。
*
* 必须严格小于 [ChatImageBudget.MAX_DECODED_BYTES],否则上传分支永远走不到(死代码);
* 这一条由 `ChatImageDeliveryTest` 钉住。
*/
const val INLINE_MAX_BYTES = 64 * 1024
/** @param preparedBytes [top.yeij.cyrene.data.image.ChatImagePreparer] 产出的字节数。 */
fun forPreparedBytes(preparedBytes: Int): ImageDelivery =
if (preparedBytes <= INLINE_MAX_BYTES) ImageDelivery.Inline else ImageDelivery.Upload
}
@@ -0,0 +1,55 @@
package top.yeij.cyrene.domain.chat
/** 一条消息该走哪条上行通道。 */
enum class SendChannel {
/** 短文本走 WS `chat` 帧:直达、绕过有损总线,且今天就能用。 */
Ws,
/** 长文本 / 任何图片走 REST `POST /api/v1/chat`(`stream:true`)。 */
Rest,
}
/**
* 上行路由规则(纯函数,VM 与测试共用)。
*
* ## 为什么必须有这条规则
* WS 帧的读上限是**服务端** `wsReadLimit = 4096` 字节/帧,且**超限由传输层直接断连**——
* 不是回一个错误,而是把这条连接踢掉(api-client §1 行 3 G3 的诚实边界)。所以
* 「长文发不出去」在今天不是「服务端拒绝」,是**发送帧把连接打断**。REST 没有这个上限,
* 且 `stream:true` 的回合同样在**已连接的 WS** 上流式回来 ⇒ 「REST 发、WS 收」不牺牲流式体验。
*
* 图片一律走 REST:真实图片的 base64 远超 4096 字节,在 WS 上**必然**断连。
*
* ## 阈值
* 判据是**文本的 UTF-8 字节数**(不是字符数——中文一个字 3 字节,按字符数算会在
* 333 个汉字时就撞上 4096)。上限取 [WS_TEXT_LIMIT_BYTES] = 4096 − 512 字节预留:
* 预留覆盖 `{"type","data":{"sessionId","text","deviceId","clientMsgId","image"}}` 的
* JSON 外壳(键名 + 值,UTF-8 最坏情况约 150 字节),并留出余量。留得保守是因为
* **超限的代价是断连**,而多走一次 REST 的代价只是几十毫秒——风险不对称,宁可早退。
*
* 边界:`utf8Length(text) <= WS_TEXT_LIMIT_BYTES` → WS;多 1 字节即 REST。
*/
object ChatSendRouting {
/** 服务端 WS 读上限(`wsReadLimit`,契约 v0.56 明确记载)。 */
const val WS_FRAME_LIMIT_BYTES = 4096
/** WS `chat` 帧 JSON 外壳的预留:键名 + `sessionId` / `deviceId` / `clientMsgId` 等值的字节数。 */
const val WS_ENVELOPE_RESERVE_BYTES = 512
/** 允许走 WS 的**文本**字节上限(不含图片:有图片一律走 REST)。 */
const val WS_TEXT_LIMIT_BYTES = WS_FRAME_LIMIT_BYTES - WS_ENVELOPE_RESERVE_BYTES
/** 文本的 UTF-8 字节数;`text` 为空返回 0。 */
fun utf8Length(text: String): Int = text.toByteArray(Charsets.UTF_8).size
/**
* 这一条该走哪条通道。
* @param hasImage 本次是否带图片(带图**永远**走 REST)
*/
fun channelFor(text: String, hasImage: Boolean): SendChannel = when {
hasImage -> SendChannel.Rest
utf8Length(text) <= WS_TEXT_LIMIT_BYTES -> SendChannel.Ws
else -> SendChannel.Rest
}
}
@@ -0,0 +1,63 @@
package top.yeij.cyrene.domain.enums
import androidx.compose.runtime.staticCompositionLocalOf
import top.yeij.cyrene.data.api.dto.EnumItemDto
import top.yeij.cyrene.data.api.dto.EnumTableDto
/**
* 枚举标签查找器(api-client §1.1):`/meta/enums` 抓到什么就查什么。
*
* **未知取值回落(红线)**:不在表内的取值一律**原样返回**——不丢弃、不报错、不替换占位符、
* 不猜测;服务端新增取值属非破坏性变更,不得因此清空角标或中断渲染。
*
* 因此这里**没有任何硬编码兜底表**:宁可显示原始 id,也不能让客户端自建的表与服务端漂移。
* 还没拿到表(未登录 / 首次拉取失败且无快照)时用 [Empty],一切原样显示。
*
* 判定只比对 `id`(大小写敏感,契约 id 为小写);`label` 仅作显示默认值。
*/
class EnumCatalog private constructor(
private val platforms: Map<String, String>,
private val moods: Map<String, String>,
private val modes: Map<String, String>,
) {
/** 表内 → 默认标签;不在表内 → 原始值。 */
fun platformLabel(id: String): String = platforms[id] ?: id
fun moodLabel(id: String): String = moods[id] ?: id
fun modeLabel(id: String): String = modes[id] ?: id
/**
* 表内 → 默认标签;不在表内 → `null`。
* 给「认识」与「不认识」需要走**不同**回落的调用方用(如 `deviceId` 前缀:
* 认识的平台名取友好标签,不认识的原样显示整串 `deviceId`,而不是只留前缀)。
*/
fun platformLabelOrNull(id: String): String? = platforms[id]
companion object {
/** 尚无权威表:一切原样显示。 */
val Empty = EnumCatalog(emptyMap(), emptyMap(), emptyMap())
fun of(table: EnumTableDto?): EnumCatalog = if (table == null) {
Empty
} else {
EnumCatalog(
platforms = table.platforms.toLabels(),
moods = table.moods.toLabels(),
modes = table.modes.toLabels(),
)
}
/**
* 空 id 不入表:否则查空串会拿到别人的标签,等于凭空造出一个标签。
* 空 label 同样不入表:查得到却没有标签只会把角标显示成空白,不如原样显示 id。
*/
private fun List<EnumItemDto>.toLabels(): Map<String, String> = mapNotNull { item ->
item.takeIf { it.id.isNotBlank() && it.label.isNotBlank() }?.let { it.id to it.label }
}.toMap()
}
}
/** 全局注入枚举标签查找器,供聊天角标 / 管理页等深层组件读取(与 `LocalAppPrefs` 同套路)。 */
val LocalEnumCatalog = staticCompositionLocalOf { EnumCatalog.Empty }
@@ -0,0 +1,409 @@
package top.yeij.cyrene.domain.parse
import kotlin.math.min
/*
* ChatContentParser —— 纯 Kotlin 的聊天内容解析器(无 Android / Compose 依赖)。
*
* 权威规则:
* - `configs/personas/cyrene.yaml`「分段约定」「表达方式:动作与旁白」;
* - `devdocs/03-客户端细化开发方案.md` §5 渲染管线;
* - Web 端 `frontend/web/src/lib/parseContent.ts` + `components/chat.tsx`(行为对齐基准)。
*
* 语义摘要:
* - `<p>…</p>`:每个完整块 = 一条独立气泡;无完整 `<p>` 时回退按空行 `\n\s*\n` 切分;
* 单个 `\n` 是句内换行,绝不切分。未闭合的 `<p>` 不构成块,按普通文本处理(流式下会先缓存)。
* `<p>` 之外的非空白正文不会被丢弃:并入相邻气泡(若只是旁白/空白则依附之,否则自成一气泡)。
* - `<action>…</action>` / `<narration>…</narration>`:旁白/动作,不独立成气泡,依附气泡:
* 首部连续块 → 挂气泡上方(head);尾部连续块 → 挂下方(tail);夹在正文中间的块 → 上提到 head。
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
* 关键词 旁白/内心 → narration,其余 → action。
* - 未闭合/畸形的标签一律回落为普通文本,绝不抛异常。
*
* `ParsedBubble` 结构(每条气泡渲染一个):
* - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块;
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
* - `tail` :要渲染在气泡下方的旁白/动作块(尾部连续块)。
* 无正文时 `body` 为空串,仅 `head` 承载整条消息(纯动作/旁白气泡)。
* `parse("")` / 纯空白输入返回空列表;纯空白且无旁白的气泡会被丢弃。
*
* 流式边界决策(`StreamingContentParser`):
* - `onDelta` 返回「当前已确定前缀」的解析结果;`finish` 返回全量缓冲的解析结果,
* 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。
* - 未确定片段从以下最早位置开始整体扣留,不产出半截结果:
* 1) 未闭合的 `<action>` / `<narration>` / `<p>` 开标签(从该开标签起扣留);
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
* - 标签残缺判断只影响「何时产出」,不影响最终结果;扣留期间不会抛异常。
* - 本类无副作用、非线程安全,调用方保证单线程喂入即可。
*/
/** 气泡内的一个有序片段。 */
sealed interface ContentPart {
val text: String
data class Text(override val text: String) : ContentPart
data class Action(override val text: String) : ContentPart
data class Narration(override val text: String) : ContentPart
}
/**
* 一条可渲染的气泡:正文 + 有序片段 + 上/下依附的旁白块。
* 字段含义见文件头注释;`body` 为空且 `head` 非空时,表示只有旁白没有正文。
*/
data class ParsedBubble(
val body: String,
val parts: List<ContentPart>,
val head: List<ContentPart>,
val tail: List<ContentPart>,
) {
val isEmpty: Boolean
get() = body.isEmpty() && head.isEmpty() && tail.isEmpty()
}
/** 完整 `<p>…</p>` 块(允许 `class` 等属性,但要求标签名后为空白或直接 `>`)。 */
private val P_BLOCK_RE = Regex("<p(\\s[^>]*)?>([\\s\\S]*?)</p>")
/**
* 一次匹配旁白的三种等价写法;左侧优先(最靠左的匹配获胜),
* 三种 body 均非贪婪,可跨换行;未闭合/畸形则整段回落为文本。
* 捕获组:1 = 标签名,2 = 标签 body,3 = 全角星 body,4 = 半角星 body。
*/
private val ROLE_RE = Regex(
"<\\s*(action|narration)\\s*>([\\s\\S]*?)</\\s*\\1\\s*>" +
"|*([\\s\\S]*?)*" +
"|\\*((?:动作|旁白|内心|神态|轻声)\\s*[::]?[\\s\\S]*?)\\*",
)
/** 星号块开头的关键词(可带中/英文冒号)。 */
private val ROLE_KEYWORD_RE = Regex("^(动作|旁白|内心|神态|轻声)\\s*[::]?\\s*")
private val ROLE_KEYWORDS = listOf("动作", "旁白", "内心", "神态", "轻声")
/** 空行切分:`\n` + 若干空白 + `\n`。 */
private val BLANK_LINE_RE = Regex("\\n\\s*\\n")
/** 无关键词的全角星块超过此长度即视为普通文本(对齐 Web `STAR_SHORT_RUNES`)。 */
private const val FULL_STAR_MAX_LEN = 40
private val TAG_NAMES = listOf("action", "narration", "p")
private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$")
private val CLOSE_ROLE_TAG_RE = Regex("^</\\s*(action|narration)\\s*>$")
private val OPEN_P_TAG_RE = Regex("^<p(\\s[^>]*)?>$")
private val CLOSE_P_TAG_RE = Regex("^</p>$")
/** 一次性解析:把一条消息的原始文本拆成若干独立气泡(含旁白依附关系)。 */
fun parse(raw: String): List<ParsedBubble> {
if (raw.isEmpty()) return emptyList()
return buildBubbles(tokenize(raw))
}
/**
* 带缓冲的流式增量解析器:`onDelta` 逐段喂入,`finish` 收口。
* 无副作用;调用方保证单线程访问。
*/
class StreamingContentParser {
private val buffer = StringBuilder()
/** 当前累计的原始文本(含尚未确定、被扣留的尾部)。 */
val raw: String
get() = buffer.toString()
/** 追加一段 delta,返回「当前已确定前缀」解析出的气泡列表。 */
fun onDelta(delta: String): List<ParsedBubble> {
if (delta.isNotEmpty()) buffer.append(delta)
val current = buffer.toString()
return parse(current.substring(0, stablePrefixEnd(current)))
}
/** 收口:对全部缓冲做一次性解析,结果与 `parse(拼接的全部 delta)` 完全一致。 */
fun finish(): List<ParsedBubble> = parse(buffer.toString())
/** 清空缓冲,可复用本实例处理下一条消息。 */
fun reset() {
buffer.setLength(0)
}
}
// ---------------------------------------------------------------------------
// 内部实现
// ---------------------------------------------------------------------------
private sealed interface Tok {
data class Txt(val text: String) : Tok
data class Act(val text: String) : Tok
data class Nar(val text: String) : Tok
object Break : Tok
}
/** 先切 `<p>` 块(决定气泡边界),再在每个文本区做旁白/星号解析。 */
private fun tokenize(raw: String): List<Tok> {
val pMatches = P_BLOCK_RE.findAll(raw).toList()
val out = ArrayList<Tok>()
if (pMatches.isEmpty()) {
appendAnnotations(raw, out, splitBlankLines = true)
return out
}
var last = 0
for (m in pMatches) {
val lead = raw.substring(last, m.range.first)
if (lead.isNotEmpty()) appendAnnotations(lead, out, splitBlankLines = false)
out.add(Tok.Break)
val inner = m.groupValues[2]
if (inner.isNotEmpty()) appendAnnotations(inner, out, splitBlankLines = false)
out.add(Tok.Break)
last = m.range.last + 1
}
val trail = raw.substring(last)
if (trail.isNotEmpty()) appendAnnotations(trail, out, splitBlankLines = false)
return out
}
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
private fun appendAnnotations(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
var last = 0
for (m in ROLE_RE.findAll(text)) {
val before = text.substring(last, m.range.first)
if (before.isNotEmpty()) appendText(before, out, splitBlankLines)
when (val part = classify(m)) {
null -> appendText(m.value, out, splitBlankLines)
is ContentPart.Text -> appendText(part.text, out, splitBlankLines)
is ContentPart.Action -> out.add(Tok.Act(part.text))
is ContentPart.Narration -> out.add(Tok.Nar(part.text))
}
last = m.range.last + 1
}
val tail = text.substring(last)
if (tail.isNotEmpty()) appendText(tail, out, splitBlankLines)
}
/** 追加普通文本;无 `<p>` 时按空行插入气泡边界。 */
private fun appendText(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
if (!splitBlankLines) {
out.add(Tok.Txt(text))
return
}
var last = 0
for (m in BLANK_LINE_RE.findAll(text)) {
val piece = text.substring(last, m.range.first)
if (piece.isNotEmpty()) out.add(Tok.Txt(piece))
out.add(Tok.Break)
last = m.range.last + 1
}
val tail = text.substring(last)
if (tail.isNotEmpty()) out.add(Tok.Txt(tail))
}
/** 把一次正则匹配归类;返回 null 表示「整段保持普通文本」。 */
private fun classify(m: MatchResult): ContentPart? {
if (m.groups[1] != null) {
val name = m.groupValues[1]
val body = m.groupValues[2]
return if (name == "action") ContentPart.Action(body) else ContentPart.Narration(body)
}
if (m.groups[3] != null) return classifyFullStar(m.groupValues[3])
if (m.groups[4] != null) return classifyHalfStar(m.groupValues[4])
return null
}
/** 全角 `*…*`:有关键词按关键词定类,否则短且单行才当 action。 */
private fun classifyFullStar(body: String): ContentPart? {
matchRoleKeyword(body)?.let { return it }
val trimmed = body.trim()
val short = trimmed.isNotEmpty() && trimmed.length <= FULL_STAR_MAX_LEN
if (short && !trimmed.contains('\n') && !trimmed.contains('\r')) {
return ContentPart.Action(trimmed)
}
return null
}
/** 半角 `*…*`:正则已保证以关键词开头,这里只负责剥离关键词与定类。 */
private fun classifyHalfStar(body: String): ContentPart =
matchRoleKeyword(body) ?: ContentPart.Action(body.trim())
/** 匹配并剥离开头关键词;旁白/内心 → narration,其余 → action。 */
private fun matchRoleKeyword(body: String): ContentPart? {
val m = ROLE_KEYWORD_RE.find(body) ?: return null
val rest = body.substring(m.value.length).trim()
return if (m.groupValues[1] == "旁白" || m.groupValues[1] == "内心") {
ContentPart.Narration(rest)
} else {
ContentPart.Action(rest)
}
}
/** 以 Break 为界分组,把「只有旁白没有正文」的组并入相邻气泡,再逐组构造气泡。 */
private fun buildBubbles(tokens: List<Tok>): List<ParsedBubble> {
val groups = ArrayList<MutableList<Tok>>()
var current = ArrayList<Tok>()
for (t in tokens) {
if (t is Tok.Break) {
if (current.isNotEmpty()) groups.add(current)
current = ArrayList()
} else {
current.add(t)
}
}
if (current.isNotEmpty()) groups.add(current)
val merged = ArrayList<MutableList<Tok>>()
var carry = ArrayList<Tok>()
for (group in groups) {
val combined = ArrayList<Tok>(carry.size + group.size)
combined.addAll(carry)
combined.addAll(group)
carry = ArrayList()
if (hasBodyText(combined)) merged.add(combined) else carry = combined
}
if (carry.isNotEmpty()) {
if (merged.isNotEmpty()) merged.last().addAll(carry) else merged.add(carry)
}
return merged.mapNotNull { toBubble(it) }
}
private fun hasBodyText(tokens: List<Tok>): Boolean =
tokens.any { it is Tok.Txt && it.text.isNotBlank() }
/** 构造气泡:合并相邻文本、计算 body,并按首/尾规则分配 head / tail。 */
private fun toBubble(tokens: List<Tok>): ParsedBubble? {
val parts = ArrayList<ContentPart>(tokens.size)
for (t in tokens) {
when (t) {
is Tok.Txt -> {
val prev = parts.lastOrNull()
if (prev is ContentPart.Text) {
parts[parts.size - 1] = ContentPart.Text(prev.text + t.text)
} else {
parts.add(ContentPart.Text(t.text))
}
}
is Tok.Act -> parts.add(ContentPart.Action(t.text))
is Tok.Nar -> parts.add(ContentPart.Narration(t.text))
Tok.Break -> Unit
}
}
if (parts.isEmpty()) return null
val firstText = parts.indexOfFirst { it is ContentPart.Text }
val lastText = parts.indexOfLast { it is ContentPart.Text }
val head = ArrayList<ContentPart>()
val tail = ArrayList<ContentPart>()
val body = StringBuilder()
parts.forEachIndexed { i, part ->
if (part is ContentPart.Text) {
body.append(part.text)
} else if (firstText < 0 || i < firstText || (i > firstText && i < lastText)) {
head.add(part)
} else {
tail.add(part)
}
}
val bodyText = body.toString().trim()
if (bodyText.isEmpty() && head.isEmpty() && tail.isEmpty()) return null
return ParsedBubble(body = bodyText, parts = parts.toList(), head = head.toList(), tail = tail.toList())
}
// ---------------------------------------------------------------------------
// 流式边界:计算「可安全产出」的前缀长度
// ---------------------------------------------------------------------------
/** 返回当前缓冲中可安全解析的前缀长度;其后的未完成片段一律扣留。 */
private fun stablePrefixEnd(buf: String): Int {
var cut = buf.length
val openTags = ArrayDeque<Pair<String, Int>>()
var i = 0
while (i < buf.length) {
val lt = buf.indexOf('<', i)
if (lt < 0) break
val gt = buf.indexOf('>', lt)
if (gt < 0) {
if (isPotentialTagPrefix(buf, lt)) cut = min(cut, lt)
break
}
val tag = buf.substring(lt, gt + 1)
val openName = openTagName(tag)
if (openName != null) {
openTags.addLast(openName to lt)
} else {
val closeName = closeTagName(tag)
if (closeName == null) {
i = lt + 1
continue
}
val idx = openTags.indexOfLast { it.first == closeName }
if (idx >= 0) openTags.removeAt(idx)
}
i = gt + 1
}
if (openTags.isNotEmpty()) cut = min(cut, openTags.first().second)
val freeStart = buf.lastIndexOf('>') + 1
cut = withholdDanglingFullStar(buf, freeStart, cut)
cut = withholdDanglingHalfStar(buf, freeStart, cut)
return cut.coerceIn(0, buf.length)
}
private fun openTagName(tag: String): String? {
OPEN_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
return if (OPEN_P_TAG_RE.matches(tag)) "p" else null
}
private fun closeTagName(tag: String): String? {
CLOSE_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
return if (CLOSE_P_TAG_RE.matches(tag)) "p" else null
}
/** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */
private fun isPotentialTagPrefix(buf: String, lt: Int): Boolean {
var j = lt + 1
if (j < buf.length && buf[j] == '/') j++
while (j < buf.length && buf[j].isWhitespace()) j++
val nameStart = j
while (j < buf.length && buf[j] in 'a'..'z') j++
val name = buf.substring(nameStart, j)
if (j >= buf.length) return name.isEmpty() || TAG_NAMES.any { it.startsWith(name) }
if (name.isEmpty()) return false
if (!buf[j].isWhitespace()) return false
return TAG_NAMES.any { it.startsWith(name) }
}
/** 自由文本区内孤立的全角 `*`(计数为奇)从最后一个起扣留。 */
private fun withholdDanglingFullStar(buf: String, from: Int, cut: Int): Int {
var count = 0
var last = -1
for (i in from until buf.length) {
if (buf[i] == '*') {
count++
last = i
}
}
return if (count % 2 == 1 && last >= 0) min(cut, last) else cut
}
/** 自由文本区内孤立且可能开启关键词旁白的半角 `*` 从最后一个起扣留。 */
private fun withholdDanglingHalfStar(buf: String, from: Int, cut: Int): Int {
var count = 0
var last = -1
for (i in from until buf.length) {
if (buf[i] == '*') {
count++
last = i
}
}
if (count % 2 == 0 || last < 0) return cut
return if (couldStartRoleKeyword(buf.substring(last + 1))) min(cut, last) else cut
}
private fun couldStartRoleKeyword(after: String): Boolean {
if (after.isEmpty()) return true
return ROLE_KEYWORDS.any { it.startsWith(after) || after.startsWith(it) }
}
@@ -0,0 +1,72 @@
package top.yeij.cyrene.ui.admin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
import top.yeij.cyrene.domain.admin.AdminPage
import top.yeij.cyrene.domain.admin.AdminRole
import top.yeij.cyrene.domain.admin.conclusionParagraphs
import top.yeij.cyrene.domain.admin.formatEpochMillis
import top.yeij.cyrene.domain.admin.orDash
/**
* 后台思考页:`GET /api/v1/admin/backthink`(**仅 `owner`/`admin`**)。
* `at` 是 **Unix 毫秒**,与 trace 的 RFC3339 不同源,用 [formatEpochMillis] 单独解析。
* `conclusion` 是**与聊天消息同一套 Web 端 markup**的原文(真实样本里有连写的空 `<p></p>` 与
* `<action>`),因此不直接上屏:先经 [conclusionParagraphs] 解析成渲染模型,再逐段渲染。
*/
@Composable
fun AdminBackthinkScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
val state by viewModel.backthink.collectAsState()
val reload = { viewModel.load(AdminPage.Backthink, role) }
LaunchedEffect(Unit) { reload() }
AdminPageScaffold(title = AdminPage.Backthink.title, onBack = onBack, onRefresh = reload) { padding ->
AdminLoadShell(
state = state,
onRetry = reload,
modifier = Modifier
.fillMaxSize()
.padding(padding),
) { records ->
if (records.isEmpty()) {
AdminCenterHint("暂无后台思考记录")
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = records, key = { it.id }) { record -> BackthinkCard(record) }
}
}
}
}
}
@Composable
private fun BackthinkCard(record: AdminBackthinkItemDto) {
val paragraphs = remember(record.conclusion) { conclusionParagraphs(record.conclusion) }
AdminRichTextCard(
title = orDash(record.trigger),
paragraphs = paragraphs,
notice = backthinkNotice(record),
trailing = if (record.at > 0) formatEpochMillis(record.at) else "-",
)
}
/** 结尾的推送状态行;与 `conclusion` 正文分开渲染,不参与 markup 解析。 */
private fun backthinkNotice(record: AdminBackthinkItemDto): String =
"通知 ${if (record.shouldNotify) "需推送" else "不推送"} · ${if (record.pushed) "已送达" else "未送达"}"
@@ -0,0 +1,306 @@
package top.yeij.cyrene.ui.admin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.domain.admin.AdminFailure
import top.yeij.cyrene.domain.admin.ConclusionParagraph
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.HorizontalDivider
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.ChevronBackward
import top.yukonga.miuix.kmp.icon.extended.Refresh
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 管理页统一外壳:独立的 Miuix [Scaffold] + 返回按钮,各页只填内容。
* 管理员区是聊天之外的独立导航(design D15),所以每页都有自己的 Scaffold。
*/
@Composable
fun AdminPageScaffold(
title: String,
onBack: () -> Unit,
onRefresh: (() -> Unit)? = null,
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
topBar = {
SmallTopAppBar(
title = title,
navigationIcon = {
IconButton(onClick = onBack) {
Icon(imageVector = MiuixIcons.ChevronBackward, contentDescription = "返回")
}
},
actions = {
if (onRefresh != null) {
IconButton(onClick = onRefresh) {
Icon(imageVector = MiuixIcons.Refresh, contentDescription = "刷新")
}
}
},
)
},
content = content,
)
}
@Composable
fun AdminSectionTitle(text: String) {
Text(
text = text,
style = MiuixTheme.textStyles.subtitle,
color = MiuixTheme.colorScheme.primary,
modifier = Modifier.padding(start = 20.dp, top = 20.dp, bottom = 8.dp),
)
}
/** 居中提示(加载中 / 空态 / 无权限),整页级别。 */
@Composable
fun AdminCenterHint(
title: String,
detail: String? = null,
onRetry: (() -> Unit)? = null,
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 32.dp),
) {
Text(
text = title,
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
if (detail != null) {
Spacer(Modifier.height(6.dp))
Text(
text = detail,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
if (onRetry != null) {
Spacer(Modifier.height(12.dp))
TextButton("重试", onClick = onRetry)
}
}
}
}
/** 区块内的行内提示(不占满高度,可放进可滚动列 / LazyColumn 的 item)。 */
@Composable
fun AdminInlineHint(text: String) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp),
)
}
/**
* 取数失败时的整页状态。
* **无权限与其它失败必须分开**:403/401 给「无权限」+ 角色说明,绝不渲染成空列表。
*/
@Composable
fun AdminFailureState(
failure: AdminFailure?,
onRetry: () -> Unit,
) {
when (failure) {
is AdminFailure.Forbidden -> AdminCenterHint(
title = "无权限",
detail = "当前角色不能读取该页,请用 owner 或 admin 账号登录。",
)
else -> AdminCenterHint(
title = "加载失败",
detail = (failure as? AdminFailure.Other)?.message ?: "请稍后重试",
onRetry = onRetry,
)
}
}
/**
* 列表/详情页共用的取数外壳:先判权限,再判加载,最后才渲染数据。
* 顺序不能反——反了就会把 403 渲染成「暂无数据」。
*/
@Composable
fun <T> AdminLoadShell(
state: AdminUiState<T>,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit,
) {
val data = state.data
Box(modifier = modifier) {
when {
state.failure is AdminFailure.Forbidden -> AdminFailureState(state.failure, onRetry)
data == null && state.loading -> AdminCenterHint("加载中…")
data == null -> AdminFailureState(state.failure, onRetry)
else -> content(data)
}
}
}
/** 「左标签 + 右值」一行;必须放在 [PreferenceCard] 内。 */
@Composable
fun AdminValueRow(title: String, value: String, valueColor: Color? = null) {
BasicComponent(
title = title,
endActions = {
Text(
text = value,
style = MiuixTheme.textStyles.footnote1,
color = valueColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
},
)
}
/** 指标卡片:一组「标签 → 值」,单张卡片内多行。 */
@Composable
fun AdminMetricCard(rows: List<Pair<String, String>>) {
PreferenceCard {
Column {
rows.forEachIndexed { index, (label, value) ->
if (index > 0) {
HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
}
AdminValueRow(title = label, value = value)
}
}
}
}
/** 卡片首行:左标题 + 右尾注(长尾注省略)。两种文本卡共用,外观一致。 */
@Composable
private fun AdminCardHeader(title: String, trailing: String?, trailingColor: Color?) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MiuixTheme.textStyles.main, modifier = Modifier.weight(1f))
if (trailing != null) {
Text(
text = trailing,
modifier = Modifier.widthIn(max = 160.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MiuixTheme.textStyles.footnote1,
color = trailingColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
/** 管理与详情页里的普通文本块(长文本用:[PreferenceCard] 里一行标题 + 正文)。 */
@Composable
fun AdminTextCard(
title: String,
body: String,
trailing: String? = null,
trailingColor: Color? = null,
) {
PreferenceCard {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
AdminCardHeader(title, trailing, trailingColor)
if (body.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(
text = body,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
/**
* 承载 **Web 端 markup 原文**的文本块:正文来自 [ConclusionParagraph] 渲染模型,逐段渲染。
* 段与段之间用间距分隔(正文里不再出现拼接的 `\n`);段内片段按原文顺序同行排列,
* 正文用卡片正文色,动作/旁白(`Action` / `Narration`)按聊天页的约定渲染成**灰斜体**
* (见 `ui/chat/MessageBubble.kt` 的旁白行)。[notice] 是挂在段落下方的次要一行(灰、小字)。
*/
@Composable
fun AdminRichTextCard(
title: String,
paragraphs: List<ConclusionParagraph>,
notice: String? = null,
trailing: String? = null,
trailingColor: Color? = null,
) {
PreferenceCard {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
AdminCardHeader(title, trailing, trailingColor)
paragraphs.forEachIndexed { index, paragraph ->
if (index == 0) Spacer(Modifier.height(4.dp)) else Spacer(Modifier.height(8.dp))
ParagraphLine(paragraph)
}
if (!notice.isNullOrBlank()) {
Spacer(Modifier.height(if (paragraphs.isEmpty()) 4.dp else 8.dp))
Text(
text = notice,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
/** 一段:片段同行渲染,正文正常色,动作/旁白灰斜体;片段之间按需补一个空格。 */
@Composable
private fun ParagraphLine(paragraph: ConclusionParagraph) {
val narrationColor = MiuixTheme.colorScheme.onSurfaceVariantSummary
val line = remember(paragraph, narrationColor) {
buildAnnotatedString {
paragraph.parts.forEachIndexed { index, part ->
if (index > 0 && needsSpace(paragraph.parts[index - 1].text, part.text)) append(' ')
when (part) {
is ContentPart.Text -> append(part.text)
is ContentPart.Action, is ContentPart.Narration -> withStyle(
SpanStyle(color = narrationColor, fontStyle = FontStyle.Italic),
) { append(part.text) }
}
}
}
}
Text(
text = line,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainer,
)
}
/** 相邻片段要不要补空格:两边都没自带空白时才补,别在换行前留出多余空格。 */
private fun needsSpace(previous: String, next: String): Boolean =
previous.isNotEmpty() && next.isNotEmpty() &&
!previous.last().isWhitespace() && !next.first().isWhitespace()
@@ -0,0 +1,154 @@
package top.yeij.cyrene.ui.admin
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.domain.admin.AdminPage
import top.yeij.cyrene.domain.admin.AdminRole
import top.yeij.cyrene.domain.admin.canAccess
import top.yeij.cyrene.domain.admin.orDash
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.ChevronForward
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 管理只读监控(C-2):独立的顶层屏幕 + 独立 Scaffold,**不在聊天界面里**(design D15)。
*
* 结构是「入口页列出可用的管理页 → 逐页进入」的**可扩展壳**:页清单由
* [AdminPage] 驱动,新增一个管理模块只需在清单里加一项、在 [AdminPageScreen] 里加一个分支,
* 不需要改动本文件的布局。
*
* 权限降级(api-client §2.0):入口按角色禁用越权页(**不发请求**),
* ViewModel 再做一次同样的短路,服务端返回 403/401 时还有第三层「无权限」状态。
*/
@Composable
fun AdminHomeScreen(
container: AppContainer,
onBack: () -> Unit = {},
) {
val session by container.authManager.state.collectAsState()
val rawRole = (session as? SessionState.LoggedIn)?.role
val role = AdminRole.of(rawRole)
var openPage by remember { mutableStateOf<AdminPage?>(null) }
val viewModel: AdminViewModel = viewModel(
factory = viewModelFactory { initializer { AdminViewModel(container) } },
)
val page = openPage
BackHandler(enabled = page != null) { openPage = null }
if (page == null) {
AdminIndexScreen(
rawRole = rawRole,
role = role,
onOpen = { openPage = it },
onBack = onBack,
)
} else {
AdminPageScreen(
page = page,
role = role,
viewModel = viewModel,
onBack = { openPage = null },
)
}
}
/** 管理入口页:列出全部监控页,越权页显示为禁用的「无权限」行而不是隐藏。 */
@Composable
private fun AdminIndexScreen(
rawRole: String?,
role: AdminRole,
onOpen: (AdminPage) -> Unit,
onBack: () -> Unit,
) {
AdminPageScaffold(title = "管理员监控", onBack = onBack) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
Text(
text = "只读监控 · 当前角色 ${orDash(rawRole)}",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(start = 20.dp, top = 16.dp),
)
AdminSectionTitle("监控页")
AdminPage.entries.forEach { page ->
PreferenceCard { AdminPageRow(page = page, role = role, onOpen = onOpen) }
Spacer(Modifier.height(8.dp))
}
Spacer(Modifier.height(32.dp))
}
}
}
@Composable
private fun AdminPageRow(
page: AdminPage,
role: AdminRole,
onOpen: (AdminPage) -> Unit,
) {
if (canAccess(role, page)) {
BasicComponent(
title = page.title,
summary = page.summary,
onClick = { onOpen(page) },
endActions = {
Icon(imageVector = MiuixIcons.ChevronForward, contentDescription = null)
},
)
} else {
BasicComponent(
title = page.title,
summary = "需要 owner 或 admin 权限",
endActions = {
Text(
text = "无权限",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.error,
)
},
)
}
}
/** 管理页分发:每个页在自己的文件里带 Scaffold,这里只做映射。 */
@Composable
private fun AdminPageScreen(
page: AdminPage,
role: AdminRole,
viewModel: AdminViewModel,
onBack: () -> Unit,
) {
when (page) {
AdminPage.Overview -> AdminOverviewScreen(viewModel, role, onBack)
AdminPage.Personas -> AdminPersonaScreen(viewModel, role, onBack)
AdminPage.Memories -> AdminMemoryScreen(viewModel, role, onBack)
AdminPage.Backthink -> AdminBackthinkScreen(viewModel, role, onBack)
}
}
@@ -0,0 +1,59 @@
package top.yeij.cyrene.ui.admin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.domain.admin.AdminPage
import top.yeij.cyrene.domain.admin.AdminRole
import top.yeij.cyrene.domain.admin.memoryScopeLabel
import top.yeij.cyrene.domain.admin.orDash
/**
* 记忆页:`GET /api/v1/admin/memories`(**仅 `owner`/`admin`**,含私人记忆正文)。
* `viewer` 进不来:入口已禁用、ViewModel 也短路,服务端还会 403。
*/
@Composable
fun AdminMemoryScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
val state by viewModel.memories.collectAsState()
val reload = { viewModel.load(AdminPage.Memories, role) }
LaunchedEffect(Unit) { reload() }
AdminPageScaffold(title = AdminPage.Memories.title, onBack = onBack, onRefresh = reload) { padding ->
AdminLoadShell(
state = state,
onRetry = reload,
modifier = Modifier
.fillMaxSize()
.padding(padding),
) { memories ->
if (memories.isEmpty()) {
AdminCenterHint("暂无记忆")
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = memories, key = { it.id }) { memory ->
AdminTextCard(
// scope 是开放集合:未知取值原样显示。
title = orDash(memoryScopeLabel(memory.scope)),
body = memory.text,
trailing = memory.id,
)
}
}
}
}
}
}
@@ -0,0 +1,135 @@
package top.yeij.cyrene.ui.admin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.domain.admin.AdminFailure
import top.yeij.cyrene.domain.admin.AdminPage
import top.yeij.cyrene.domain.admin.AdminRole
import top.yeij.cyrene.domain.admin.formatCounts
import top.yeij.cyrene.domain.admin.formatDouble
import top.yeij.cyrene.domain.admin.formatNanos
import top.yeij.cyrene.domain.admin.formatProps
import top.yeij.cyrene.domain.admin.formatRfc3339
import top.yeij.cyrene.domain.admin.orDash
import top.yeij.cyrene.domain.admin.traceKindLabel
import top.yeij.cyrene.domain.admin.traceStatusLabel
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 概览页:`metrics` 卡片 + 其下的 `trace` 列表(两次请求,见 api-client §2「可观测性契约」)。
*/
@Composable
fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
val state by viewModel.overview.collectAsState()
val reload = { viewModel.load(AdminPage.Overview, role) }
LaunchedEffect(Unit) { reload() }
AdminPageScaffold(title = AdminPage.Overview.title, onBack = onBack, onRefresh = reload) { padding ->
val data = state.data
val failure = state.failure
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
when {
failure is AdminFailure.Forbidden -> AdminFailureState(failure, reload)
data == null && state.loading -> AdminCenterHint("加载中…")
data == null || (data.metrics == null && data.trace.isEmpty()) ->
AdminFailureState(failure, reload)
else -> LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp),
) {
item(key = "__metrics__") {
Column {
AdminSectionTitle("指标")
if (data.metrics != null) {
AdminMetricCard(metricRows(data.metrics))
Spacer(Modifier.height(8.dp))
AdminMetricCard(countRows(data.metrics))
} else {
AdminTextCard("指标不可用", failureMessage(failure))
}
}
}
item(key = "__trace_title__") { AdminSectionTitle("最近 trace") }
when {
// trace 失败时绝不渲染成「暂无数据」——那是把失败伪装成空成功。
failure != null -> item(key = "__trace_error__") {
AdminTextCard("trace 不可用", failureMessage(failure))
}
data.trace.isEmpty() -> item(key = "__trace_empty__") {
AdminInlineHint("暂无 trace span")
}
else -> items(items = data.trace, key = { it.id }) { span ->
TraceSpanCard(span)
}
}
item(key = "__bottom__") { Spacer(Modifier.height(32.dp)) }
}
}
}
}
}
private fun metricRows(metrics: AdminMetricsDto): List<Pair<String, String>> = listOf(
"总请求数" to metrics.totalRequests.toString(),
"QPS(近 1 秒)" to formatDouble(metrics.qps),
"平均延迟" to "${formatDouble(metrics.avgLatencyMs)} ms",
"最大延迟" to "${metrics.maxLatencyMs} ms",
"LLM 调用" to metrics.llmCalls.toString(),
"子代理事件" to metrics.subagentEvents.toString(),
)
private fun countRows(metrics: AdminMetricsDto): List<Pair<String, String>> = listOf(
"按方法" to formatCounts(metrics.perMethod).ifBlank { "-" },
"按路径" to formatCounts(metrics.perPath).ifBlank { "-" },
"状态码" to formatCounts(metrics.statusCounts).ifBlank { "-" },
)
@Composable
private fun TraceSpanCard(span: AdminTraceSpanDto) {
AdminTextCard(
title = span.name.ifBlank { span.id },
body = traceBody(span),
trailing = orDash(traceStatusLabel(span.status)),
trailingColor = if (span.status == "error") MiuixTheme.colorScheme.error else null,
)
}
private fun traceBody(span: AdminTraceSpanDto): String = buildList {
add("${traceKindLabel(span.kind)} · ${formatNanos(span.durationNs)}")
add("开始 ${formatRfc3339(span.startedAt) ?: orDash(span.startedAt)}")
span.endedAt?.let { add("结束 ${formatRfc3339(it) ?: it}") }
span.parentId?.let { add("父 span $it") }
formatProps(span.props)?.let { add("props $it") }
span.events?.forEach { event ->
val props = formatProps(event.props)?.let { " $it" }.orEmpty()
add("事件 ${event.type} @ ${formatRfc3339(event.time) ?: event.time}$props")
}
}.joinToString("\n")
private fun failureMessage(failure: AdminFailure?): String =
(failure as? AdminFailure.Other)?.message ?: "请求失败,请重试"
@@ -0,0 +1,117 @@
package top.yeij.cyrene.ui.admin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
import top.yeij.cyrene.data.api.dto.PersonaThinkingDto
import top.yeij.cyrene.domain.admin.AdminPage
import top.yeij.cyrene.domain.admin.AdminRole
import top.yeij.cyrene.domain.admin.moodLabel
import top.yeij.cyrene.domain.admin.orDash
import top.yeij.cyrene.domain.admin.personaModeLabel
import top.yeij.cyrene.domain.admin.triStateLabel
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 人格页:`GET /api/v1/admin/personas`(`viewer` 可读),含 admin 专属 `thinking` 旋钮。 */
@Composable
fun AdminPersonaScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
val state by viewModel.personas.collectAsState()
val reload = { viewModel.load(AdminPage.Personas, role) }
LaunchedEffect(Unit) { reload() }
AdminPageScaffold(title = AdminPage.Personas.title, onBack = onBack, onRefresh = reload) { padding ->
AdminLoadShell(
state = state,
onRetry = reload,
modifier = Modifier
.fillMaxSize()
.padding(padding),
) { personas ->
if (personas.isEmpty()) {
AdminCenterHint("暂无人格")
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = personas, key = { it.id }) { PersonaCard(it) }
}
}
}
}
}
@Composable
private fun PersonaCard(persona: PersonaSummaryDto) {
val catalog = LocalEnumCatalog.current
PreferenceCard {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(persona.name.ifBlank { persona.id }, style = MiuixTheme.textStyles.main)
if (persona.isDefault) {
Text(
text = " · 默认",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.primary,
)
}
}
PersonaDetail("id", persona.id)
PersonaDetail("character", persona.character.ifBlank { "-" })
// mode 是开放集合:权威表里没有的取值原样显示。
PersonaDetail("mode", personaModeLabel(persona.mode, catalog).ifBlank { "-" })
PersonaDetail("记忆域", persona.memoryNamespace.ifBlank { "-" })
// mood 为空表示「该角色暂无被跟踪的情绪」,不伪造默认值。
PersonaDetail(
"情绪",
persona.mood?.takeIf { it.isNotBlank() }?.let { moodLabel(it, catalog) } ?: "暂无",
)
PersonaDetail("思考", thinkingLabel(persona.thinking))
persona.avatar?.let { PersonaDetail("avatar", it) }
}
}
}
@Composable
private fun PersonaDetail(label: String, value: String) {
Spacer(Modifier.height(2.dp))
Text(
text = "$label:$value",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
/**
* `thinking` 未配置时该键**完全不出现**(不是全零对象);已配置时 `showToUser` 是三态,
* null 必须显示成「未配置」,不能拍平成「不显示」。
*/
private fun thinkingLabel(thinking: PersonaThinkingDto?): String {
if (thinking == null) return "未配置"
return listOf(
"budgetTokens ${thinking.budgetTokens}",
"effort ${orDash(thinking.effort)}",
"mode ${orDash(thinking.mode)}",
"showToUser ${triStateLabel(thinking.showToUser)}",
).joinToString(" · ")
}
@@ -0,0 +1,114 @@
package top.yeij.cyrene.ui.admin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
import top.yeij.cyrene.domain.admin.AdminFailure
import top.yeij.cyrene.domain.admin.AdminPage
import top.yeij.cyrene.domain.admin.AdminRole
import top.yeij.cyrene.domain.admin.canAccess
import top.yeij.cyrene.domain.admin.toAdminFailure
/**
* 单个管理页的取数状态。
* [data] 为 null 表示**还没拿到**(加载中或失败),空集合表示**确实没有数据**——
* 两者在 UI 上必须分开,否则 403 会伪装成「暂无数据」。
*/
data class AdminUiState<T>(
val loading: Boolean = false,
val data: T? = null,
val failure: AdminFailure? = null,
)
/** 概览页:metrics 与 trace 两次请求合成一份状态(`trace` 作为指标卡片下方列表)。 */
data class AdminOverviewData(
val metrics: AdminMetricsDto? = null,
val trace: List<AdminTraceSpanDto> = emptyList(),
)
/**
* 管理只读监控的四页取数(api-client §2)。
*
* [load] 先按 [AdminRole] 短路:角色不够的页**不发请求**,直接给
* [AdminFailure.Forbidden],UI 因此能显示「无权限」而不是等一个 403 回来。
*/
class AdminViewModel(private val container: AppContainer) : ViewModel() {
private val _overview = MutableStateFlow(AdminUiState<AdminOverviewData>())
val overview: StateFlow<AdminUiState<AdminOverviewData>> = _overview.asStateFlow()
private val _personas = MutableStateFlow(AdminUiState<List<PersonaSummaryDto>>())
val personas: StateFlow<AdminUiState<List<PersonaSummaryDto>>> = _personas.asStateFlow()
private val _memories = MutableStateFlow(AdminUiState<List<MemorySummaryDto>>())
val memories: StateFlow<AdminUiState<List<MemorySummaryDto>>> = _memories.asStateFlow()
private val _backthink = MutableStateFlow(AdminUiState<List<AdminBackthinkItemDto>>())
val backthink: StateFlow<AdminUiState<List<AdminBackthinkItemDto>>> = _backthink.asStateFlow()
/** 进入页面时调用;每次进入都重新取数(监控数据要新鲜)。 */
fun load(page: AdminPage, role: AdminRole) {
if (!canAccess(role, page)) {
deny(page)
return
}
viewModelScope.launch {
when (page) {
AdminPage.Overview -> loadOverview()
AdminPage.Personas -> loadList(_personas) { container.adminRepository.personas() }
AdminPage.Memories -> loadList(_memories) { container.adminRepository.memories() }
AdminPage.Backthink -> loadList(_backthink) { container.adminRepository.backthink() }
}
}
}
/** 概览页两个请求并发;任一 403 即整页无权限,其它失败则保留已到的部分。 */
private suspend fun loadOverview() {
_overview.value = _overview.value.copy(loading = true, failure = null)
val repository = container.adminRepository
val (metrics, trace) = coroutineScope {
val metricsCall = async { repository.metrics() }
val traceCall = async { repository.trace() }
metricsCall.await() to traceCall.await()
}
val failures = listOfNotNull(metrics.exceptionOrNull(), trace.exceptionOrNull())
.map { it.toAdminFailure() }
_overview.value = AdminUiState(
data = AdminOverviewData(
metrics = metrics.getOrNull(),
trace = trace.getOrNull().orEmpty(),
),
failure = failures.firstOrNull { it is AdminFailure.Forbidden } ?: failures.firstOrNull(),
)
}
private suspend fun <T> loadList(
target: MutableStateFlow<AdminUiState<List<T>>>,
fetch: suspend () -> Result<List<T>>,
) {
target.value = target.value.copy(loading = true, failure = null)
fetch()
.onSuccess { list -> target.value = AdminUiState(data = list) }
.onFailure { e -> target.value = AdminUiState(failure = e.toAdminFailure()) }
}
private fun deny(page: AdminPage) {
when (page) {
AdminPage.Overview -> _overview.value = AdminUiState(failure = AdminFailure.Forbidden)
AdminPage.Personas -> _personas.value = AdminUiState(failure = AdminFailure.Forbidden)
AdminPage.Memories -> _memories.value = AdminUiState(failure = AdminFailure.Forbidden)
AdminPage.Backthink -> _backthink.value = AdminUiState(failure = AdminFailure.Forbidden)
}
}
}
@@ -0,0 +1,96 @@
package top.yeij.cyrene.ui.chat
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.ui.admin.AdminHomeScreen
import top.yeij.cyrene.ui.common.MiuixDrawer
import top.yeij.cyrene.ui.sessions.SessionDrawerContent
import top.yeij.cyrene.ui.sessions.SessionsViewModel
import top.yeij.cyrene.ui.settings.SettingsScreen
/**
* 聊天页 + 侧边栏(会话切换)+ 设置页 + 管理员监控。
* 返回键逐层回退:管理/设置 → 聊天;抽屉打开时先关抽屉;都关着才交给系统(回桌面)。
*/
@Composable
fun ChatHomeScreen(
container: AppContainer,
onLogout: () -> Unit = {},
) {
val sessionsViewModel: SessionsViewModel = viewModel(
factory = viewModelFactory { initializer { SessionsViewModel(container) } },
)
val state by sessionsViewModel.state.collectAsState()
var drawerOpen by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
var showAdmin by remember { mutableStateOf(false) }
BackHandler(enabled = showAdmin) { showAdmin = false }
BackHandler(enabled = showSettings) { showSettings = false }
BackHandler(enabled = drawerOpen) { drawerOpen = false }
when {
showAdmin -> AdminHomeScreen(
container = container,
onBack = { showAdmin = false },
)
showSettings -> SettingsScreen(
container = container,
onBack = { showSettings = false },
onLogout = onLogout,
)
else -> MiuixDrawer(
open = drawerOpen,
// 抽屉只会被外部入口打开(MiuixDrawer 的 onOpenChange 只回报关闭),
// 所以这里不用管「打开」分支。
onOpenChange = { open -> drawerOpen = open },
content = {
ChatScreen(
container = container,
sessionId = state.activeSessionId,
onOpenDrawer = {
drawerOpen = true
// 抽屉打开时静默重拉:列表只在 init / 新建 / 删除时取过,而服务端的
// 自动标题与置顶顺序**没有任何 WS 帧**会推过来,只能靠这里对齐。
// 静默是为了不闪「同步中…」——屏幕上本来就有内容。
sessionsViewModel.refresh(silent = true)
},
onLogout = onLogout,
)
},
drawerContent = {
SessionDrawerContent(
state = state,
onSelect = { sessionId ->
sessionsViewModel.select(sessionId)
drawerOpen = false
},
onCreate = sessionsViewModel::createSession,
onDelete = { sessionsViewModel.deleteSession(it.id) },
onTogglePin = { sessionsViewModel.setPinned(it.id, !it.pinned) },
onRename = { sessionId, title -> sessionsViewModel.renameSession(sessionId, title) },
onOpenIot = { /* P2:IoT 页面 */ },
onOpenAdmin = {
drawerOpen = false
showAdmin = true
},
onOpenSettings = {
drawerOpen = false
showSettings = true
},
)
},
)
}
}
@@ -0,0 +1,332 @@
package top.yeij.cyrene.ui.chat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import coil3.compose.AsyncImage
import kotlinx.coroutines.flow.distinctUntilChanged
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.data.ws.WsState
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.basic.Sidebar
import top.yukonga.miuix.kmp.icon.extended.Image
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 判定「贴着底部」的容差(px)。 */
private const val BOTTOM_TOLERANCE = 64
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
private val InputBottomSpacer = 22.dp
@Composable
fun ChatScreen(
container: AppContainer,
sessionId: String,
onOpenDrawer: () -> Unit = {},
onLogout: () -> Unit = {},
) {
val viewModel: ChatViewModel = viewModel(
key = sessionId,
factory = viewModelFactory { initializer { ChatViewModel(container, sessionId) } },
)
val state by viewModel.state.collectAsState()
val listState = rememberLazyListState()
// 系统相册选择器(PickVisualMedia 走 Photo Picker,**不需要任何存储权限**)。
// 只把 URI 交给 VM:真正的解码 / 缩放 / base64 在发送时于 IO 线程做。
val imagePicker = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia(),
) { uri -> uri?.let { viewModel.onImagePicked(it.toString()) } }
// 是否跟随底部(微信/QQ 式):只有用户主动滚到别处时才停止跟随。
// 只在「滚动结束」时更新,避免新消息插入导致的锚点位移把状态带偏。
var followBottom by remember { mutableStateOf(true) }
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress }
.distinctUntilChanged()
.collect { scrolling ->
if (!scrolling) {
followBottom = listState.firstVisibleItemIndex == 0 &&
listState.firstVisibleItemScrollOffset < BOTTOM_TOLERANCE
}
}
}
// 最新一项变化(新消息 / 乐观气泡 / 流式气泡 / 落库归一)时,跟随则滚到底。
val newestKey = state.items.lastOrNull()?.key
LaunchedEffect(newestKey) {
if (newestKey != null && followBottom) {
listState.animateScrollToItem(0)
}
}
// reverseLayout 下「列表末尾」就是屏幕顶部;接近末尾时加载更早的一页。
LaunchedEffect(listState) {
snapshotFlow {
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - 2
}
.distinctUntilChanged()
.collect { nearTop -> if (nearTop) viewModel.loadOlder() }
}
Scaffold(
topBar = {
SmallTopAppBar(
title = "与昔涟",
subtitle = connectionLabel(state),
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(imageVector = MiuixIcons.Basic.Sidebar, contentDescription = "菜单")
}
},
)
},
bottomBar = {
InputBar(
value = state.draft,
imageUri = state.draftImage,
busyText = busyLabel(state),
onValueChange = viewModel::onDraftChange,
onSend = viewModel::send,
onPickImage = {
imagePicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
)
},
onClearImage = viewModel::clearDraftImage,
)
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
when {
state.loading && state.items.isEmpty() -> CenterHint("加载中…")
state.items.isEmpty() -> CenterHint("还没有消息")
else -> LazyColumn(
state = listState,
reverseLayout = true,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(items = state.items.asReversed(), key = { it.key }) { item ->
when (item) {
is ChatDisplayItem.Committed -> MessageBubble(item.message)
is ChatDisplayItem.Pending -> PendingBubble(item.item)
ChatDisplayItem.Streaming -> state.streaming?.let { StreamingBubble(it) }
}
}
if (state.hasMore) {
item(key = "__load_older__") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = if (state.loadingOlder) "加载更早的消息…" else "上滑加载更早的消息",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
}
state.error?.let { message ->
Text(
text = message,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.error,
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.background(MiuixTheme.colorScheme.surfaceContainer)
.padding(12.dp),
)
}
}
}
}
@Composable
private fun InputBar(
value: String,
imageUri: String?,
busyText: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
onPickImage: () -> Unit,
onClearImage: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.imePadding()
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
) {
imageUri?.let { DraftImagePreview(uri = it, onClear = onClearImage) }
Row(verticalAlignment = Alignment.Bottom) {
// 图片入口只在没有待发图片时出现:已经选好一张就没必要再添一张(一次一条消息一张图)。
if (imageUri == null) {
IconButton(onClick = onPickImage) {
Icon(
imageVector = MiuixIcons.Image,
contentDescription = "发送图片",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
}
TextField(
value = value,
onValueChange = onValueChange,
label = "和昔涟说点什么…",
useLabelAsPlaceholder = true,
maxLines = 5,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Button(
onClick = onSend,
// 图片可以单独发出(契约:text / image 至少一项非空)。
enabled = value.isNotBlank() || imageUri != null,
) {
Text("发送")
}
}
// 输入栏下方预留的空间:让输入框/按钮避开屏幕圆角,同时放忙碌提示
Box(
modifier = Modifier
.fillMaxWidth()
.height(InputBottomSpacer)
.padding(start = 12.dp, top = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = busyText,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
/** 待发图片的本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。 */
@Composable
private fun DraftImagePreview(uri: String, onClear: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box {
AsyncImage(
model = uri,
contentDescription = "待发送的图片",
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(10.dp)),
)
IconButton(
onClick = onClear,
modifier = Modifier.align(Alignment.TopEnd),
) {
Icon(
imageVector = MiuixIcons.Basic.Close,
contentDescription = "移除图片",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
@Composable
private fun CenterHint(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
/** 顶栏副标题:只反映连接状态。 */
private fun connectionLabel(state: ChatUiState): String = when (state.connection) {
WsState.Connected -> "已连接"
WsState.Connecting -> "连接中…"
WsState.Reconnecting -> "重连中…"
WsState.Offline -> "离线"
WsState.Idle -> ""
}
/**
* 输入栏下方的忙碌提示。
*
* 三个来源共用这一行:WS 的 `session_busy`([ChatUiState.busyMode])、WS 的
* `pending_ack`/`pending_drain` 与 REST `202 {queued:true,queueDepth}`([ChatUiState.queueDepth])。
* REST 的 202 只给得到条数、给不到忙碌通道,所以队列深度**单独也能成句**——否则 202 之后到
* `session_busy` 之前会有一小段什么都看不见的空档。
*/
private fun busyLabel(state: ChatUiState): String = when {
state.busyMode != null && state.queueDepth > 0 -> "正在思考… 已排队 ${state.queueDepth} 条"
state.busyMode != null -> "正在思考…"
state.queueDepth > 0 -> "已排队 ${state.queueDepth} 条"
else -> ""
}
@@ -0,0 +1,722 @@
package top.yeij.cyrene.ui.chat
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.ChatRequest
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.image.ChatImagePreparer
import top.yeij.cyrene.data.image.PreparedImage
import top.yeij.cyrene.data.repository.ChatAck
import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.historyForSession
import top.yeij.cyrene.data.repository.mergeMessages
import top.yeij.cyrene.data.ws.ChatSocket
import top.yeij.cyrene.data.ws.WsEvent
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.data.ws.isForSession
import top.yeij.cyrene.domain.chat.ChatImageDelivery
import top.yeij.cyrene.domain.chat.ChatSendRouting
import top.yeij.cyrene.domain.chat.ImageDelivery
import top.yeij.cyrene.domain.chat.SendChannel
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.StreamingContentParser
import java.util.UUID
const val MAIN_SESSION_ID = "web"
/** 本端标识(design/16 §4.5 允许的可读前缀形态)。 */
private const val DEVICE_ID = "voiceassist:cyrene:v1"
/**
* 乐观消息:已发出、还没等到 `message_persisted` 归一的本地消息。
*
* [localId] 同时是**上行幂等键** `clientMsgId`(契约 v0.56 §3.13):同一会话内服务端只承认
* 第一个非空值,重复到达回 `chat_duplicate`(WS)或 `{duplicate:true,messageId}`(REST)而
* **不再落库、不再起回合**。所以手动重试可以**复用**同一个 id,弱网重发不会变成第二条消息。
* 它必须足够唯一:服务端的判重窗口是**整个会话历史**(键没有过期清理),进程重启后复用
* 旧值会被当成重复、消息永远发不出去——因此用随机值而不是自增序号。
*/
data class PendingMessage(
val localId: String,
val text: String,
/** 本次带的图片的**本地**预览(`content://` URI);null = 纯文本消息。 */
val imageUri: String? = null,
)
/**
* 落库的 user 消息到达时,该从待发列表里去掉哪一个乐观气泡(纯函数;返回新列表)。
*
* **首选 `clientMsgId`**(契约 v0.56 §3.13:带键发出的消息服务端会原样回填):键相等即同一条
* 消息,**文本不同**也绝不会认错(发送时被 trim、服务端将来会再加工)。这是 v0.56 专门为
* 「乐观气泡归一」加的通道,用来取代此前唯一的「同文本」匹配。
*
* 落库消息**没有键**时(老客户端发的、IM 镜像进来的、v0.56 之前的历史行)才回落到既有规则:
* 去掉**第一条同文本**的气泡——服务端若是旧版本,永远只走这条路,行为与加入 REST 之前一致。
*
* 命中**恰好一条**且不修改入参:同一帧被重放、或两条同文本消息分别落库时,都不会一次吃掉两个气泡。
*/
fun consumePendingFor(
pending: List<PendingMessage>,
message: HistoryMessageDto,
): List<PendingMessage> {
val key = message.clientMsgId?.takeIf { it.isNotBlank() }
val index = if (key != null) {
pending.indexOfFirst { it.localId == key }
} else {
pending.indexOfFirst { it.text == message.content }
}
if (index < 0) return pending
return pending.filterIndexed { i, _ -> i != index }
}
/**
* 工具执行的细粒度阶段,与后端流程图 tool 节点 status **同一套枚举**(契约 v0.50 §3.1 / §3.5)。
* `null` = 还没收到任何 `tool_execution.*` 帧(只有调用与参数)。
*/
enum class ToolPhase { Running, Done, Error }
/** 后端 `phase` 字符串 → 本地阶段;未知取值回落 `null`(不猜、不替服务端造枚举)。 */
fun toolPhaseOf(phase: String): ToolPhase? = when (phase) {
"running" -> ToolPhase.Running
"done" -> ToolPhase.Done
"error" -> ToolPhase.Error
else -> null
}
/** 一次工具调用的中间态(参数增量累积 + 执行阶段 / 输出)。 */
data class ToolCallState(
val id: String,
val index: Int,
/** 工具名;`tool_call.*` 帧不带,由 `tool_execution.*` 帧补上。 */
val tool: String = "",
val arguments: String = "",
val output: String = "",
/** `tool_call.end` 已到(参数流完整)。 */
val done: Boolean = false,
/** 执行阶段;`null` = 尚未进入执行。 */
val phase: ToolPhase? = null,
)
/**
* 一帧 `tool_execution.*` 该落到 [tools] 的哪张卡上(纯函数;返回下标,`-1` = 不落位)。
*
* **关联键是 `id`**(provider 的 tool call id,与同轮 `tool_call.*` 同源;这三帧刻意不带
* `index`——它们不属于任何一次模型流)。`id` 有值但不匹配任何卡 → `-1`:宁可丢这一帧的状态,
* 也**不**错挂到别的工具上。
*
* **`id` 缺席时的回落**(后端在 provider 没给 id 时整个省略该键):只在**无歧义**时才落位——
* 1. `id` 为空的工具卡恰好只有一张 → 落那张(id 缺席与 id 为空是同一件事);
* 2. 否则整个列表恰好只有一张卡 → 落那张(这一轮只有一次工具调用,id 缺席不会认错);
* 3. 其余情况(多张卡且无法唯一识别)→ `-1`,**不**默认落 `index == 0` / 第一张。
*/
fun toolExecutionTargetIndex(tools: List<ToolCallState>, id: String?): Int {
val key = id?.takeIf { it.isNotBlank() }
if (key != null) return tools.indexOfFirst { it.id == key }
val anonymous = tools.indices.filter { tools[it].id.isBlank() }
if (anonymous.size == 1) return anonymous.single()
if (tools.size == 1) return 0
return -1
}
/**
* 把一帧 `tool_execution.*` 应用到工具卡列表(纯函数,VM 与测试共用)。
*
* 没找到落点时**原样返回**:不新建卡、不改别的卡。`tool` 为空白时保留卡上已有的名字,
* `phase` 无法识别时保留卡上已有的阶段,`delta` 为空时不改输出。
*/
fun applyToolExecution(
tools: List<ToolCallState>,
id: String?,
tool: String,
phase: String,
delta: String? = null,
): List<ToolCallState> {
val target = toolExecutionTargetIndex(tools, id)
if (target < 0) return tools
return tools.mapIndexed { index, state ->
if (index != target) {
state
} else {
state.copy(
tool = tool.ifBlank { state.tool },
phase = toolPhaseOf(phase) ?: state.phase,
output = if (delta.isNullOrEmpty()) state.output else state.output + delta,
)
}
}
}
/** 正在流式的助手回复。 */
data class StreamingState(
val bubbles: List<ParsedBubble> = emptyList(),
val thinking: String = "",
val thinkingActive: Boolean = false,
val tools: List<ToolCallState> = emptyList(),
val active: Boolean = true,
)
sealed interface ChatDisplayItem {
val key: String
data class Committed(val message: HistoryMessageDto) : ChatDisplayItem {
override val key: String get() = "m:${message.id}"
}
data class Pending(val item: PendingMessage) : ChatDisplayItem {
override val key: String get() = "p:${item.localId}"
}
data object Streaming : ChatDisplayItem {
override val key: String get() = "s"
}
}
data class ChatUiState(
/** 已落库消息,升序(旧 → 新)。 */
val messages: List<HistoryMessageDto> = emptyList(),
val pending: List<PendingMessage> = emptyList(),
val streaming: StreamingState? = null,
val draft: String = "",
/** 已选好、还没发出去的图片(本地 `content://` URI 字符串);null = 没有待发图片。 */
val draftImage: String? = null,
val loading: Boolean = true,
val loadingOlder: Boolean = false,
val hasMore: Boolean = true,
val error: String? = null,
val connection: WsState = WsState.Idle,
/** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */
val busyMode: String? = null,
/**
* 待处理队列深度。两个来源:WS 的 `pending_ack` / `pending_drain`,
* 以及 REST `202 {queued:true,queueDepth:n}` 的受理回执(同一个队列,同一份语义)。
*/
val queueDepth: Int = 0,
) {
/** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */
val items: List<ChatDisplayItem>
get() = buildList {
messages.forEach { add(ChatDisplayItem.Committed(it)) }
pending.forEach { add(ChatDisplayItem.Pending(it)) }
if (streaming != null) add(ChatDisplayItem.Streaming)
}
}
/**
* M2 + M3 + M4:只读历史 + 发送 + 流式 + 思维链/工具卡。
*
* 数据来源四路,全部按 `id` 去重收敛:
* 1. REST 首屏 / 上拉分页(权威,带游标)
* 2. WS 首帧 `history`
* 3. WS 增量 `message_persisted`(含乐观消息归一:按 `clientMsgId` 首选、同文本兜底)
* 4. WS 流式 `thinking.* / tool_call.* / message.* / stream_end`(临时气泡,落库后由 3 取代)
*
* 上行两条通道(路由见 [ChatSendRouting]):短文本走 WS `chat` 帧,长文本 / 任何图片走
* REST `POST /api/v1/chat`(`stream:true`)——**收都在 WS 上**,所以两种发的体验一致。
* 图片在 REST 之前还多一层选择:小图内联成 `data:` URI,大图先 `POST /api/v1/upload`
* 换 URL 再发(见 [deliverImage])——服务端把 `image` **原样落库**,内联的字节会在每一页
* 历史与每一次 `history` 回放里重传,上传是唯一能把这个成本买断的手段。
*
* 本 VM 只收**属于 [sessionId] 的帧**(见 [onEvent] 的会话闸)。服务端广播不按会话订阅,
* 且每个会话各有一个 VM(各自的 socket 都还活着),没有这道闸就会串台。
*/
class ChatViewModel(
private val container: AppContainer,
private val sessionId: String = MAIN_SESSION_ID,
) : ViewModel() {
private val _state = MutableStateFlow(ChatUiState())
val state: StateFlow<ChatUiState> = _state.asStateFlow()
private var socket: ChatSocket? = null
private var repository: MessageRepository? = null
private var oldestLoadedAt: Long? = null
private var streamParser: StreamingContentParser? = null
init {
start()
}
private fun start() {
val repo = container.messageRepository
repository = repo
viewModelScope.launch {
repo.loadLatest(sessionId)
.onSuccess { page ->
oldestLoadedAt = page.oldestCreatedAt
_state.update {
it.copy(
messages = mergeMessages(it.messages, page.messages),
loading = false,
hasMore = page.hasMore,
error = null,
)
}
}
.onFailure { e ->
_state.update { it.copy(loading = false, error = e.userMessage()) }
}
}
val ws = container.createChatSocket()
socket = ws
viewModelScope.launch {
ws.state.collect { connection -> _state.update { it.copy(connection = connection) } }
}
viewModelScope.launch { ws.events.collect(::onEvent) }
ws.connect()
}
fun onDraftChange(text: String) = _state.update { it.copy(draft = text) }
/** 选好待发图片(本地 `content://` URI);传 null 或空白串即清除。 */
fun onImagePicked(uri: String?) =
_state.update { it.copy(draftImage = uri?.takeIf { value -> value.isNotBlank() }) }
fun clearDraftImage() = _state.update { it.copy(draftImage = null) }
/**
* 发送:先乐观上屏,再按路由决定走哪条上行通道;失败回滚并把文本 / 图片还给输入框。
*
* 路由 [ChatSendRouting]:**短文本且无图 → WS**(直达、绕过有损总线,今天就能用);
* **长文本或任何图片 → REST `stream:true`**。REST 起的回合**同样在已连接的 WS 上流式
* 回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。
*
* 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上;
* 气泡同步上屏,所以等待期间用户已经能看到它。重编码之后再按体积决定**内联还是上传**
* (见 [deliverImage] 与 [ChatImageDelivery])——大图宁可多走一次 `POST /api/v1/upload`,
* 也不把几 MB base64 永久留在每一页历史里。
*/
fun send() {
val snapshot = _state.value
val text = snapshot.draft.trim()
val imageUri = snapshot.draftImage
if (text.isEmpty() && imageUri == null) return
// localId 即幂等键:重试复用同一个值,服务端不会把它当成第二条消息。
val localId = "local-${UUID.randomUUID()}"
_state.update {
it.copy(
pending = it.pending + PendingMessage(localId, text, imageUri),
draft = "",
draftImage = null,
error = null,
)
}
viewModelScope.launch { deliver(localId, text, imageUri) }
}
private suspend fun deliver(localId: String, text: String, imageUri: String?) {
val image = imageUri?.let { uri ->
withContext(Dispatchers.IO) { ChatImagePreparer.prepare(container.appContext, Uri.parse(uri)) }
}
// 图片处理失败就**不发**:绝不退回原图 base64(会撞服务端 5 MiB 上限、也会把历史背成几 MB)。
if (imageUri != null && image == null) {
rollback(localId, "图片处理失败,请换一张")
return
}
when (ChatSendRouting.channelFor(text, hasImage = image != null)) {
SendChannel.Ws -> {
val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId) ?: false
if (!sent) rollback(localId, "发送失败,请重试")
}
SendChannel.Rest -> if (image == null) {
sendViaRest(localId, text, image = null)
} else {
deliverImage(localId, text, image)
}
}
}
/**
* 一张图怎么送上去:小图内联成 `data:` URI,大图先上传、再把返回的 URL 当 `image`
* (阈值与这笔成本账见 [ChatImageDelivery])。两者都走 REST `stream:true`,收都在 WS 上。
*
* **上传失败不回退内联**:回退等于把「省历史重量」这件事当场作废——用户以为发的是 URL、
* 落库的却是几 MB base64,而且是在他看不见的情况下换了一条语义不同的路。这里与
* [ChatImagePreparer] 同一纪律:宁可回滚、把图和文本还给输入框让用户重试。
*/
private suspend fun deliverImage(localId: String, text: String, image: PreparedImage) {
when (ChatImageDelivery.forPreparedBytes(image.bytes.size)) {
ImageDelivery.Inline -> sendViaRest(localId, text, image.dataUri)
ImageDelivery.Upload -> container.uploadRepository
.uploadImage(image.bytes, image.mime)
.onSuccess { uploaded -> sendViaRest(localId, text, uploaded.url) }
.onFailure { e -> rollback(localId, uploadFailureMessage(e)) }
}
}
private suspend fun sendViaRest(localId: String, text: String, image: String?) {
val request = ChatRequest(
sessionId = sessionId,
text = text,
image = image,
deviceId = DEVICE_ID,
// 必须 true:`stream:false` 是同步的,服务端**没有写超时**而客户端读超时 60s——
// 慢回合会在客户端先超时、服务端还在跑,重发就是第二次对话(契约明确禁用)。
stream = true,
clientMsgId = localId,
)
container.chatRepository
.send(request)
.onSuccess { ack -> onAck(localId, ack) }
.onFailure { e -> rollback(localId, e.userMessage()) }
}
/** REST 受理回执的六个分支(契约 v0.56 的响应面;形状见 [ChatAck])。 */
private fun onAck(localId: String, ack: ChatAck) {
when (ack) {
// 200 {stream:true}:回合已起,气泡等 message_persisted 归一。
ChatAck.Accepted -> Unit
// 202 {queued:true}:已**落库并续接排队**。轻量态交给既有忙碌标签(busyLabel 认
// queueDepth);队列清空时 WS 的 `pending_drain` / `session_idle` 会把它归零。
is ChatAck.Queued -> _state.update { it.copy(queueDepth = ack.queueDepth) }
// 幂等重复:服务端没有重复落库、也没有起第二个回合——对齐既有消息,**不是错误**。
is ChatAck.Duplicate -> alignDuplicate(localId, ack.messageId)
// 409 队列满:这条**没有被受理、没有落库**(服务端在入队判定处直接拒绝),
// 所以不能当成「已排队」——回滚气泡并把文本还回输入框,用的是既有忙碌文案的口径。
ChatAck.QueueFull -> rollback(localId, "会话正忙,待处理消息已满,请稍后再发")
is ChatAck.Unavailable -> rollback(localId, "服务端暂时接不住这一轮,请稍后重试")
is ChatAck.Rejected -> rollback(localId, rejectMessage(ack))
}
}
private fun rejectMessage(rejected: ChatAck.Rejected): String = when (rejected.code) {
"image_too_large" -> "图片过大,请换一张更小的"
else -> rejected.message.ifBlank { "发送失败,请重试" }
}
/**
* 幂等重复的对齐(REST `{duplicate:true}` 与 WS `chat_duplicate` 共用):
* 去掉那条乐观气泡;本地若**没有**这条消息,再补拉最新一页。
*
* 复用同一个 `clientMsgId` 重试时,正常路径本端早就收到过 `message_persisted`(第一次发的
* 时候),但弱网 / 重连丢帧会留下「气泡没了、消息也没了」的空档——用 `messageId` 补一次
* 增量拉取才是真的「对齐到服务端真相」,而不是让气泡凭空消失。
*/
private fun alignDuplicate(clientMsgId: String, messageId: String?) {
_state.update { state ->
state.copy(pending = state.pending.filterNot { it.localId == clientMsgId })
}
val known = messageId != null && _state.value.messages.any { it.id == messageId }
if (messageId != null && !known) refreshLatest()
}
/** 静默补拉最新一页(成功才并进来;失败不改状态——这只是一次对齐尝试)。 */
private fun refreshLatest() {
val repo = repository ?: return
viewModelScope.launch {
repo.loadLatest(sessionId).onSuccess { page ->
page.oldestCreatedAt?.let { oldest -> oldestLoadedAt = minOf(oldestLoadedAt ?: oldest, oldest) }
_state.update {
it.copy(
messages = mergeMessages(it.messages, page.messages),
pending = reconcilePending(it.pending, page.messages),
)
}
}
}
}
/**
* 一批**服务端权威消息**到达时,把它们对应的乐观气泡一并收掉(按 `clientMsgId`)。
*
* 通路除了 `message_persisted`,还有两条同样会带来「这条早就落库了」的路径:
* WS 握手 `history` 回放(重连 / 冷启动)与 REST 补拉。少了这一步,
* 「发出去 → 丢帧 → 重连回放」会留下**乐观气泡与落库消息同屏重复**。
*/
private fun reconcilePending(
pending: List<PendingMessage>,
incoming: List<HistoryMessageDto>,
): List<PendingMessage> = incoming
.filter { it.role == "user" }
.fold(pending) { acc, message -> consumePendingFor(acc, message) }
/**
* 回滚乐观气泡:把文本 / 图片还给输入框并给出原因(挂掉的气泡本身就是权威副本)。
* 空文本(纯图片)不覆盖用户当前正在输入的内容;图片照原样还给待发位,不必让用户重挑。
*/
private fun rollback(localId: String, reason: String) {
_state.update { state ->
val failed = state.pending.firstOrNull { it.localId == localId }
state.copy(
pending = state.pending.filterNot { it.localId == localId },
draft = failed?.text?.ifBlank { state.draft } ?: state.draft,
draftImage = failed?.imageUri ?: state.draftImage,
error = reason,
)
}
}
private fun onEvent(event: WsEvent) {
// 广播是发给每条已认证连接的(服务端没有按会话订阅),所以每个 VM 都会收到**所有**会话的帧。
// 帧壳自服务端 v0.36 起带 `sessionId`,先按它收敛:明确属于别的会话 → 整帧丢掉,
// 否则上一会话的 VM 会拿新会话的 delta 跑自己的流式状态机(思维链/工具卡/正文串台)。
// `history` 不走这道闸——它的归属由帧内**每条消息**的 sessionId 判定(见下面的
// `historyForSession`),逐条过滤比帧壳更细,也更耐「将来服务端给次级会话回放」。
if (event !is WsEvent.History && !event.isForSession(sessionId)) return
when (event) {
is WsEvent.History -> {
// 这一帧是**主会话**的回放,却会被投给任意 ChatViewModel——必须先收敛到本会话,
// 否则进次级会话会看到主会话的消息混进来。
val incoming = historyForSession(event.messages, sessionId)
if (incoming.isEmpty()) {
_state.update { it.copy(loading = false) }
} else {
val oldest = incoming.minOf { it.createdAt }
oldestLoadedAt = oldestLoadedAt?.let { minOf(it, oldest) } ?: oldest
_state.update { state ->
state.copy(
messages = mergeMessages(state.messages, incoming),
pending = reconcilePending(state.pending, incoming),
loading = false,
)
}
}
}
// —— 流式正文 ——
is WsEvent.MessageStart -> {
if (event.index == 0) {
streamParser = StreamingContentParser()
freshTurnIfFinished()
}
val parser = streamParser ?: StreamingContentParser().also { streamParser = it }
val bubbles = parser.onDelta("")
updateStreaming { it.copy(bubbles = bubbles, active = true) }
}
is WsEvent.MessageDelta -> {
val parser = streamParser ?: StreamingContentParser().also { streamParser = it }
val bubbles = parser.onDelta(event.delta)
updateStreaming { it.copy(bubbles = bubbles, active = true) }
}
// —— 思维链 ——
is WsEvent.ThinkingStart -> {
if (event.index == 0) freshTurnIfFinished()
updateStreaming { it.copy(thinkingActive = true) }
}
is WsEvent.ThinkingDelta -> updateStreaming {
it.copy(thinking = it.thinking + event.delta, thinkingActive = true)
}
is WsEvent.ThinkingEnd -> updateStreaming { it.copy(thinkingActive = false) }
// —— 工具调用 ——
is WsEvent.ToolCallStart -> updateStreaming { streaming ->
if (streaming.tools.any { it.id == event.id }) {
streaming
} else {
streaming.copy(tools = streaming.tools + ToolCallState(event.id, event.index))
}
}
is WsEvent.ToolCallDelta -> updateStreaming { streaming ->
streaming.copy(
tools = streaming.tools.map {
if (it.id == event.id) it.copy(arguments = it.arguments + event.delta) else it
},
)
}
is WsEvent.ToolCallEnd -> updateStreaming { streaming ->
streaming.copy(
tools = streaming.tools.map { if (it.id == event.id) it.copy(done = true) else it },
)
}
is WsEvent.ToolExecutionStart -> onToolExecution(event.id, event.tool, event.phase)
is WsEvent.ToolExecutionUpdate ->
onToolExecution(event.id, event.tool, event.phase, event.delta)
is WsEvent.ToolExecutionEnd -> onToolExecution(event.id, event.tool, event.phase)
is WsEvent.StreamEnd -> {
val bubbles = streamParser?.finish().orEmpty()
updateStreaming {
it.copy(bubbles = bubbles, thinkingActive = false, active = false)
}
}
is WsEvent.MessagePersisted -> onPersisted(event.message)
is WsEvent.SessionBusy -> if (event.sessionId == sessionId) {
_state.update { it.copy(busyMode = event.mode) }
}
is WsEvent.PendingAck -> if (event.sessionId == sessionId) {
_state.update { it.copy(busyMode = event.mode, queueDepth = event.queueDepth) }
}
is WsEvent.PendingDrain -> if (event.sessionId == sessionId) {
_state.update { it.copy(queueDepth = event.queueDepth) }
}
// 幂等重复(契约 v0.56 §3.13):这条消息**早已受理过**,本端重发(弱网 / 切后台)撞上了
// 服务端的部分唯一索引。它不是错误——按既有 messageId 对齐气泡即可,绝不弹报错。
is WsEvent.ChatDuplicate -> if (event.sessionId == sessionId) {
alignDuplicate(event.clientMsgId, event.messageId)
}
is WsEvent.SessionIdle -> if (event.sessionId == sessionId) {
_state.update { it.copy(busyMode = null, queueDepth = 0) }
}
is WsEvent.SessionInterrupted -> if (event.sessionId == sessionId) {
_state.update { it.copy(busyMode = null, queueDepth = 0) }
}
is WsEvent.Error -> _state.update { it.copy(error = event.message) }
else -> Unit
}
}
/** 新一轮开始时,若上一轮的流式状态已结束(active=false)就清掉,避免思维链/工具卡串台。 */
private fun freshTurnIfFinished() {
_state.update { state ->
val streaming = state.streaming
if (streaming != null && !streaming.active) state.copy(streaming = StreamingState()) else state
}
}
private fun updateStreaming(block: (StreamingState) -> StreamingState) {
_state.update { state ->
state.copy(streaming = block(state.streaming ?: StreamingState()))
}
}
/**
* 收口三帧 `tool_execution.*`:按 `id` 关联到对应的工具卡(关联规则见
* [toolExecutionTargetIndex]),落 phase / 工具名 / 局部输出。落不到就整帧忽略。
*/
private fun onToolExecution(id: String?, tool: String, phase: String, delta: String? = null) {
updateStreaming { streaming ->
streaming.copy(tools = applyToolExecution(streaming.tools, id, tool, phase, delta))
}
}
/**
* 落库消息到达:
* - user:把对应的乐观气泡去掉——**首选 `clientMsgId`**(v0.56 起服务端回填),
* 没有键时才回落到「同文本」匹配(见 [consumePendingFor])
* - assistant:用落库内容取代流式临时气泡
*/
private fun onPersisted(message: HistoryMessageDto?) {
val msg = message ?: return
if (msg.sessionId != sessionId) return
val clearStreaming = msg.role == "assistant"
// 流式结束:把这一轮的思维链挂到落库消息上,保留 10 分钟后再自动消失
val streamedThinking = if (clearStreaming) {
_state.value.streaming?.thinking?.takeIf { it.isNotBlank() }
} else {
null
}
val persisted = if (streamedThinking != null) msg.copy(thinking = streamedThinking) else msg
if (clearStreaming) streamParser = null
_state.update { state ->
state.copy(
messages = mergeMessages(state.messages, listOf(persisted)),
pending = if (msg.role == "user") consumePendingFor(state.pending, msg) else state.pending,
streaming = if (clearStreaming) null else state.streaming,
)
}
if (streamedThinking != null) {
viewModelScope.launch {
kotlinx.coroutines.delay(THINKING_LINGER_MILLIS)
_state.update { state ->
state.copy(
messages = state.messages.map {
if (it.id == persisted.id) it.copy(thinking = null) else it
},
)
}
}
}
}
/** 上拉加载更早的一页。 */
fun loadOlder() {
val repo = repository ?: return
val before = oldestLoadedAt ?: return
val snapshot = _state.value
if (snapshot.loadingOlder || !snapshot.hasMore) return
_state.update { it.copy(loadingOlder = true) }
viewModelScope.launch {
repo.loadPage(sessionId, before = before)
.onSuccess { page ->
if (page.oldestCreatedAt != null) oldestLoadedAt = page.oldestCreatedAt
_state.update {
it.copy(
messages = mergeMessages(it.messages, page.messages),
loadingOlder = false,
hasMore = page.hasMore && page.messages.isNotEmpty(),
)
}
}
.onFailure { e ->
_state.update { it.copy(loadingOlder = false, error = e.userMessage()) }
}
}
}
fun dismissError() = _state.update { it.copy(error = null) }
override fun onCleared() {
socket?.close()
socket = null
super.onCleared()
}
}
private fun Throwable.userMessage(): String =
(this as? CyreneException)?.error?.userMessage ?: "加载失败,请稍后重试"
/**
* 上传失败的文案(纯函数,可单测)。
*
* 前两条是服务端的**机器可读**拒绝码(对着在线后端实测过,见 `UploadDtoRealPayloadTest`):
* 在本端预算下它们不该出现(重编码出口 ≤ 1 MiB,类型非 png 即 jpeg,都在白名单里),
* 但真出现时正确的动作是「换一张图」而不是「重试」,文案必须把这两者分开。
*
* 其余(网络 / 超时 / 5xx)用既有错误口径,并**点明是上传这一步**失败——否则用户看到
* 「无法连接到服务器」会以为整条消息没发出去,而实际上文字也是跟着一起回滚的。
*/
fun uploadFailureMessage(e: Throwable): String {
val error = (e as? CyreneException)?.error
return when ((error as? CyreneError.Server)?.code) {
"payload_too_large" -> "图片超过服务端 5 MB 上限,请换一张更小的"
"unsupported_media_type" -> "服务端不接受这张图片的格式,请换一张"
else -> "图片上传失败," + (error?.userMessage ?: "请重试")
}
}
/** 思维链在流式结束后继续保留的时长。 */
private const val THINKING_LINGER_MILLIS = 10 * 60 * 1000L
@@ -0,0 +1,199 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import top.yeij.cyrene.core.prefs.LocalAppPrefs
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 流式进行中的光标。 */
private const val CURSOR = "▍"
/**
* 一条落库消息渲染成一个或多个气泡:
* - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分,
* `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方;
* - 其它 mode 原样整条渲染,不解析标签;
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
* - `image` 非空时在气泡上方显示图片。
*
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
* 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。
*/
@Composable
fun MessageBubble(message: HistoryMessageDto) {
val isUser = message.role == "user"
// 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同:
// 表只能给「id → 标签」,说不出哪个 mode 是「按 markup 切气泡的聊天式渲染」,
// 所以这条字面量刻意保留为客户端渲染契约,不表驱动。
val isChat = message.mode.isNullOrBlank() || message.mode == "chat"
val bubbles = remember(message.content, isChat) {
val parsed = if (isChat) parse(message.content).filterNot { it.isEmpty } else emptyList()
parsed.ifEmpty { listOf(plainBubble(message.content)) }
}
MessageGroup(isUser = isUser, footer = messageSourceLabel(message, LocalEnumCatalog.current)) {
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
bubbles.forEachIndexed { index, bubble ->
Bubble(bubble, isUser, isLast = index == bubbles.lastIndex)
}
}
}
/**
* 乐观发送中的用户消息(还没落库)。
*
* 图片显示的是**本地** `content://` URI(不是上行的那段 base64):预览不需要联网、
* 也不必把几 MB 的字符串塞进 UI 状态。落库后由 `message.image` 接管。
*/
@Composable
fun PendingBubble(item: PendingMessage) {
MessageGroup(isUser = true, footer = "发送中…") {
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
}
}
/** 正在流式的助手回复:思维链 → 工具卡 → 气泡(末尾带光标)。 */
@Composable
fun StreamingBubble(streaming: StreamingState) {
MessageGroup(isUser = false) {
if (streaming.thinking.isNotBlank() || streaming.thinkingActive) {
ThinkingCard(text = streaming.thinking, active = streaming.thinkingActive)
}
streaming.tools.forEach { ToolCard(it) }
val bubbles = if (streaming.bubbles.isEmpty() && streaming.active) {
listOf(plainBubble(CURSOR))
} else {
streaming.bubbles
}
bubbles.forEachIndexed { index, bubble ->
Bubble(
bubble = bubble,
isUser = false,
isLast = index == bubbles.lastIndex,
showCursor = streaming.active,
)
}
}
}
@Composable
private fun MessageGroup(
isUser: Boolean,
footer: String? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp),
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
content()
// 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)
if (footer != null) {
Text(
text = footer,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
)
}
}
}
@Composable
private fun Bubble(
bubble: ParsedBubble,
isUser: Boolean,
isLast: Boolean,
showCursor: Boolean = false,
) {
AnnotationLine(bubble.head)
val body = if (showCursor && isLast && bubble.body.isNotEmpty()) bubble.body + CURSOR else bubble.body
if (body.isNotEmpty()) BubbleBody(body, isUser)
AnnotationLine(bubble.tail)
}
/** 旁白/动作:灰斜体小字,依附在气泡上/下。 */
@Composable
private fun AnnotationLine(parts: List<ContentPart>) {
if (!LocalAppPrefs.current.showNarration) return
val text = parts.joinToString(" ") { it.text }.trim()
if (text.isEmpty()) return
SelectionContainer(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
fontStyle = FontStyle.Italic,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
@Composable
private fun BubbleBody(text: String, isUser: Boolean) {
Box(
modifier = Modifier
.widthIn(max = 300.dp)
.background(
color = if (isUser) {
MiuixTheme.colorScheme.primary
} else {
MiuixTheme.colorScheme.surfaceContainer
},
shape = RoundedCornerShape(18.dp),
)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
SelectionContainer {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = if (isUser) MiuixTheme.colorScheme.onPrimary else MiuixTheme.colorScheme.onSurfaceContainer,
)
}
}
}
@Composable
private fun ChatImage(model: String) {
AsyncImage(
model = model,
contentDescription = null,
modifier = Modifier
.widthIn(max = 240.dp)
.clip(RoundedCornerShape(14.dp)),
)
}
private fun plainBubble(text: String) = ParsedBubble(
body = text,
parts = emptyList(),
head = emptyList(),
tail = emptyList(),
)
@@ -0,0 +1,39 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.enums.EnumCatalog
/**
* 来源角标(S1.3):每条消息默认显示来源。
* - 主人发的 → 「来自 X」(从哪一端发出)
* - 昔涟发的 → 「发送至 X」(回到哪一端)
* 优先用 `deviceId` 区分具体端,再回落到 `platform`;两者都没有则不显示。
*
* 平台名到标签的映射来自 `/meta/enums` 的权威表([catalog]),**不含任何硬编码表**:
* 表里没有的取值原样显示(红线),未登录 / 尚无快照时全部原样。
*/
fun messageSourceLabel(message: HistoryMessageDto, catalog: EnumCatalog): String? {
val name = sourceName(message, catalog) ?: return null
val verb = if (message.role == "assistant") "发送至" else "来自"
return "$verb $name"
}
private fun sourceName(message: HistoryMessageDto, catalog: EnumCatalog): String? {
val deviceId = message.deviceId
if (!deviceId.isNullOrBlank()) {
// 下面两条是 device 粒度、与平台表无关的端标识,不参与权威表映射。
if (deviceId.startsWith("voiceassist")) return "安卓"
if (deviceId.startsWith("web:")) {
val browser = deviceId.removePrefix("web:").substringBefore(':')
return "Web($browser)"
}
// IM 端 deviceId 形如 <平台名>:<配置id>:<uid>,只取平台名做友好显示;
// 平台名不在表里(服务端新增渠道)则整串原样显示,不丢信息也不猜。
val platform = deviceId.substringBefore(':')
return catalog.platformLabelOrNull(platform) ?: deviceId
}
val platform = message.platform
if (!platform.isNullOrBlank()) return catalog.platformLabel(platform)
return null
}
@@ -0,0 +1,157 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.core.prefs.LocalAppPrefs
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 思维链卡片。流式期间展开(方便观察),流式结束自动折叠;历史思维链默认折叠。
* 正文包在 [SelectionContainer] 里,长按可自由选择、菜单「全选」只作用于本卡片。
*/
@Composable
fun ThinkingCard(
text: String,
active: Boolean = false,
modifier: Modifier = Modifier,
) {
if (text.isBlank() && !active) return
val defaultExpanded = LocalAppPrefs.current.thinkingExpanded
var expanded by remember { mutableStateOf(active && defaultExpanded) }
LaunchedEffect(active) {
if (!active) expanded = false
}
FoldCard(modifier = modifier, onClick = { expanded = !expanded }) {
FoldCardHeader(
title = if (active) "思考中…" else "思维链",
expanded = expanded,
)
if (expanded && text.isNotBlank()) {
Spacer(Modifier.height(6.dp))
FoldCardBody(text)
}
}
}
/** 工具调用卡片:参数与执行输出分开显示,默认折叠。标题/配色由 `tool_execution.*` 的 phase 驱动。 */
@Composable
fun ToolCard(
tool: ToolCallState,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
val name = tool.tool.takeIf { it.isNotBlank() }
val title = when (tool.phase) {
ToolPhase.Running -> name?.let { "执行 $it…" } ?: "执行中…"
ToolPhase.Done -> name?.let { "工具调用 · $it" } ?: "工具调用"
ToolPhase.Error -> name?.let { "工具调用失败 · $it" } ?: "工具调用失败"
null -> if (tool.done) name?.let { "工具调用 · $it" } ?: "工具调用" else "调用工具…"
}
val titleColor = if (tool.phase == ToolPhase.Error) {
MiuixTheme.colorScheme.error
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
}
FoldCard(modifier = modifier, onClick = { expanded = !expanded }) {
FoldCardHeader(
title = title,
expanded = expanded,
titleColor = titleColor,
)
if (expanded) {
if (tool.arguments.isNotBlank()) {
Spacer(Modifier.height(6.dp))
FoldCardLabel("参数")
FoldCardBody(tool.arguments)
}
if (tool.output.isNotBlank()) {
Spacer(Modifier.height(6.dp))
FoldCardLabel("结果")
FoldCardBody(tool.output)
}
}
}
}
@Composable
private fun FoldCard(
modifier: Modifier,
onClick: () -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.background(MiuixTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(14.dp))
.clickable(onClick = onClick)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
content = content,
)
}
@Composable
private fun FoldCardHeader(
title: String,
expanded: Boolean,
titleColor: Color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = title,
style = MiuixTheme.textStyles.footnote1,
color = titleColor,
modifier = Modifier.weight(1f),
)
Text(
text = if (expanded) "收起" else "展开",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.primary,
)
}
}
@Composable
private fun FoldCardLabel(text: String) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.primary,
)
}
@Composable
private fun FoldCardBody(text: String) {
SelectionContainer {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
@@ -0,0 +1,25 @@
package top.yeij.cyrene.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun LoadingScreen() {
Scaffold { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
Text("加载中…", style = MiuixTheme.textStyles.main)
}
}
}
@@ -0,0 +1,226 @@
package top.yeij.cyrene.ui.common
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.ceil
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 完全关闭时抽屉面板的位移比例(面板整体在屏幕左侧之外)。 */
private const val ClosedFraction = 0f
/** 完全打开时抽屉面板的位移比例。 */
private const val OpenFraction = 1f
/** 位移大于该值才认为抽屉「露出」,用于决定是否挂载 scrim / 拖拽手势。 */
private const val VisibleFractionEpsilon = 0.001f
/** 松手时位移超过面板宽度的一半即吸附到打开。 */
private const val PositionalThreshold = 0.5f
/** 打开 / 关闭吸附动画时长。 */
private const val SettleAnimationMillis = 240
/** 触发「速度吸附」的阈值,与 AnchoredDraggableDefaults 的默认值保持一致。 */
private val FlingVelocity = 125.dp
/**
* 自研左滑抽屉(Miuix 组件库没有 Drawer,故自行实现)。
*
* 设计约定:
* - 只能由汉堡按钮等外部入口打开,**不做左边缘滑出**(会和系统返回手势冲突),
* 因此关闭状态下不挂载任何拖拽 / 点击手势;
* - 抽屉露出时,在面板或遮罩上横向拖拽即可关闭;
* - 返回键优先关闭抽屉;
* - 关闭状态下遮罩与面板都不参与命中测试,避免挡住聊天页等底层内容。
*
* 拖拽 API 选用 [Modifier.draggable] 而不是 `anchoredDraggable`:
* `AnchoredDraggableState` 的构造器 / `settle` 重载在 Compose 1.11.2 中已被标记
* `@Deprecated`(阈值与动画需改走 `AnchoredDraggableDefaults.flingBehavior`),
* 版本间签名变动较大;而 [Modifier.draggable] 长期稳定且无实验性注解,
* 配合 [Animatable] 手动实现「位移过半 / 速度超阈值」的吸附逻辑,行为完全可控。
*
* 纵向滚动与横向拖拽的冲突:这里**不会**给 [drawerContent] 外层套 `verticalScroll`,
* 否则调用方在内部放 `LazyColumn` 时会拿到无限高约束而崩溃。调用方若需要滚动,
* 请在 [drawerContent] 内部自行使用 `LazyColumn` 或给自定义 Column 加 `verticalScroll`。
* 横向 `draggable` 与纵向滚动靠触摸 slop 的轴向判定自然区分:谁先超过 slop 谁接管手势,
* 因此纵向滑动列表、横向拖拽关闭可以共存。
*
* @param open 抽屉是否打开(单向数据流,真实位移由内部 [Animatable] 维护)。
* @param onOpenChange 请求切换开合状态;返回键、点击遮罩、拖拽吸附都会回调。
* @param drawerWidth 抽屉面板宽度。
* @param content 主内容,铺满整个容器,位于遮罩与抽屉之下。
* @param drawerContent 抽屉面板内容,带 [ColumnScope],可放任意 Miuix 组件。
*/
@Composable
fun MiuixDrawer(
open: Boolean,
onOpenChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
drawerWidth: Dp = 300.dp,
content: @Composable () -> Unit,
drawerContent: @Composable ColumnScope.() -> Unit,
) {
val density = LocalDensity.current
val drawerWidthPx = with(density) { drawerWidth.toPx() }.coerceAtLeast(1f)
val flingVelocityPx = with(density) { FlingVelocity.toPx() }
// open 只决定动画目标值,实际位移用 Animatable 维护:
// 拖拽中的中间态、吸附动画、外部状态切换三者互不覆盖。
val offsetFraction = remember { Animatable(if (open) OpenFraction else ClosedFraction) }
val currentOnOpenChange by rememberUpdatedState(onOpenChange)
val scope = rememberCoroutineScope()
// derivedStateOf 只在布尔值翻转时触发重组,动画过程中不会逐帧重组。
val drawerVisible by remember {
derivedStateOf { offsetFraction.value > VisibleFractionEpsilon }
}
// 拖拽手势的挂载条件必须包含 open:拖到完全关闭的瞬间 fraction 会先归零,
// 若这时就摘掉手势节点,onDragStopped 不会触发,外部 open 状态会失去同步。
val interactionEnabled = open || drawerVisible
LaunchedEffect(open) {
offsetFraction.animateTo(
targetValue = if (open) OpenFraction else ClosedFraction,
animationSpec = tween(
durationMillis = SettleAnimationMillis,
easing = FastOutSlowInEasing,
),
)
}
BackHandler(enabled = open) { currentOnOpenChange(false) }
// snapTo 是挂起函数,而 DraggableState 的回调不是挂起上下文,
// 因此放到组合作用域里启动;Animatable 的 MutatorMutex 会取消正在进行的开合动画。
val dragState = rememberDraggableState { delta ->
scope.launch {
offsetFraction.snapTo(
(offsetFraction.value + delta / drawerWidthPx)
.coerceIn(ClosedFraction, OpenFraction),
)
}
}
Box(modifier = modifier.fillMaxSize()) {
content()
// 始终组合遮罩与面板,以保留 drawerContent(如 LazyColumn)的内部状态;
// 但只有在抽屉露出(或正在被拖拽)时才挂载手势,关闭状态下整块对触摸完全透明。
Box(
modifier = Modifier
.matchParentSize()
.then(
if (interactionEnabled) {
Modifier.draggable(
state = dragState,
orientation = Orientation.Horizontal,
onDragStopped = { velocity ->
val target = resolveTarget(
fraction = offsetFraction.value,
velocity = velocity,
flingVelocityPx = flingVelocityPx,
)
currentOnOpenChange(target == OpenFraction)
offsetFraction.animateTo(
targetValue = target,
animationSpec = tween(
durationMillis = SettleAnimationMillis,
easing = FastOutSlowInEasing,
),
)
},
)
} else {
Modifier
},
),
) {
// 遮罩:关闭时 alpha 为 0 且不挂 pointerInput,因此绝不吃掉触摸事件。
Box(
modifier = Modifier
.matchParentSize()
// 在绘制阶段读 Animatable,避免逐帧重组。
// windowDimming 是 Miuix 主题自带的半透明黑遮罩色(随浅色/深色主题变化),
// 比硬编码 Color.Black.copy(alpha = 0.32f) 更贴合 HyperOS 视觉;
// 若觉得偏重,可换成后者。
.graphicsLayer { alpha = offsetFraction.value }
.background(MiuixTheme.colorScheme.windowDimming)
.then(
if (drawerVisible) {
Modifier.pointerInput(Unit) {
detectTapGestures { currentOnOpenChange(false) }
}
} else {
Modifier
},
),
)
Column(
modifier = Modifier
.width(drawerWidth)
.fillMaxHeight()
// 关闭时 x = -drawerWidthPx,面板整体移出屏幕左侧:
// 既看不见,也不会落在任何屏幕内坐标的命中范围内。
// 用 ceil 取整,保证即使宽度是小数也至少偏移满一个面板宽度。
.offset {
IntOffset(
x = -ceil(drawerWidthPx * (OpenFraction - offsetFraction.value)).toInt(),
y = 0,
)
}
.background(MiuixTheme.colorScheme.surface)
// 面板铺满全高(背景仍到底),内容再避开状态栏 / 导航栏
.windowInsetsPadding(WindowInsets.systemBars),
) {
drawerContent()
}
}
}
}
/**
* 松手吸附目标:速度超过阈值按方向吸附,否则按位移是否过半判断。
*/
private fun resolveTarget(
fraction: Float,
velocity: Float,
flingVelocityPx: Float,
): Float = when {
velocity > flingVelocityPx -> OpenFraction
velocity < -flingVelocityPx -> ClosedFraction
fraction >= PositionalThreshold -> OpenFraction
else -> ClosedFraction
}
@@ -0,0 +1,38 @@
package top.yeij.cyrene.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* Miuix 的 `BasicComponent` / Preference 系列本身**没有背景与圆角**(只是可点击的 Column),
* 需要外面包一层卡片容器才能得到 MD3 的「小圆角卡片」外观。
*
* 页面底色用 `surface`、卡片用 `surfaceContainer`:浅色下 #F7F7F7 对 #FFFFFF,
* 深色下 #000000 对 #242424,卡片因此能凸显出来。
*/
@Composable
fun PreferenceCard(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = CardHorizontalMargin)
.clip(RoundedCornerShape(16.dp))
.background(MiuixTheme.colorScheme.surfaceContainer),
) {
content()
}
}
/** 卡片与屏幕左右两侧的间距(Miuix 的 Preference 组件也是这个量级)。 */
private val CardHorizontalMargin = 12.dp
@@ -0,0 +1,143 @@
package top.yeij.cyrene.ui.login
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import top.yeij.cyrene.app.AppContainer
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun LoginScreen(container: AppContainer) {
val viewModel: LoginViewModel = viewModel(
factory = viewModelFactory {
initializer { LoginViewModel(container.authManager, container.sessionStore) }
},
)
val state by viewModel.state.collectAsState()
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(72.dp))
Text("Cyrene", style = MiuixTheme.textStyles.title1)
Spacer(Modifier.height(4.dp))
Text(
text = "登录后与昔涟聊天",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
Spacer(Modifier.height(32.dp))
Text(
text = if (state.serverExpanded) "服务器地址(点击收起)" else "服务器地址(点击展开)",
style = MiuixTheme.textStyles.subtitle,
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.toggleServerExpanded() }
.padding(vertical = 8.dp),
)
if (state.serverExpanded) {
TextField(
value = state.baseUrl,
onValueChange = viewModel::onBaseUrlChange,
label = "http://192.168.1.10:8080",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
}
TextField(
value = state.username,
onValueChange = viewModel::onUsernameChange,
label = "用户名",
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
TextField(
value = state.password,
onValueChange = viewModel::onPasswordChange,
label = "密码",
singleLine = true,
visualTransformation = if (state.passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { viewModel.submit() }),
trailingIcon = {
IconButton(onClick = viewModel::togglePasswordVisible) {
Text(
text = if (state.passwordVisible) "隐藏" else "显示",
style = MiuixTheme.textStyles.footnote1,
)
}
},
modifier = Modifier.fillMaxWidth(),
)
if (state.error != null) {
Spacer(Modifier.height(12.dp))
Text(
text = state.error.orEmpty(),
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(28.dp))
Button(
onClick = viewModel::submit,
enabled = !state.loading,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.loading) "登录中…" else "登录")
}
Spacer(Modifier.height(32.dp))
}
}
}
@@ -0,0 +1,73 @@
package top.yeij.cyrene.ui.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import top.yeij.cyrene.core.auth.AuthManager
import top.yeij.cyrene.core.auth.SessionStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class LoginUiState(
val baseUrl: String = "",
val username: String = "",
val password: String = "",
val passwordVisible: Boolean = false,
val serverExpanded: Boolean = false,
val loading: Boolean = false,
val error: String? = null,
)
class LoginViewModel(
private val authManager: AuthManager,
sessionStore: SessionStore,
) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state.asStateFlow()
init {
val stored = sessionStore.load()
_state.update {
it.copy(
baseUrl = stored.baseUrl,
username = stored.username.orEmpty(),
serverExpanded = stored.baseUrl.isBlank(),
)
}
}
fun onBaseUrlChange(value: String) = _state.update { it.copy(baseUrl = value, error = null) }
fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) }
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
fun togglePasswordVisible() = _state.update { it.copy(passwordVisible = !it.passwordVisible) }
fun toggleServerExpanded() = _state.update { it.copy(serverExpanded = !it.serverExpanded) }
fun submit() {
val snapshot = _state.value
if (snapshot.loading) return
val validationError = when {
snapshot.baseUrl.isBlank() -> "请填写服务器地址"
snapshot.username.isBlank() -> "请填写用户名"
snapshot.password.isBlank() -> "请填写密码"
else -> null
}
if (validationError != null) {
_state.update { it.copy(error = validationError, serverExpanded = true) }
return
}
_state.update { it.copy(loading = true, error = null) }
viewModelScope.launch {
val error = authManager.login(snapshot.baseUrl, snapshot.username.trim(), snapshot.password)
_state.update { it.copy(loading = false, error = error?.userMessage) }
}
}
}
@@ -0,0 +1,372 @@
package top.yeij.cyrene.ui.sessions
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.Badge
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Add
import top.yukonga.miuix.kmp.icon.extended.Delete
import top.yukonga.miuix.kmp.icon.extended.Pin
import top.yukonga.miuix.kmp.icon.extended.Unpin
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 侧边栏内容:会话列表 + IoT/设置入口。列表项为 MD3 小圆角卡片。
*
* 行内交互:单击切换会话、**长按重命名**、行尾置顶/取消置顶;删除只对次级会话开放。
* 标题是服务端的自动标题(首条用户消息生成),没有就落到兜底标题。
*/
@Composable
fun ColumnScope.SessionDrawerContent(
state: SessionsUiState,
onSelect: (String) -> Unit,
onCreate: () -> Unit,
onDelete: (SessionItem) -> Unit,
onTogglePin: (SessionItem) -> Unit,
onRename: (String, String) -> Unit,
onOpenIot: () -> Unit,
onOpenAdmin: () -> Unit,
onOpenSettings: () -> Unit,
) {
var pendingDelete by remember { mutableStateOf<SessionItem?>(null) }
var pendingRename by remember { mutableStateOf<SessionItem?>(null) }
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp)) {
Text("Cyrene", style = MiuixTheme.textStyles.title2)
Text(
text = "与昔涟",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("会话", style = MiuixTheme.textStyles.subtitle, modifier = Modifier.weight(1f))
IconButton(onClick = onCreate) {
Icon(imageVector = MiuixIcons.Add, contentDescription = "新建会话")
}
}
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = state.sessions, key = { it.id }) { session ->
SessionRow(
session = session,
active = session.id == state.activeSessionId,
onClick = { onSelect(session.id) },
onTogglePin = { onTogglePin(session) },
onRename = { pendingRename = session },
onDelete = { pendingDelete = session },
)
}
}
if (state.error != null) {
Text(
text = state.error.orEmpty(),
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
)
}
if (state.fromCache && state.syncedAt > 0) {
Text(
text = "网络异常,显示 ${relativeTime(state.syncedAt)} 的结果",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
)
} else if (state.loading) {
Text(
text = "同步中…",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
)
}
Spacer(Modifier.height(12.dp))
PreferenceCard { BasicComponent(title = "管理员监控", onClick = onOpenAdmin) }
Spacer(Modifier.height(8.dp))
PreferenceCard { BasicComponent(title = "IoT(规划中)", onClick = onOpenIot) }
Spacer(Modifier.height(8.dp))
PreferenceCard { BasicComponent(title = "App 设置", onClick = onOpenSettings) }
Spacer(Modifier.height(16.dp))
pendingDelete?.let { target ->
ConfirmDialog(
title = "删除「${target.title}」?",
message = "该会话的全部消息会一并删除,且不可恢复。",
confirmText = "删除",
onConfirm = {
onDelete(target)
pendingDelete = null
},
onDismiss = { pendingDelete = null },
)
}
pendingRename?.let { target ->
RenameDialog(
initial = target.title,
onConfirm = { title ->
onRename(target.id, title)
pendingRename = null
},
onDismiss = { pendingRename = null },
)
}
}
/**
* 会话行。手势只有**一个**检测器:整个行由 `combinedClickable` 负责单击/长按,
* `BasicComponent` 因此不再传 `onClick`(传了它内部会再装一个 clickable,
* 子级会先吃掉按下事件,长按就永远不会触发)。
*
* ## 为什么不用 `BasicComponent` 的 `title` / `summary` 参数
* 那两个参数只收 `String`,塞不下未读角标;而角标**不能**放进 `endActions`——
* 抽屉固定 300dp,卡片 + 内边距之后只剩约 244dp,而 `endActions` 里已经有三个 40dp 的
* 图标按钮(120dp),`BasicComponent` 又把它内部可用宽度的 60% 作为 `endActions` 的硬上限:
* 再塞一个角标会把图标挤到裁剪(Row 不换行),标题列也被压到约 95dp。
* 所以这里改用「内容重载」自己搭标题行:`[标题][角标]` 同行,角标紧挨标题、不占额外列宽。
*
* 代价是标题 / 摘要的字号、字重、颜色要自己写,取值与库内实现(`Component.kt`)逐字一致
* (`headline1.fontSize` + `Medium` + `BasicComponentDefaults.titleColor()`;
* `body2.fontSize` + `summaryColor()`),**唯一的差异是收紧了行数**:
* 标题与摘要都 `maxLines = 1` + 省略号——这正是「可读性要强」要的效果:长标题不再在约 116dp 的
* 列里折成两三行把卡片撑高,而是稳定地占一行、尾部省略,列表因此等高、可扫读。
*/
@Composable
private fun SessionRow(
session: SessionItem,
active: Boolean,
onClick: () -> Unit,
onTogglePin: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
val titleColor = BasicComponentDefaults.titleColor().color
val summaryColor = BasicComponentDefaults.summaryColor().color
val badge = unreadBadgeText(session.unreadCount)
PreferenceCard {
BasicComponent(
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onRename),
endActions = {
if (active) {
Text(
text = "✓",
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
IconButton(onClick = onTogglePin) {
Icon(
imageVector = if (session.pinned) MiuixIcons.Unpin else MiuixIcons.Pin,
contentDescription = if (session.pinned) "取消置顶" else "置顶",
tint = if (session.pinned) {
MiuixTheme.colorScheme.primary
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
)
}
if (!session.isMain) {
IconButton(onClick = onDelete) {
Icon(
imageVector = MiuixIcons.Delete,
contentDescription = "删除会话",
tint = MiuixTheme.colorScheme.error,
)
}
}
},
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = session.title,
// 标题是这一行唯一的权重子项,`fill = false` 让短标题只占自己需要的宽度。
// 真正保证角标不被挤掉的是 Row 的测量顺序:**非权重子项(角标与它前面的间距)
// 先量**,权重只分配剩下的空间,标题再在这块空间里省略——
// 所以 `99+` 永远进得来,标题也不会被压到完全看不见。
modifier = Modifier.weight(1f, fill = false),
fontSize = MiuixTheme.textStyles.headline1.fontSize,
fontWeight = FontWeight.Medium,
color = titleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (badge != null) {
Spacer(Modifier.width(UNREAD_BADGE_GAP))
// Miuix 自带角标:错误色药丸 + `onError` 文字 + 16dp 下限高度,
// 在 `surfaceContainer` 卡片上对比度足够;`99+` 会自然撑成药丸而不会溢出。
Badge(
modifier = Modifier.clearAndSetSemantics {
contentDescription = "未读 $badge"
},
) {
Text(badge)
}
}
}
session.lastMessage?.let { preview ->
Text(
text = preview,
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = summaryColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** 标题与角标之间的间距:太小会粘成一个词,太大又脱离标题。 */
private val UNREAD_BADGE_GAP = 6.dp
/** 重命名对话框:服务端不接受空标题(400)也不接受超过 80 个字符,两者都在这里挡住。 */
@Composable
private fun RenameDialog(
initial: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var name by remember(initial) { mutableStateOf(initial) }
val trimmed = name.trim()
val used = trimmed.runes()
val tooLong = used > TITLE_MAX_RUNES
val canSave = trimmed.isNotEmpty() && !tooLong
Dialog(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp))
.padding(20.dp),
) {
Text("重命名会话", style = MiuixTheme.textStyles.title3)
Spacer(Modifier.height(12.dp))
TextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
label = "会话名称",
singleLine = true,
)
Spacer(Modifier.height(6.dp))
Text(
text = "$used / $TITLE_MAX_RUNES",
style = MiuixTheme.textStyles.footnote2,
color = if (tooLong) {
MiuixTheme.colorScheme.error
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
)
Spacer(Modifier.height(20.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton("取消", onClick = onDismiss)
Spacer(Modifier.width(8.dp))
TextButton(
text = "保存",
onClick = { onConfirm(trimmed) },
enabled = canSave,
)
}
}
}
}
@Composable
private fun ConfirmDialog(
title: String,
message: String,
confirmText: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp))
.padding(20.dp),
) {
Text(title, style = MiuixTheme.textStyles.title3)
Spacer(Modifier.height(8.dp))
Text(
text = message,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
Spacer(Modifier.height(20.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton("取消", onClick = onDismiss)
Spacer(Modifier.width(8.dp))
TextButton(confirmText, onClick = onConfirm)
}
}
}
}
/** 服务端 `PATCH /sessions/{id}` 的上限:超过 80 个字符直接 400。 */
private const val TITLE_MAX_RUNES = 80
/** 按 Unicode 码点计数,与服务端 Go 的「rune」口径一致(代理对算一个)。 */
private fun String.runes(): Int = codePointCount(0, length)
private fun relativeTime(timestamp: Long): String {
val minutes = (System.currentTimeMillis() - timestamp) / 60_000
return when {
minutes < 1 -> "刚刚"
minutes < 60 -> "$minutes 分钟前"
minutes < 24 * 60 -> "${minutes / 60} 小时前"
else -> "${minutes / (24 * 60)} 天前"
}
}
@@ -0,0 +1,438 @@
package top.yeij.cyrene.ui.sessions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.ws.ChatSocket
import top.yeij.cyrene.data.ws.WsEvent
data class SessionItem(
val id: String,
val title: String,
val isMain: Boolean,
val pinned: Boolean,
/** 最后一条消息的一行摘要;会话还没有消息时为 null。 */
val lastMessage: String?,
/**
* 最后一条消息的时间戳(毫秒);会话还没有消息时为 null。
* 只用于已读游标比对(见 [SessionItem.withReadCursor]),不直接显示。
*/
val lastMessageAt: Long? = null,
/** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */
val unreadCount: Int = 0,
)
data class SessionsUiState(
val sessions: List<SessionItem> = emptyList(),
val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
val loading: Boolean = false,
/** 当前展示的是本地快照(服务端没拉成功)。 */
val fromCache: Boolean = false,
val syncedAt: Long = 0L,
val error: String? = null,
)
/**
* 会话列表(§3.1):
* 启动先用本地快照渲染(指纹不匹配就不用),再拉服务端;成功则整体替换并回写快照,
* 失败则保留快照并提示。顺序完全信任服务端(`pinned DESC, main DESC, created_at ASC`),
* 客户端不重排。
*
* ## 未读(D4)
* 未读条数来自服务端(`GET /sessions` 的 `unreadCount`,恒下发),本类只在三处改它:
* 列表整表替换(以服务端为准)、**进入会话**、以及**正在看的会话有新消息落库**——后两处
* 同时调 `POST /sessions/{id}/read` 把游标推上去(策略详见 [pushReadCursor])。
* 反向同步(别的端读了)靠下行 `session_read` 帧。
*
* ## 为什么本类自己开一条 WS 连接
* `session_read` / `message_persisted` 都在 WS 上,而 `AppContainer` 只提供
* `createChatSocket()`(**每次调用新建**,没有进程级共享连接),`ChatSocket` / `ChatViewModel`
* 又都在别的改动范围里。要在这里收帧,唯一不改别人的办法就是自持一条连接。
* 代价如实说:这是本应用**已知遗留问题**(「每 VM 一条 socket、旧 socket 不关」,见
* `devdocs/05`)之上再加一条——服务端对同一账号的多连接是支持的(多端场景本来如此),
* 而且本类只关心两个帧类型,收不到也不会让功能失效(`GET /sessions` 会收敛)。
* 生命周期:登录后连接、登出断开、VM 销毁断开([onCleared])。
*/
class SessionsViewModel(private val container: AppContainer) : ViewModel() {
private val _state = MutableStateFlow(SessionsUiState())
val state: StateFlow<SessionsUiState> = _state.asStateFlow()
private val prefs = container.sessionPreferences
/**
* 本端**已知的已读游标**(会话 id → 毫秒),写入值一律取自**服务端口径**:
* - `POST /sessions/{id}/read` 响应里的 `readAt`(权威);
* - 下行 `session_read` 帧的 `readAt`(可能是别的端推的);
* - 本端对某条落库消息发起的推进,先用该消息的 `createdAt` 占位——它也是服务端时间,
* 所以不存在设备时钟偏差。
*
* 唯一用途是 [withReadCursors]:整表替换(REST 快照)时,把「本地已知没有比游标更新的
* 消息」的那些行的未读压回 0。不做这件事的话,刚在别处读完、或本端刚标记过的会话,
* 会因为一次竞态中的 GET 把角标又闪回来。
*/
private val readCursors = mutableMapOf<String, Long>()
private val socket: ChatSocket = container.createChatSocket()
init {
viewModelScope.launch {
val fingerprint = fingerprint()
val cached = prefs.data.first()
val snapshot = cached.snapshotFor(fingerprint)
_state.update {
it.copy(
sessions = snapshot.map { dto -> dto.toItem() }.withReadCursors(readCursors),
activeSessionId = if (snapshot.isEmpty()) {
SessionSummaryDto.MAIN_SESSION_ID
} else {
cached.activeSessionId
},
fromCache = snapshot.isNotEmpty(),
syncedAt = if (snapshot.isEmpty()) 0L else cached.snapshotAt,
)
}
refresh()
}
// 登出时把快照一起清掉(先例:EnumCatalogViewModel 在登出时清枚举快照)——
// 否则换账号后第一帧会闪一份上个账号的会话列表。已读游标同理:它是**账号级**的
// (服务端单一游标),留着会让新账号的会话被旧账号的游标错误地清成已读。
viewModelScope.launch {
container.authManager.state.collect { auth ->
when (auth) {
SessionState.LoggedOut -> {
socket.close()
readCursors.clear()
prefs.clear()
_state.value = SessionsUiState()
}
// 连接只在登录后建立:Socket 的令牌 / 地址都是每次重连现读的,
// connect() 幂等(已连接时直接返回)。本 VM 存活期跨越登出 → 重登,
// 所以「登出断开、重登接上」必须在这里显式表达。
is SessionState.LoggedIn -> socket.connect()
SessionState.Unknown -> Unit
}
}
}
// 未读的两个下行来源都在这里收敛;见 [onEvent]。
viewModelScope.launch { socket.events.collect(::onEvent) }
}
override fun onCleared() {
socket.close()
super.onCleared()
}
/**
* 拉取服务端列表。
*
* @param silent 静默刷新:**不动**“同步中…”横幅。用于界面上已经有内容、
* 只是去对齐服务端状态的场景(抽屉打开、改名/置顶之后),避免闪一下横幅。
*/
fun refresh(silent: Boolean = false) {
viewModelScope.launch {
if (!silent) _state.update { it.copy(loading = true) }
container.sessionRepository.listSessions()
.onSuccess { list ->
prefs.saveSnapshot(list, fingerprint())
val cached = prefs.data.first()
// 会话可能在**别处**被删掉(Web 面板 / 别的设备 / 直接调 API),而别处删除无法感知——
// 不校验 `activeSessionId` 就会一直指着一个已删会话,且这个陈旧 id 会被持久化下去。
val active = resolveActiveSession(_state.value.activeSessionId, list.map { it.id })
if (active != _state.value.activeSessionId) prefs.setActive(active)
_state.update {
it.copy(
// 未读以服务端为准,但先过一遍本地已知游标:一次竞态中的 GET
// (快照早于本端刚发起的 read)不该把角标闪回来。
sessions = list.map { dto -> dto.toItem() }.withReadCursors(readCursors),
activeSessionId = active,
loading = false,
fromCache = false,
syncedAt = cached.snapshotAt,
error = null,
)
}
}
.onFailure { e ->
_state.update {
it.copy(
loading = false,
fromCache = it.sessions.isNotEmpty(),
error = e.userMessage(),
)
}
}
}
}
/**
* 切换 / 进入会话。
*
* **进入即已读**(D4):用户点开这个会话,聊天页紧接着会加载并显示它的最新一页,
* 所以立刻把游标推到「现在」(`before` 缺席 = 服务端取现在)——这比推一个本地
* `lastMessage.createdAt` 更诚实:本地的列表可能已经陈旧,而聊天页看到的一定是最新的。
* 先就地清零(不等网络往返,角标立刻消失),再用响应里的权威游标收敛。
*
* `createSession` / `deleteSession` 也走这里,语义同样成立:那两个动作之后,
* 屏幕上(即将)显示的就是这个会话。
*/
fun select(sessionId: String) {
_state.update {
it.copy(activeSessionId = sessionId, sessions = it.sessions.markRead(sessionId))
}
viewModelScope.launch { prefs.setActive(sessionId) }
pushReadCursor(sessionId)
}
fun createSession() {
viewModelScope.launch {
container.sessionRepository.createSession()
.onSuccess { sessionId ->
refresh()
select(sessionId)
}
.onFailure { e -> _state.update { it.copy(error = e.userMessage()) } }
}
}
fun deleteSession(sessionId: String) {
if (sessionId == SessionSummaryDto.MAIN_SESSION_ID) return
viewModelScope.launch {
container.sessionRepository.deleteSession(sessionId)
.onSuccess {
prefs.removeSession(sessionId)
if (_state.value.activeSessionId == sessionId) {
select(SessionSummaryDto.MAIN_SESSION_ID)
}
refresh()
}
.onFailure { e -> _state.update { it.copy(error = e.userMessage()) } }
}
}
/**
* 置顶 / 取消置顶。主会话也能置顶(契约允许)。
*
* 先就地翻转 `pinned` 拿到即时反馈,再发 PATCH;成功后**静默重拉**——
* 服务端才是顺序的权威(置顶会把会话排到最前),快照也顺带被修正。
* 失败则回滚到调用前的列表并报错。
*/
fun setPinned(sessionId: String, pinned: Boolean) {
val before = _state.value.sessions
_state.update { state ->
state.copy(
sessions = state.sessions.map { if (it.id == sessionId) it.copy(pinned = pinned) else it },
)
}
viewModelScope.launch {
container.sessionRepository.updateSession(sessionId, pinned = pinned)
.onSuccess { refresh(silent = true) }
.onFailure { e ->
_state.update { it.copy(sessions = before, error = e.userMessage()) }
}
}
}
/**
* 重命名。不做乐观更新:服务端会 trim 后回写,等静默重拉拿到权威标题。
* 调用方必须保证 [title] 非空白且不超过 80 个字符(服务端会 400)。
*/
fun renameSession(sessionId: String, title: String) {
viewModelScope.launch {
container.sessionRepository.updateSession(sessionId, title = title.trim())
.onSuccess { refresh(silent = true) }
.onFailure { e -> _state.update { it.copy(error = e.userMessage()) } }
}
}
fun dismissError() = _state.update { it.copy(error = null) }
/**
* 下行帧收敛(未读相关只有两类)。
*
* **这里刻意不用 [top.yeij.cyrene.data.ws.isForSession] 过滤**:那道闸是给「一个 VM 只关心
* 一个会话」的场景(`ChatViewModel`)用的,而本 VM 持有**整个会话列表**——按 activeSessionId
* 过滤会把「别的会话被读了」这一帧直接丢掉,正是最该处理的一半。所以改为按帧里的会话 id
* **逐行定位**([applyReadCursor],影响面严格限制在那一行)。
*/
private fun onEvent(event: WsEvent) {
when (event) {
// 别处(Web 面板 / 别的设备 / 本端另一次请求)读了:清掉那一行的红点。
is WsEvent.SessionRead -> onSessionRead(event)
// 正在看的会话落了新消息:消息已经显示在屏幕上了,立刻推进游标,
// 否则「自己正看着的会话」会攒出一个未读角标(契约 §3.9 的客户端处理建议)。
is WsEvent.MessagePersisted -> onMessagePersisted(event)
else -> Unit
}
}
private fun onSessionRead(event: WsEvent.SessionRead) {
if (event.sessionId.isBlank()) return
recordReadCursor(event.sessionId, event.readAt)
_state.update { it.copy(sessions = it.sessions.applyReadCursor(event.sessionId, event.readAt)) }
}
/**
* `message_persisted`:只有**当前打开的那个会话**才推进游标。
*
* 别的会话攒未读是正确行为(红点本来就该亮),所以这里不碰它们的计数——
* 本端只做「看到即已读」,不做「替服务端数未读」(增量计数会与编辑 / 删除 / 别端操作漂移,
* 而抽屉每次打开都会静默重拉,以服务端为准)。
*/
private fun onMessagePersisted(event: WsEvent.MessagePersisted) {
val sessionId = event.sessionId?.takeIf { it.isNotBlank() } ?: return
if (sessionId != _state.value.activeSessionId) return
val message = event.message ?: return
// `createdAt` 是服务端时间:用它当 before 比「现在」更精确——只把**这条**及其之前标成
// 已读,同时落库的其它消息(时间更晚)仍算未读。缺时间戳时退化成「现在」,语义见 select()。
val before = message.createdAt.takeIf { it > 0 }
before?.let { recordReadCursor(sessionId, it) }
_state.update { it.copy(sessions = it.sessions.markRead(sessionId)) }
pushReadCursor(sessionId, before)
}
/**
* 推进已读游标(D4)。**失败静默**:这是一次后台的「顺手记一下」,不是用户发起的操作——
* 弹错误横幅会为一件用户没要求、也不影响使用的失败打扰他;而且服务端游标才是唯一真源,
* 漏标一次会在下一次 `GET /sessions` 暴露出来、再被 [select] / [onMessagePersisted] 纠正。
* 请求本身幂等(服务端 `MAX(read_at, before)`),重复发不会把已读退回未读。
*
* @param before null = 现在。**绝不能传 0**:服务端把 `0` / 负数当非法参数回 400。
*/
private fun pushReadCursor(sessionId: String, before: Long? = null) {
viewModelScope.launch {
container.sessionRepository.markRead(sessionId, before)
.onSuccess { response ->
if (response.readAt > 0) recordReadCursor(sessionId, response.readAt)
_state.update {
it.copy(sessions = it.sessions.applyReadCursor(sessionId, response.readAt))
}
}
}
}
/** 记下已知游标:只接受正数(坏帧的 `readAt = 0` 不能污染成「读到纪元」)。 */
private fun recordReadCursor(sessionId: String, readAt: Long) {
if (readAt <= 0) return
readCursors[sessionId] = maxOf(readCursors[sessionId] ?: 0L, readAt)
}
private fun fingerprint(): String =
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
}
internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem(
id = id,
title = displayTitle(),
isMain = isMain,
pinned = pinned,
lastMessage = lastMessage?.content?.toPreviewText()?.takeIf { it.isNotBlank() },
lastMessageAt = lastMessage?.createdAt?.takeIf { it > 0 },
unreadCount = unreadCount,
)
/**
* 未读角标的文本(纯函数)。`null` = **不显示**角标。
*
* `0` 与负数都不显示(服务端只会给非负整数,负数当数据错误处理,不画一个「-1」)。
* 超过 [UNREAD_BADGE_MAX] 收敛为 `99+`:三位数的角标在 300dp 的抽屉里会把标题挤没,
* 而三位数以上的精确值对「有多少条没看」几乎没有信息增量。
*/
internal fun unreadBadgeText(count: Int): String? = when {
count <= 0 -> null
count > UNREAD_BADGE_MAX -> "$UNREAD_BADGE_MAX+"
else -> count.toString()
}
/** 角标显示的未读上限;超过就显示 `99+`。 */
internal const val UNREAD_BADGE_MAX = 99
/** 整表替换(REST 快照)时,把本地已知已读的那些行压回 0 未读;见 `SessionsViewModel.readCursors`。 */
internal fun List<SessionItem>.withReadCursors(cursors: Map<String, Long>): List<SessionItem> =
map { item -> cursors[item.id]?.let { readAt -> item.withReadCursor(readAt) } ?: item }
/** 把某一行的未读清零(`session_read` 帧、或本端刚标记过)。 */
internal fun List<SessionItem>.applyReadCursor(sessionId: String, readAt: Long): List<SessionItem> =
map { if (it.id == sessionId) it.withReadCursor(readAt) else it }
/**
* 用户**正在看**这个会话(或本端刚为它推进过游标)时,无条件清掉它的未读。
* 与 [withReadCursor] 的区别:这里不需要游标依据——调用方已经知道「这一行就是屏幕上的那一行」
* (进入会话、正在看的会话落了新消息)。
*/
internal fun List<SessionItem>.markRead(sessionId: String): List<SessionItem> =
map { if (it.id == sessionId) it.copy(unreadCount = 0) else it }
/**
* 按已读游标清未读(纯函数):**只在「本地已知的最新一条消息不比游标新」时清零**。
*
* 为什么不能一律清零(api-client §3.9 说「直接清零也不会错」):那边的假设是「本地没有更完整的
* 数据」。本端有——`lastMessage.createdAt` 就是本地已知的最新消息时间。若它**晚于**游标,
* 说明确实还有未读(游标只前进,这只能是读完以后新到的消息),此时清零就是**把真实未读藏起来**。
* 保留旧计数更接近真相:精确条数本地算不出来(没有逐条时间线),等下次 `GET /sessions` 收敛。
*
* 会话没有消息([SessionItem.lastMessageAt] 为 null)时一定没有未读,清零。
*/
internal fun SessionItem.withReadCursor(readAt: Long): SessionItem {
val newest = lastMessageAt
return if (newest != null && newest > readAt) this else copy(unreadCount = 0)
}
/**
* 刷新成功后校验「当前会话」是否还在列表里;不在就回落到主会话。
*
* 什么时候会不在:会话在**别处**被删掉(Web 面板 / 别的设备 / 直接调 API)。
* 本端自己的删除已经处理过(`deleteSession` 会主动切回主会话),但**别处删除无法感知**——
* 不校验的话 `activeSessionId` 会一直指着一个已删会话:聊天页对着空会话,
* 而且这个陈旧 id 会被 `prefs.setActive` 持久化下去,重启后依然是它。
*
* 列表为空时**不回落**:那是异常响应,不该拿它去改用户的选择。
*/
internal fun resolveActiveSession(activeSessionId: String, fetchedIds: List<String>): String =
if (fetchedIds.isEmpty() || activeSessionId in fetchedIds) {
activeSessionId
} else {
SessionSummaryDto.MAIN_SESSION_ID
}
/**
* 列表行标题:优先用服务端的自动标题(首条用户消息生成),
* 会话还没有用户消息时 `title` 键整个缺席(或为空白),此时才兜底。
*
* 主会话兜底用「与昔涟」而不是服务端的「主会话」——这是个人应用,标题更贴切。
*/
fun SessionSummaryDto.displayTitle(): String = title?.takeIf { it.isNotBlank() }
?: if (isMain) MAIN_SESSION_TITLE else NEW_SESSION_TITLE
internal const val MAIN_SESSION_TITLE = "与昔涟"
internal const val NEW_SESSION_TITLE = "新会话"
/**
* 预览文本:服务端的 `content` 只折叠空白 + 截断到 60 字,`<p>` / `<action>` / `<narration>`
* 标记**原样保留**(截断还可能把标签截在半路),直接显示会露出标签,所以这里剥掉标签、折成一行。
*
* 注意标签替成**空格**而不是空串:旁白紧贴正文时(`…凑近了一点<action>悄悄戳了戳你</action>今天下午…`),
* 替成空串会把两侧的字粘成一个词。
*/
internal fun String.toPreviewText(): String = replace(PREVIEW_TAG_RE, " ")
.replace(WHITESPACE_RE, " ")
.trim()
/** 只剥本应用认识的三种标记;`\b` 保证 `<pre>` 这类同前缀标签不会被误伤。 */
private val PREVIEW_TAG_RE = Regex("</?\\s*(?:p|action|narration)\\b[^>]*>")
private val WHITESPACE_RE = Regex("\\s+")
private fun Throwable.userMessage(): String =
(this as? CyreneException)?.error?.userMessage ?: "操作失败,请稍后重试"
@@ -0,0 +1,195 @@
package top.yeij.cyrene.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import top.yeij.cyrene.BuildConfig
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.prefs.AppPrefs
import top.yeij.cyrene.core.prefs.InterruptMode
import top.yeij.cyrene.core.prefs.ThemeMode
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Back
import top.yukonga.miuix.kmp.icon.extended.ChevronBackward
import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun SettingsScreen(
container: AppContainer,
onBack: () -> Unit,
onLogout: () -> Unit = {},
) {
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
val scope = rememberCoroutineScope()
val settings = container.appSettings
Scaffold(
topBar = {
SmallTopAppBar(
title = "App 设置",
navigationIcon = {
IconButton(onClick = onBack) {
Icon(imageVector = MiuixIcons.ChevronBackward, contentDescription = "返回")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
SectionTitle("外观")
ThemeMode.entries.forEach { mode ->
PreferenceCard {
BasicComponent(
title = mode.label(),
onClick = { scope.launch { settings.setThemeMode(mode) } },
endActions = {
if (prefs.themeMode == mode) {
Text(
text = "✓",
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.primary,
)
}
},
)
}
Spacer(Modifier.height(8.dp))
}
SectionTitle("聊天")
PreferenceCard {
SwitchPreference(
title = "显示旁白",
summary = "以灰斜体小字显示 <action> / <narration>",
checked = prefs.showNarration,
onCheckedChange = { scope.launch { settings.setShowNarration(it) } },
)
}
Spacer(Modifier.height(8.dp))
PreferenceCard {
SwitchPreference(
title = "思维链默认展开",
summary = "流式期间展开思维链,结束后自动折叠",
checked = prefs.thinkingExpanded,
onCheckedChange = { scope.launch { settings.setThinkingExpanded(it) } },
)
}
SectionTitle("她忙时发消息")
InterruptMode.entries.forEach { mode ->
PreferenceCard {
BasicComponent(
title = mode.label(),
summary = mode.summary(),
onClick = { scope.launch { settings.setInterruptMode(mode) } },
endActions = {
if (prefs.interruptMode == mode) {
Text(
text = "✓",
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.primary,
)
}
},
)
}
Spacer(Modifier.height(8.dp))
}
SectionTitle("关于")
PreferenceCard {
BasicComponent(
title = "版本",
summary = null,
endActions = {
Text(
text = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
},
)
}
Spacer(Modifier.height(32.dp))
PreferenceCard {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onLogout) {
Icon(
imageVector = MiuixIcons.Back,
contentDescription = "登出",
tint = MiuixTheme.colorScheme.error,
)
}
}
}
Spacer(Modifier.height(32.dp))
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(
text = text,
style = MiuixTheme.textStyles.subtitle,
color = MiuixTheme.colorScheme.primary,
modifier = Modifier.padding(start = 20.dp, top = 20.dp, bottom = 8.dp),
)
}
private fun ThemeMode.label(): String = when (this) {
ThemeMode.System -> "跟随系统"
ThemeMode.Light -> "浅色"
ThemeMode.Dark -> "深色"
}
private fun InterruptMode.label(): String = when (this) {
InterruptMode.AppendQueue -> "追加队列"
InterruptMode.Interrupt -> "打断"
}
/**
* 两档的差别只有一处:**忙时那条排队消息还在不在**。文案必须说清这一点——
* 一个用户看不懂效果的偏好,比没有这个偏好更糟。
*/
private fun InterruptMode.summary(): String = when (this) {
InterruptMode.AppendQueue ->
"她正忙时只把消息排进队列,等她做完当前回复再依次回;不打断(默认)"
InterruptMode.Interrupt ->
"她正忙时先打断当前这一句;已排队的消息保留,继续依次回答"
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Cyrene" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowBackground">@android:color/background_dark</item>
</style>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#F8F8F8</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Cyrene</string>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Cyrene" parent="@android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@android:color/background_light</item>
</style>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
自部署场景:用户会把 App 指向自己局域网/本机的 HTTP 服务(如 http://192.168.x.x:8080、
http://127.0.0.1:10942),所以必须放行明文流量;同时信任用户安装的 CA,
便于自签证书的 HTTPS 部署。生产公网部署仍建议用 HTTPS。
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more