feat(session,web): 会话角色不可变 — PATCH 拒 personaId + 移除前端换角色入口
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

design/22 §8-1(主人 2026-10-03 拍板):会话角色在创建时确定、之后不可变,
要换角色必须新开会话。

后端:
- PATCH /api/v1/sessions/{id} 的 personaId 改为 json.RawMessage 判「键出现」,
  出现即 400(null/""/未知 id 一律拒),且先于会话存在性检查;错误消息点名 personaId
- 删除 Server.setSessionPersona 与 PATCH 的 501 分支;title/pinned 行为逐字不变
- sessionPersonaWriter 接口与 history.Store.SetSessionPersona 保留(存储能力)

前端:
- 移除会话行「切换角色」入口与面板「挂角色」下拉(含 setSessionPersona action、
  lib/sessionPersona.ts);保留「新建会话选角色」;面板「角色」列改为只读展示

契约:Docs/api-client.md -> v0.46(破坏性:依赖 PATCH 换角色的客户端须改为新建会话)

验证:go build/vet ./... 绿;gateway+history 包测试绿(-count=1);前端 npm run build
(tsc --noEmit + vite build)绿;9 个前端逻辑测试全绿。
变异验证:改回「静默忽略」/「写绑定」两种反向实现,新断言均 FAIL。
This commit is contained in:
AskaEth committed 2026-10-03 21:24:51 +08:00
1 parent f9430dee81
commit c7ecc3a249
10 files changed
+193 -448

No files matched your search

+9 -8
View File
@@ -27,12 +27,12 @@
| 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` |
| 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,deviceId?,mode?,stream?}` → `{data:{reply}}`;`stream:true` 时经 WS 触发流式。`image` 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06)——**真视觉需配 `LLM_VISION_MODEL`/凭据,无凭据时降级为主模型直接看图或视觉·OCR 工具文本描述**;`deviceId` 为**客户端生成的具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3`,可选),落库到消息、便于主人知道从哪个端发来;`mode` 为**行为模式**(design/01 §3 / design/16 §5.1,如 `chat`/`code`/`home`/`docs`/…,可选),随该轮次落库——IM 恒 `chat`、缺省回落 `chat`,供前端按模式渲染气泡(仅 `chat` 才解析 `<p>` 拆气泡)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}` |
| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`(**排序(D5):`pinned=true` 优先,其次主会话 `id="web"` 恒在前,再按 `createdAt` 升序**;次级 `web:<uuid>`;`pinned` 恒返回 bool;**`unreadCount`(D4)恒返回整数**——该会话中 `createdAt >` 已读游标的消息条数(0 = 无未读),与 `lastMessage` **同一条 SQL 派生**、无 N+1;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略;**`personaId`(v0.42,可选)**=该会话绑定的角色 id(design/22 §2/§4.5),**空/缺省 = 继承全局默认角色**(`owner.yaml:defaultPersona`),客户端据此渲染激活会话的角色名/头像);未注入时退化回渠道会话列表 |
| 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId,personaId?}}`(`sessionId="web:<uuid>"`)。body **可选**(可完全省略或空 body):`{personaId?}`(v0.42)在创建时就把新会话绑到该角色,等价于随后 PATCH 一次(design/22 §2:渠道级只作"新会话的初始角色提供者");`personaId` 为**未知角色**回 `400 bad_request`;省略/空 = 不绑(`persona_id=''`,继承全局默认);**`personaId` 非空时响应回显它**(省略时响应形状与 v0.41 完全一致);未注入 HistoryStore 回 `501 not_implemented`;注入了**不支持角色绑定**的 HistoryStore 且显式传 `personaId` 时也回 `501`(不假装写入) |
| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`personaId`(v0.42,可选)**=**该消息说话时的角色 id**(design/22 §7-5:会话可在保留上下文的前提下换角色,故每条消息都记"谁说的";assistant 消息 = 生成它的角色,user 消息 = 当回合角色;**空 = 全局默认角色**,即 v0.42 之前的全部历史消息),客户端据此按角色渲染名字/头像;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501` |
| 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId,personaId?}}`(`sessionId="web:<uuid>"`)。body **可选**(可完全省略或空 body):`{personaId?}`(v0.42)在创建时就把新会话绑到该角色(design/22 §2:渠道级只作"新会话的初始角色提供者";**会话角色只在创建时写入、之后不可变**,见 §1 行 8 / design/22 §8-1);`personaId` 为**未知角色**回 `400 bad_request`;省略/空 = 不绑(`persona_id=''`,继承全局默认);**`personaId` 非空时响应回显它**(省略时响应形状与 v0.41 完全一致);未注入 HistoryStore 回 `501 not_implemented`;注入了**不支持角色绑定**的 HistoryStore 且显式传 `personaId` 时也回 `501`(不假装写入) |
| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`personaId`(v0.42,可选)**=**该消息说话时的角色 id**(design/22 §7-5:逐条记录"谁说的"——会话角色在创建时固定,§8-1;assistant 消息 = 生成它的角色,user 消息 = 当回合角色;**空 = 全局默认角色**,即 v0.42 之前的全部历史消息),客户端据此按角色渲染名字/头像;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501` |
| 6a | `/api/v1/messages/{id}` | PATCH | JWT(owner/admin) | ✅ | **编辑单条消息**(D3):body `{content}`(非空、≤4000 rune,空/纯空白/超长回 `400`)→ **`{data:<消息DTO>}`**(`data` **直接就是**更新后的完整消息 DTO,含 `id/sessionId/role/content/.../createdAt`,**无 `message` 包一层**;**只改 `content`**,role/时间戳/平台保持不变);消息不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_edited`**(§3.8,账号其它端免刷新同步)。**重发不单独设端点**——客户端用「编辑后的内容再 `POST /chat`」即可(避免重复轮次语义) |
| 6b | `/api/v1/messages/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除单条消息**(D3)→ `{data:{deleted:<id>}}`;不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_deleted`**(§3.8,账号其它端按 `id` 移除) |
| 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:<id>}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` |
| 8 | `/api/v1/sessions/{id}` | PATCH | JWT(owner/admin) | ✅ | **会话元数据更新**(A4 改名 + D5 置顶 + v0.42 选角):body `{title?,pinned?,personaId?}`(**至少一个字段**,都缺回 `400`)。`title` trim、≤80 rune,空/超长回 `400`;`pinned` 为 bool(置顶/取消,主会话与次级会话均可);**`personaId`**(v0.42)重新绑定该会话的角色——**显式 `""` = 解绑**(回到"继承全局默认"),未知角色 id 回 `400`,主会话与次级会话均可改(design/22 §4.5);**改角色不动已存会话内容**:历史消息仍带各自"说话时的角色"(`chat_messages.persona_id`),只有**下一回合**起用新角色的提示词/记忆域/情绪/思考预算。响应只回显**本次变更**的字段:`{data:{id,title?}}` / `{data:{id,pinned?}}` / `{data:{id,personaId?}}` / 多项同改则都有(**title-only 保持 A4 原 `{id,title}` 形状**);不存在回 `404`;主会话(`id="web"`)可改名/置顶/选角但不可删;未注入 HistoryStore 回 `501`(不支持角色绑定的 HistoryStore 在 `personaId` 路径也回 `501`)。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 |
| 8 | `/api/v1/sessions/{id}` | PATCH | JWT(owner/admin) | ✅ | **会话元数据更新**(A4 改名 + D5 置顶):body `{title?,pinned?}`(**至少一个字段**,都缺回 `400`)。`title` trim、≤80 rune,空/超长回 `400`;`pinned` 为 bool(置顶/取消,主会话与次级会话均可);**`personaId` 出现即 `400`**(v0.46,design/22 §8-1:**会话角色在创建时确定、之后不可变,要换角色请新开会话**)——**不是静默忽略、也不是 200 空操作**:**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request`,错误消息点名 `personaId`,且该检查**先于**会话存在性检查(**未知会话 + `personaId` 也回 `400`**,不是 `404`;连 `title` 同传也整体 `400`、不改任何字段)。响应只回显**本次变更**的字段:`{data:{id,title?}}` / `{data:{id,pinned?}}` / 两项同改则都有(**title-only 保持 A4 原 `{id,title}` 形状**);不存在回 `404`;主会话(`id="web"`)可改名/置顶但不可删;未注入 HistoryStore 回 `501`。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 |
| 8a | `/api/v1/sessions/{id}/read` | POST | JWT(owner/admin) | ✅ | **推进已读游标**(D4 / Android 协同 B3):body `{before?}`——`before` 为**可选**的毫秒 Unix 时间戳,**缺省(或空 body)= 现在**,即「我看到的都算已读」。→ `200 {data:{sessionId,readAt}}`(`readAt` 为推进后的游标毫秒)。**判定**:`createdAt <= readAt` 的消息算已读(列表 `unreadCount` 只数 `createdAt > readAt` 的);**语义**:**会话级单一游标、单 owner 账号**——读一处 = **该账号所有端已读**(不做 per-device 游标,与「同一账号所有浏览器都属于同一 owner」一致);**只前进不后退**(SQL `read_at = MAX(read_at, before)`),所以乱序/并发/重试请求幂等安全、绝不会把已读变回未读。`before` 为 `0`/负数/非数字、或 body 不是 JSON 对象 → `400`;未知会话(含隔离 `sess-xxx`,不在 `chat_sessions`)→ `404`;未注入 HistoryStore → `501 not_implemented`。**成功后广播下行 `session_read`**(§3.9)让同账号其它端清红点;**失败路径不广播**。**升级回填**:迁移 `0010` 把既有会话游标回填为「该会话现有最新消息时间」,升级当天不误报未读 |
| 9 | `/api/v1/personas` | GET | JWT | ✅ | **角色目录**(任意已认证角色可读)→ `{data:[PersonaSummary...]}`,每项 `{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}`。**`mood`(v0.43)** = 该角色**当前情绪状态**(角色级,取值见 `GET /meta/enums` 的 `moods`;`""` = 该角色暂无被跟踪的情绪,**不伪造默认值**)。**语义:情绪跟随角色,与会话无关**——同一角色的所有会话看到同一 `mood`;客户端登录/加载角色目录时用它**播种心情徽标**,避免刷新后短暂空白(随后以 WS `mood` 帧为准)。**不含 admin 专属的 `thinking` 旋钮**(用户侧不暴露模型调用参数)。 |
| 10 | `/api/v1/audio/transcribe` | POST | JWT(owner) | ✅ | **语音→文本**:body `{audio:"<base64>",format?,deviceId?}` → `{data:{text, provider?}}`(走后端 STT,百炼 `qwen-audio-3.0-asr-flash`;**当结果为空文本/silent 降级时响应体带 `provider`**(如 `"silent"`,非空文本省略),客户端据此提示「没识别到声音」) |
@@ -110,7 +110,7 @@
- Web 端镜像实现在 `frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 对未知值返回原值),权威仍以本端点为准。
- **反漂移保证(后端)**:后端有测试断言「每个内置适配器的 `Name()` 都在 `platforms` 表内」「`moods` 与 persona 情绪状态机的内置状态一致」「`modes` 与前端 `MODES` 九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。
> **契约版本 `v0.45`**(**`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态**:**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`——修 v0.44 会**静默清空**渠道角色绑定的一处前后端契约不一致;纯语义收紧,**字段与端点形状不变**)。上一版 `v0.44`:渠道级选角(`PUT /admin/channels/{platform}/config` body 新增可选 `personaId`、`GET /admin/channels` 每账号新增可选 `personaId`)+ 角色解析优先级(会话绑定 > 渠道绑定 > 全局默认,群聊/陌生人强制默认角色)。
> **契约版本 `v0.46`**(**`PATCH /api/v1/sessions/{id}` 的 `personaId` 由「可重新绑定该会话的角色」改为「出现即 `400`」**:**会话角色在创建时确定、之后不可变**,要换角色请新开会话(design/22 §8-1)——**明确拒绝**而非静默忽略/200 空操作;**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request` 且消息点名 `personaId`,该检查**先于**会话存在性检查(未知会话 + `personaId` 也回 `400` 而不是 `404`)。**破坏性**:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 `""` 解绑」的客户端必须改为**新建会话**选角色;`title`/`pinned` 行为与 `POST /sessions {personaId}`(创建时绑定)**完全不变**,不再有 `501/200 {personaId}` 的 PATCH 路径)。上一版 `v0.45`:`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态(**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`)。
## 2. REST 端点(管理,`/api/v1/admin/*`)
@@ -332,15 +332,15 @@
### 4.2 会话与消息用法(聊天)
- **列出会话**:`GET /api/v1/sessions` → `[{id,type:"main"|"sub",title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]`。**排序:置顶会话(`pinned=true`)最前,其次主会话 `id="web"`**(她与你的主线,最大化继承上下文),再按创建时间升序;次级 `web:<uuid>` 由 `POST /api/v1/sessions` 新建(临时小事/专业问题,独立上下文回溯)。**`personaId`(v0.42)=该会话绑定的角色**;空/缺省 = **继承全局默认角色**,客户端渲染激活会话的名字/头像用它(后端唯一真源是 `chat_sessions.persona_id`,localStorage 之类只作缓存)。
- **选角 / 换角色(v0.42)**:`POST /api/v1/sessions` body `{personaId?}` 在**新建时**就绑角色;已存在的会话用 `PATCH /api/v1/sessions/{id}` body `{personaId}` 重新绑定(显式 `""` 解绑回"跟随默认")。**换角色保留上下文**:历史消息逐条带自己的 `personaId`,只有**下一回合**起提示词/记忆域/情绪/思考预算换成新角色(design/22 §7-5)。**记忆按角色隔离**:不同角色的会话各记各的,同一角色的多个会话互相可见(design/22 §3 口径 1);群聊/陌生人会话仍**只读**、且强制默认角色。
- **新建时选角(v0.42,v0.46 收口)**:`POST /api/v1/sessions` body `{personaId?}` 在**新建时**就绑角色(省略/空 = 跟随全局默认角色)。**会话角色在创建时确定、之后不可变**(design/22 §8-1):**没有"会话内换角色"这回事**——`PATCH /api/v1/sessions/{id}` 一旦收到 `personaId` 键就回 `400`(见 §1 行 8),**要换角色请新开会话**。**记忆按角色隔离**:不同角色的会话各记各的,同一角色的多个会话互相可见(design/22 §3 口径 1);群聊/陌生人会话仍**只读**、且强制默认角色。
- **角色解析优先级(v0.44,design/22 §2 裁决规则 + §7-3/§7-4)**:一个回合到底以哪个角色答话,**自上而下**取第一个命中的:
1. **请求级覆盖** —— 仍**未实现**(design/22 §2 裁决规则 1,留待后续批次);
2. **会话绑定** `chat_sessions.persona_id`(`POST/PATCH /sessions` 写入)—— 非空即赢;
2. **会话绑定** `chat_sessions.persona_id`(`POST /sessions {personaId}` 在**创建**时写入;之后不可变,design/22 §8-1)—— 非空即赢;
3. **渠道绑定** —— 该 IM 账号的 `personaId`(`PUT /admin/channels/{platform}/config`,v0.44)。**仅**作用于该账号的**主人私聊**,且仅当该会话(=镜像目标主会话 `web`)**自身未绑角色**时;
4. **全局默认角色** `owner.yaml:defaultPersona` —— 兜底(`''` 始终 = 当前默认角色)。
**群聊 / 陌生人隔离会话强制默认角色**(§7-4,更保守):这两类会话**跳过** 2/3 两条渠道与主会话绑定,一律用默认角色,避免把第三方会话挪进某个角色的记忆域;记忆仍是**只读**。**Web 主会话与 IM 主人私聊共享同一角色**(§7-3):主会话绑 Y 时,同一主会话里 IM 镜像进来的主人私聊回合也以 Y 答话。
- **模型上下文里的「谁说的」(v0.44,design/22 §7-5)**:一回合的上下文由该会话**最近 20 条**持久化消息(`chat_messages`,含 `persona_id`)+ 本回合新消息组成(此前只带新消息本身,模型看不到会话前文);**每条 assistant 历史消息按其说话角色标注**,模型收到形如 `[发言归属] 以下发言来自「昔涟」(xilian),不是你说的。` 的独立 system 标注,以及一条声明当前角色的 `[会话角色] …` 段。**正文永不被改写**:标注只走 system 消息,用户正文与 assistant 正文一字不改(design/11 §10:不给用户正文冒充系统指令的机会)。**对客户端透明**(无新字段、无新端点)。
- **改名 / 置顶 / 选角**:`PATCH /api/v1/sessions/{id}` body `{title?}`(≤80 rune)或 `{pinned?:bool}` 或 `{personaId?:string}`(至少一个字段)→ `{data:{id,title?,pinned?,personaId?}}`(只回显变更字段)。
- **改名 / 置顶**:`PATCH /api/v1/sessions/{id}` body `{title?}`(≤80 rune)和/或 `{pinned?:bool}`(至少一个字段)→ `{data:{id,title?,pinned?}}`(只回显变更字段)。**body 里出现 `personaId` 键一律 `400`**(会话角色不可变,v0.46 / design/22 §8-1)。
- **未读 / 已读游标(D4)**:列表项的 **`unreadCount`** 直接用于会话列表红点(恒返回整数,0 = 无未读)。客户端进入会话、或滚到底看到最新消息后调用 **`POST /api/v1/sessions/{id}/read`**:body `{"before":<毫秒>}`(如「本次已渲染的最新一条的 `createdAt`」),或**省略 body** = 标记到**现在**。后端**只前进**地推进该会话**唯一**游标(单 owner 账号:读一处 = 所有端已读),并广播 `session_read`(§3.9)——同账号其它端**免刷新**清红点。列表 → 读 → 再看列表即可自洽(漏收广播的端下次拉列表自然收敛)。
- **拉取激活会话历史**:`GET /api/v1/sessions/{id}/messages?limit=50&before=<ms>&platform=&q=`。
- **热分页**:`limit` 默认 50(上限 200),`before` = 当前最旧一条的 `createdAt`(毫秒),向上翻页。`platform` 可选(IM 适配器传本平台只拉本渠道;Web/通用客户端不传 = 全量共享主会话)。
@@ -367,10 +367,11 @@
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
## 5. 变更记录
- `v0.46`(**会话角色不可变:`PATCH /sessions/{id}` 拒绝 `personaId`**,对客户端**是破坏性变更**——按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色」的客户端必须改为**新建会话**选角;design/22 §8-1 主人拍板定稿):① **`PATCH /api/v1/sessions/{id}` 的 `personaId` 改为「**出现即 `400`**」**:只要请求体里**有 `personaId` 键**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)就回 `400 bad_request`,**错误消息点名 `personaId`** 并说明「会话角色在创建时确定、之后不可变,要换角色请新开会话」。**实现取「明确拒绝」而非静默忽略或 200 空操作**:静默忽略会让客户端以为绑定已改。② **顺序保证**:该检查在解析完 body 后**立即**执行、**先于**会话存在性检查 —— **未知会话 + `personaId` 回 `400`(不是 `404`)**;`title` + `personaId` 同传也整体 `400`,**其余字段一个都不落库**。③ **不变的部分**:`title` / `pinned` 的语义、边界(≤80 rune 等)与响应形状**逐字不变**;`POST /api/v1/sessions {personaId}`(**创建时**绑定、未知角色 `400`、非空回显)**完全不变**;`GET /sessions` 与消息 DTO 的只读 `personaId` 不变。④ **移除的路径**:PATCH 上再也没有 `{personaId}` 成功响应、也没有「不支持角色绑定的 HistoryStore → `501`」这条路(网关的 `Server.setSessionPersona` 及其中间层随之删除;`history.Store.SetSessionPersona` 作为存储能力保留)。⑤ **前端同步**:用户端**移除**「会话行 → 切换角色」菜单与面板「挂角色」下拉,**保留**「新建会话时选角色」;管理面板「角色」列改为**只读**展示创建时绑定的角色。⑥ **测试与变异验证**:新增断言覆盖「键出现即 400(六种取值)+ 绑定未变 + 未知会话仍 400 + `title`/`pinned` 不变」,并把「静默忽略」与「改回写绑定」两种反向实现各跑一次(两处断言均必须 FAIL,见提交说明)。
- `v0.45`(**渠道 `personaId` 三态修订:修「省略 = 解绑」会静默清空绑定**,对客户端**无字段/端点形状变更**,但**语义收紧**——按 v0.44 旧文实现的客户端若靠「省略 `personaId`」来解绑,必须改为**显式传 `""`**;design/22 §2 方案 b):① **`PUT /api/v1/admin/channels/{platform}/config` 的 `personaId` 改为三态**:**键省略(或 JSON `null`)= 保留该账号已存绑定**(不动);**显式 `""` = 解绑**(写盘时该账号不带 `personaId` 键);**非空 id = 绑定该角色**;**未知角色 id 仍 `400 bad_request`**(且**只校验本次真的传了值**的情形——省略时继承的已存绑定在当初写入时已校验)。② **修的问题(数据安全)**:管理面板只在主人**动过**角色下拉时才发这个键(前端 `channelPersona.ts` / `channelPersonaRequest`),而 v0.44 后端把「键不存在」也当成解绑 ——**主人打开一个已绑角色的账号、不动下拉、只改 token 并保存 → 该账号的角色绑定被静默清空**(无提示、无审计可见的理由),两端语义相反。现在后端的「省略」与前端一致 = 保留。③ **实现**:请求 DTO 的该字段由 `string` 变 `*string`(指针区分「省略」与「显式空串」),应用逻辑在既有「读当前配置(channels.json)→ `UpsertChannel` → 落盘 → `bridge.Reload(platform)`」路径内按三态取值(省略时从**同平台同 id** 的已存账号继承,故保存一个账号不会动到同平台其它账号的绑定);**无新端点、无新字段、无响应形状变更**。④ **`GET /api/v1/admin/channels` 每账号的 `personaId` 回显不变**(v0.44 新增,非敏感、不掩码),面板据此回填角色下拉。⑤ **测试**:省略 → 绑定保留、显式 `""` → 清除、非空 id → 绑定、未知 id → `400`(含「省略时改其它字段后读回确认」与「同平台多账号互不影响」),并把「省略 = 解绑」作为变异反向验证(该断言必须失败)。
- `v0.44`(**渠道级选角 + 「谁说的」进模型上下文**,对客户端**新增两个可选字段**,向后兼容;design/22 批次 2 后端 = §2 裁决规则 + §4.4 + §7-3/§7-4/§7-5):① **`PUT /api/v1/admin/channels/{platform}/config` body 新增可选 `personaId`**(渠道级绑定,design/22 §2 方案 b):=该账号答话时的角色 id;**未知角色 `400 bad_request`**,~~**省略/`""` = 解绑**(写盘时该账号不带 `personaId` 键)~~(**本条的「省略」一项已由 v0.45 修订为「保留已存绑定」;只有显式 `""` 才解绑**,见上);沿用既有「读当前配置 → `UpsertChannel` → 落盘 `channels.json`(0600) → `bridge.Reload(platform)` 热更新」路径,保存即生效(下一批入站消息),**无新端点**。② **`GET /api/v1/admin/channels` 每账号新增可选 `personaId`**(非敏感,不掩码),供面板回填。③ **角色解析优先级明确化(对客户端语义澄清,无形状变更)**:**会话绑定 > 渠道绑定 > 全局默认角色**;渠道绑定**只**作用于该账号的**主人私聊**且仅当主会话自身未绑角色(§7-3:IM 主人私聊镜像随主会话角色);**群聊/陌生人隔离会话强制默认角色**(§7-4,更保守,仍只读)。④ **模型上下文新增角色归属标注(§7-5 的剩下一半)**:一回合上下文改为该会话**最近 20 条**持久化消息 + 本回合新消息(此前只带新消息本身),每条 assistant 历史消息按其说话角色出具 `[发言归属] 以下发言来自「<名字>」(<id>),不是你说的。` 独立 system 标注 + 一条 `[会话角色] …` 当前角色声明;**消息正文一字不改**(标注只走 system 消息,design/11 §10),**对客户端透明**(无新字段)。前端无需改动即可继续工作;面板可选用 ① 让每个 IM 账号挂角色。⑤ **向后兼容**:纯新增可选字段;未配置 `personaId` 的账号与不读新字段的客户端行为不变(渠道绑定缺省 = 全局默认角色,与 v0.43 完全一致)。
- `v0.43`(**人格读侧合并批:admin 回读 `thinking` + 用户侧角色级 `mood`**,对客户端**新增两个可选字段**,向后兼容;design/01 §2/§2.1、design/04 §3.2):① **`GET /api/v1/admin/personas` 每项新增可选 `thinking`**(`{budgetTokens,effort,showToUser,mode}`),与写侧同构、供面板回填编辑。**未配置的人格不带 `thinking` 键**(不是全零对象),与 `PUT` 的「省略 `thinking` = 保留已存」互为镜像。② **`showToUser` 三态原样回读**:**`null` = 未配置 → 保持现行为(thinking 帧照常推客户端)**,`true`/`false` = 显式开关;**服务端不把 `null` 拍平成 `false`**(否则面板会把「没配过」误显示为「关」,v0.41 的语义会被读侧丢掉)。③ **`GET /api/v1/personas` 每项新增 `mood`(角色级)**:= 该角色**当前情绪状态**(取值同 `GET /meta/enums` 的 `moods`;`""` = 暂无被跟踪情绪,不伪造)。**情绪跟随角色、与会话无关**——同一角色的所有会话看到同一 `mood`;客户端登录/加载角色目录时用它**播种心情徽标**(按角色 id 分桶),消除「刷新后徽标短暂为空」,随后仍以 WS `mood` 帧为准。**用户侧不暴露 `thinking`**(模型调用旋钮属管理侧)。④ **`effort` 的能力提示归服务端**:读端点只回读**已存配置**,**不承诺**该值在当前 provider 上生效——白名单校验/降级/WARN 仍在模型层(design/04 §3.2),客户端不得把「配了」呈现为「已启用」。⑤ **向后兼容**:纯新增字段;不读新字段的客户端行为逐字节不变(旧面板仍可编辑并保留已存 `thinking`)。
- `v0.42`(**多角色并存 + 按会话选角**,对客户端**新增可选字段/可选请求字段**,向后兼容;design/22 批次 1 = 后端会话级绑定):① **`chat_sessions` 新增 `persona_id TEXT NOT NULL DEFAULT ''`、`chat_messages` 新增 `persona_id TEXT NOT NULL DEFAULT ''`**(同一迁移 **0011**,down 可逆,**不回填**)。`''` **= 继承全局默认角色**(`owner.yaml:defaultPersona`),所以**既有一切行升级后行为逐字节不变**,且默认角色改名后这些行跟着变(design/22 §4.7/§7-6)。② **`GET /api/v1/sessions` 每项新增可选 `personaId`**(=会话绑定角色);**消息 DTO(REST `GET /sessions/{id}/messages`、WS `history` 回放、`message_persisted`/`message_edited` 广播)新增可选 `personaId`**(=**说话时的角色**,design/22 §7-5)。③ **`POST /api/v1/sessions` body 新增可选 `{personaId}`**(创建即绑定;未知角色 `400`;响应仅在非空时回显 `personaId`,故**省略时响应形状与 v0.41 完全一致**);**`PATCH /api/v1/sessions/{id}` body 新增可选 `{personaId}`**(重新绑定,**显式 `""` 解绑**,未知角色 `400`;只回显变更字段)。④ **回合语义**:角色决定**系统提示词 / 记忆命名空间 / 情绪与好感度 / 思考预算**四者**同源**解析(design/22 §4.2),换角色**保留上下文**、**不动已存消息**,**下一回合**起生效;**记忆口径 = 保持 persona 隔离**(design/22 §3):不同角色的会话互不浮现,同角色的多个会话互相可见。⑤ **请求级覆盖(每次发送临时指定角色)本批未实现**(design/22 §2 裁决规则 1 仍待批次 2/后续);**渠道级绑定(IM 账号/群默认角色)本批未实现**(design/22 §4.4 批次 2),故 IM 路径仍按默认角色解析。⑥ **契约内新增内部读接口**:网关经窄接口类型断言读取 `history.Store.SessionPersona/SetSessionPersona/NewSubSessionFor`(注入的 HistoryStore **不支持**角色绑定时,`personaId` 写路径回 `501` 而非假成功)。
- `v0.42`(**多角色并存 + 按会话选角**,对客户端**新增可选字段/可选请求字段**,向后兼容;design/22 批次 1 = 后端会话级绑定):① **`chat_sessions` 新增 `persona_id TEXT NOT NULL DEFAULT ''`、`chat_messages` 新增 `persona_id TEXT NOT NULL DEFAULT ''`**(同一迁移 **0011**,down 可逆,**不回填**)。`''` **= 继承全局默认角色**(`owner.yaml:defaultPersona`),所以**既有一切行升级后行为逐字节不变**,且默认角色改名后这些行跟着变(design/22 §4.7/§7-6)。② **`GET /api/v1/sessions` 每项新增可选 `personaId`**(=会话绑定角色);**消息 DTO(REST `GET /sessions/{id}/messages`、WS `history` 回放、`message_persisted`/`message_edited` 广播)新增可选 `personaId`**(=**说话时的角色**,design/22 §7-5)。③ **`POST /api/v1/sessions` body 新增可选 `{personaId}`**(创建即绑定;未知角色 `400`;响应仅在非空时回显 `personaId`,故**省略时响应形状与 v0.41 完全一致**);~~**`PATCH /api/v1/sessions/{id}` body 新增可选 `{personaId}`**(重新绑定,**显式 `""` 解绑**,未知角色 `400`;只回显变更字段)~~(**本条的「PATCH 重新绑定/解绑」已由 v0.46 作废**:会话角色在创建时确定、之后不可变,PATCH 一旦收到 `personaId` 即回 `400`,见 §1 行 8 / §8-1)。④ **回合语义**:角色决定**系统提示词 / 记忆命名空间 / 情绪与好感度 / 思考预算**四者**同源**解析(design/22 §4.2);~~换角色**保留上下文**、**不动已存消息**,**下一回合**起生效~~(**已由 v0.46 作废**:会话内不再换角色——角色在创建时定死,`chat_messages.persona_id` 仍逐条记录"谁说的");**记忆口径 = 保持 persona 隔离**(design/22 §3):不同角色的会话互不浮现,同角色的多个会话互相可见。⑤ **请求级覆盖(每次发送临时指定角色)本批未实现**(design/22 §2 裁决规则 1 仍待批次 2/后续);**渠道级绑定(IM 账号/群默认角色)本批未实现**(design/22 §4.4 批次 2),故 IM 路径仍按默认角色解析。⑥ **契约内新增内部读接口**:网关经窄接口类型断言读取 `history.Store.SessionPersona/SetSessionPersona/NewSubSessionFor`(**v0.46 起**请求路径不再调用 `SetSessionPersona`——PATCH 的 persona 写路径已整体移除;「注入的 HistoryStore **不支持**角色绑定」只在 `POST /sessions` **显式传** `personaId` 时回 `501`,而非假成功)。
- `v0.41`(**人格 `thinking` 配置 + 推理旋钮按 provider 映射**,对客户端**新增一个可选请求字段**,向后兼容):① `POST /admin/personas` / `PUT /admin/personas/{id}` body 新增可选 **`thinking`** 对象 `{budgetTokens?,effort?,showToUser?,mode?}`(design/01 §2.1),原样写入并持久化到 `configs/personas/<id>.yaml` 的 `thinking` 节;非法 `mode`/负预算回 **`400 bad_request`**;**`PUT` 省略该对象时保留已存 `thinking`**(旧面板/脚本不会抹掉它);该字段只描述**呈现/资源**,不构成能力边界。② **`showToUser` 语义**:**缺省 = 照常把 `thinking.*` 流帧推给客户端**(现行为),显式 `false` = 仅落库/剥离语义不变、**不推帧**(展示与落库解耦)。③ **服务端推理映射**(对客户端透明,仅影响上游请求):Anthropic 系在 persona 配置 `budgetTokens`/`mode: on` 时发 `thinking:{type:"enabled",budget_tokens:N}`,并**保证 `budget_tokens + 正文留量 ≤ max_tokens`**(不足则抬高 `max_tokens` 并记 WARN,绝不把正文吃空);OpenAI 兼容系按**识别到的厂商白名单**校验 `reasoning_effort`(OpenAI 系 `none…max`、DeepSeek 仅 `low/high/max`),非法值**降级到最近一档并记 WARN**;**未知 provider/未识别模型、以及 Qwen 的 `enable_thinking` 硬开关(协议无统一 wire 形状)** 一律记 **WARN(含 provider/字段/取值)并保持请求不变**,绝不静默无效。详见 design/04 §3.2。
- `v0.40`(**`viewer` 角色真正接线:只读监控档**,对客户端**收紧/放宽若干 admin 读的鉴权**,无端点/字段形状变更):此前 `viewer` 只是常量(`internal/auth` 的 `RoleViewer` 注释写着「只读 dashboard/logs/monitoring」)而**从未接线**——管理侧 24 条 GET 全走 owner/admin,`viewer` 一条都读不到(**全 403**)。本版新增第四道门 `viewerAuth = owner+admin+viewer`,并把 **12 条只读监控 GET** 从 owner/admin 放宽到 owner/admin/**viewer**:`GET /admin/personas`、`/admin/sessions`、`/admin/plugins`、`/admin/plugins/installed`、`/admin/channels`、`/admin/iot`、`/admin/automations`、`/admin/rag`、`/admin/observability/metrics`、`/admin/observability/trace`、`/admin/observability/logs`、`/admin/notifications`。**明确不放给 viewer**(含凭据获取路径、他人数据、完整消息/导出明文、审计流水,或 `admin` 也不给):`GET /admin/memories`、`/admin/llm`、`/admin/users`、`/admin/backthink`、`/admin/observability/audit`、`/admin/channels/messages`、`/admin/channels/{platform}/messages`、`/admin/sessions/{id}/export`、`POST /admin/rag/query`(这 9 条 owner/admin)以及其余 **35 条 owner-only**(全部写操作 + `login-qr`/`login-status`/`view`/`friends`/渠道连接与配置/`plugins/*`/`users/*`/`system/*`)。**匿名**:任意 admin 路由仍 `401`(fail-closed)。**向后兼容**:`owner`/`admin` 语义不变(放行集合是超集),仅 `viewer` 的可见面从「全 403」变为 12 条只读监控;不依赖 `viewer` 的客户端行为不变。完整分档见 §2.0「管理侧权限矩阵」。
- `v0.39`(**微信 iLink 会话被拒可见化 + 不再假 OK**,对客户端**新增两个可选字段、一处错误码**,向后兼容):① **`GET /api/v1/admin/channels` 每平台行新增可选 `needsRescan`(bool,`omitempty`)+ `lastError`(string,`omitempty`)**——仅当适配器**真的**要求重新授权时出现(今天只有微信 iLink 返回 `errcode=-14` 这一种),取值来自适配器新增的**可选能力接口** `RescanRequired() (bool, string)`,网关**经类型断言**读取(主 `PlatformAdapter` 契约**不变**;未实现该能力的平台字段**缺省不出现**)。用途:把「需要重新扫码」与「主人自己关掉的未连接」区分开——后者仅 `connected:false`。② **`POST /admin/channels/open`** 对「iLink 已拒绝该会话」回 **`409 session_rejected`**(message 明确要求用「扫码登录」重新绑定),**不再**吞掉错误回 200;`409 needs_login`(尚无 token,v0.8 起)不变。③ **适配器行为**:iLink `errcode=-14` 进入**显式被拒态**——`IsConnected()` 恒 `false`;`Connect()` 返回被拒错误(**不再**对被拒会话报成功);`HealthCheck` 区分「会话被拒」与「未连接」;并以 **`ERROR`** 级日志写明「本次会话已被 iLink 拒绝,需要重新扫码绑定」(只带 `bot_id` 等非敏感标识,**token 永不入日志**)。被拒态在读循环与发送两条路径**都会**置位,并仅在**扫码登录拿到新 token** 或**配置换了新 token**(保存 + 热重载)时清除。④ 扫码登录侧:被拒期间 `login-status` 报 **`failed` + 被拒原因**(**不再**假报 `confirmed`,否则面板会把已作废凭据当成功重新落盘);`login-qr` 仍可正常出码(**不**回 `already_logged_in`,否则重新扫码这条路被堵死);`get_bot_qrcode` 不再携带已被拒的 token(否则服务端以「已绑定」应答、**不**发新凭据)。**向后兼容**:只新增可选字段与一处更诚实的错误码;不读新字段、不处理 409 的客户端行为不变(此时面板显示「未连接」,仍可用「扫码登录」恢复)。**语义澄清(本次仅措辞,不改契约形状、不另起版本号——v0.39 尚未被任何客户端采用)**:iLink 的 bot token 是**长期凭据**(实测一个月前扫码得到的 token 仍可用),`errcode=-14` 表示 **iLink 侧拒绝/作废了该会话凭据**,**不是时间性过期**。详见 §2 行 6 与「微信 iLink『会话被拒』契约」。
+15 -73
View File
@@ -1,12 +1,7 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import {
adminListPersonas,
adminListSessions,
createSubSession,
patchSession,
} from "../../lib/api";
import type { PersonaRecord, SessionMeta } from "../../lib/types";
import { adminListSessions, createSubSession } from "../../lib/api";
import type { SessionMeta } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
@@ -22,49 +17,11 @@ function exportMeta(sess: SessionMeta): void {
URL.revokeObjectURL(url);
}
/** The role cell of one row: the session's bound role, editable in place. An id
* that is not in the catalog (role deleted, catalog still loading) keeps its own
* option, so the cell never silently reads as "inherit the default". */
function PersonaCell({
session,
personas,
disabled,
onChange,
}: {
session: SessionMeta;
personas: PersonaRecord[];
disabled: boolean;
onChange: (personaId: string) => void;
}): React.ReactNode {
const bound = session.personaId ?? "";
const unknown = bound !== "" && !personas.some((p) => p.id === bound);
return (
<select
value={bound}
disabled={disabled}
aria-label={`会话 ${session.id} 的角色`}
onChange={(e) => onChange(e.target.value)}
className="max-w-[180px] rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
>
<option value="">(继承默认角色)</option>
{unknown ? <option value={bound}>{bound}(不在角色列表)</option> : null}
{personas.map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.isDefault ? " · 默认" : ""}
</option>
))}
</select>
);
}
/** The session table's columns. Built per render because two of the cells are
* wired to the module's live state (the busy flag, the role catalog). */
function buildColumns(opts: {
personas: PersonaRecord[];
busy: string | null;
onChangePersona: (sessionId: string, personaId: string) => void;
}): AdminColumn<SessionMeta>[] {
const { personas, busy, onChangePersona } = opts;
/** The session table's columns. Built per render because the action cell is wired to
* the module's live busy flag. The 角色 cell is read-only: a session's role is fixed
* when it is created (design/22 §8-1), so the panel shows the binding it was created
* with instead of offering a re-bind — changing the role means creating a session. */
function buildColumns(busy: string | null): AdminColumn<SessionMeta>[] {
return [
{ key: "type", title: "类型", render: (s) => <Tag tone={s.type === "main" ? "ok" : "accent"}>{s.type === "main" ? "主会话" : "次级会话"}</Tag> },
{ key: "title", title: "标题", render: (s) => <span>{s.title ? s.title : <span className="text-[var(--mu-text-lo)]">(未命名)</span>}</span> },
@@ -73,12 +30,9 @@ function buildColumns(opts: {
key: "persona",
title: "角色",
render: (s) => (
<PersonaCell
session={s}
personas={personas}
disabled={busy !== null}
onChange={(personaId) => onChangePersona(s.id, personaId)}
/>
<span className="font-mono text-xs">
{s.personaId ? s.personaId : <span className="text-[var(--mu-text-lo)]">(继承默认角色)</span>}
</span>
),
},
{ key: "createdAt", title: "创建时间", render: (s) => <span className="text-xs">{new Date(s.createdAt).toLocaleString()}</span> },
@@ -94,14 +48,12 @@ function buildColumns(opts: {
];
}
/** The page's two write actions, both audited. The list is re-read on success —
* the PATCH reply is not a full session row — which also refreshes the row from
* server truth instead of a local guess. */
/** The page's write action, audited. The list is re-read on success so the new row
* comes from server truth instead of a local guess. */
function useSessionMutations(reload: () => void): {
busy: string | null;
fail: string | null;
newSubSession: () => void;
changePersona: (sessionId: string, personaId: string) => void;
} {
const [busy, setBusy] = useState<string | null>(null);
const [fail, setFail] = useState<string | null>(null);
@@ -132,19 +84,14 @@ function useSessionMutations(reload: () => void): {
fail,
newSubSession: () =>
run("new", "session.create", () => createSubSession(), "新建次级会话失败"),
changePersona: (sessionId, personaId) =>
run(sessionId, "session.persona", () => patchSession(sessionId, { personaId }), "挂角色失败"),
};
}
export function SessionsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListSessions, []);
useAutoReload(reload, 10000);
// The role catalog for the "挂角色" column (design/22 §4.6); the panel reads it
// from the admin endpoint, and the write goes through the ordinary session PATCH.
const personas = useAsyncData(adminListPersonas, []);
const [query, setQuery] = useState("");
const { busy, fail, newSubSession, changePersona } = useSessionMutations(reload);
const { busy, fail, newSubSession } = useSessionMutations(reload);
const filtered = (data ?? []).filter((s) => {
if (!query.trim()) return true;
@@ -156,11 +103,7 @@ export function SessionsModule(): React.ReactNode {
);
});
const columns: AdminColumn<SessionMeta>[] = buildColumns({
personas: personas.data ?? [],
busy,
onChangePersona: changePersona,
});
const columns: AdminColumn<SessionMeta>[] = buildColumns(busy);
return (
<div className="space-y-5">
@@ -200,8 +143,7 @@ export function SessionsModule(): React.ReactNode {
<Notice>
列表来自后端持久化会话(主会话 + 次级会话),主会话 id 固定。导出为会话元数据 JSON;身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。
「角色」列是会话挂载的角色(design/22 §4.6):「继承默认角色」= 会话不绑定,随全局默认角色走;
改角色决定该会话记忆/情绪/提示词的归属(owner-only + 审计)。
「角色」列只读显示该会话创建时绑定的角色(design/22 §4.6/§8-1):会话角色在创建时确定、之后不可变,要换角色请新开会话(本页「新建次级会话」按默认角色新建,用户端侧栏的新建会话可选角色)。
</Notice>
</div>
);
+21 -98
View File
@@ -1,6 +1,5 @@
import { useState } from "react";
import { createSubSession, getAuthToken, patchSession } from "../lib/api";
import { setSessionPersona } from "../lib/sessionPersona";
import { createSubSession, getAuthToken } from "../lib/api";
import { useAppStore } from "../store/useAppStore";
import type { PersonaRecord, SessionMeta } from "../lib/types";
import { Icon } from "./Icon";
@@ -8,14 +7,12 @@ import { NavItem } from "./NavItem";
import { Button } from "./primitives";
/**
* The conversation list in the rail (design/16 §2 + design/22 §4.6):
* The conversation list in the rail (design/16 §2 + design/22 §4.6/§8-1):
* - main session first, secondary sessions by creation time, unread badges;
* - "新建会话" reveals a role picker, so a new conversation can start bound to a
* role (empty = inherit the global default role);
* - every row has a role menu that re-binds *that* session through
* `PATCH /sessions/{id} {personaId}` (design/22 §4.6). Changing a session's
* role changes which memory / emotion / system prompt the session talks to, so
* it stays an explicit, per-session action instead of a global switch.
* role (empty = inherit the global default role). This is the ONLY place a role
* is picked: a session's role is fixed at creation (design/22 §8-1), so a row has
* no role menu to re-bind it — switching roles means starting a new conversation.
*
* Kept out of `Nav.tsx` so no single component grows past the 80-line rule.
*/
@@ -25,12 +22,6 @@ export function SessionRail({ onNavigate }: { onNavigate: () => void }): React.R
const setSessions = useAppStore((s) => s.setSessions);
const setActiveSession = useAppStore((s) => s.setActiveSession);
const personas = useAppStore((s) => s.personas);
const setSessionPersonaLocal = useAppStore((s) => s.setSessionPersona);
/** The session whose role menu is expanded, or null when none is open. */
const [menuFor, setMenuFor] = useState<string | null>(null);
/** Set when a role change was rejected, so the row can say so instead of
* silently showing the role the server still has. */
const [failed, setFailed] = useState(false);
if (!getAuthToken()) return null;
@@ -58,104 +49,37 @@ export function SessionRail({ onNavigate }: { onNavigate: () => void }): React.R
onNavigate();
};
/** Re-bind one session to a role: written optimistically into the store and
* rolled back when the PATCH is rejected (lib/sessionPersona.ts), so the rail
* never keeps showing a binding the server refused. */
const changePersona = async (sessionId: string, personaId: string) => {
setFailed(false);
const applied = await setSessionPersona(
{
read: (id) => useAppStore.getState().sessions[id]?.personaId ?? "",
write: setSessionPersonaLocal,
persist: (id, pid) => patchSession(id, { personaId: pid }),
},
sessionId,
personaId
);
if (applied) setMenuFor(null);
else setFailed(true);
};
return (
<div className="flex flex-col gap-1">
<div className="px-2 pt-1 text-[11px] text-[var(--mu-text-lo)]">会话</div>
{sessionList.map((s) => (
<SessionEntry
key={s.id}
session={s}
active={s.id === activeSessionId}
menuOpen={menuFor === s.id}
failed={failed && menuFor === s.id}
personas={personas}
onOpen={() => openSession(s.id)}
onToggleMenu={() => {
setFailed(false);
setMenuFor(menuFor === s.id ? null : s.id);
}}
onChangePersona={(personaId) => void changePersona(s.id, personaId)}
/>
<SessionEntry key={s.id} session={s} active={s.id === activeSessionId} onOpen={() => openSession(s.id)} />
))}
<CreateSessionSection personas={personas} onCreated={adoptSession} />
</div>
);
}
/** One conversation row plus its role menu. */
/** One conversation row. It carries no role affordance: the role is fixed when the
* session was created (design/22 §8-1), so the only thing a row does is open. */
function SessionEntry({
session,
active,
menuOpen,
failed,
personas,
onOpen,
onToggleMenu,
onChangePersona,
}: {
session: SessionMeta;
active: boolean;
menuOpen: boolean;
failed: boolean;
personas: PersonaRecord[];
onOpen: () => void;
onToggleMenu: () => void;
onChangePersona: (personaId: string) => void;
}): React.ReactNode {
const label = session.title ?? (session.type === "main" ? "主会话" : "次级会话");
return (
<div className="flex flex-col">
<NavItem
active={active}
icon={session.type === "main" ? "chat" : "folder"}
label={label}
badge={session.unreadCount ?? 0}
onClick={onOpen}
trailing={
<button
onClick={onToggleMenu}
aria-label={`${label} · 切换角色`}
aria-expanded={menuOpen}
title="切换角色"
className="mr-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--mu-radius-sm)] text-[var(--mu-text-lo)] transition-colors duration-200 hover:bg-[var(--mu-surface-5)] hover:text-[var(--mu-text-hi)]"
>
<Icon name="dna" size={14} />
</button>
}
/>
{menuOpen ? (
<div className="liquid-glass-soft mx-1 mb-1 flex flex-col gap-1.5 rounded-[var(--mu-radius)] p-2">
<span className="text-[11px] text-[var(--mu-text-lo)]">会话角色</span>
<RoleSelect
value={session.personaId ?? ""}
personas={personas}
label={`${label}的角色`}
onChange={onChangePersona}
/>
{failed ? (
<span className="text-[10px] text-[var(--mu-err)]">切换失败,已保持原角色</span>
) : null}
</div>
) : null}
</div>
<NavItem
active={active}
icon={session.type === "main" ? "chat" : "folder"}
label={label}
badge={session.unreadCount ?? 0}
onClick={onOpen}
/>
);
}
@@ -236,11 +160,12 @@ function CreateSessionSection({
);
}
/** Miuix-styled role picker over `GET /personas` (design/22 §4.6). The empty
* option means "inherit the global default role" — the value a session with no
* binding has — so a session can always be returned to the default. A value that
* is not in the catalog (role deleted, catalog still loading) is shown as its own
* option rather than silently reading as "inherit". */
/** Miuix-styled role picker over `GET /personas` (design/22 §4.6), used only by the
* "new conversation" panel: the empty option means "inherit the global default
* role", which is a perfectly normal way to create a session (and the only role
* choice left — a created session cannot be re-bound, §8-1). Every value this
* control shows comes from that catalog or is "", so it needs no "unknown role"
* escape hatch. */
function RoleSelect({
value,
personas,
@@ -254,7 +179,6 @@ function RoleSelect({
label: string;
onChange: (personaId: string) => void;
}): React.ReactNode {
const unknown = value !== "" && !personas.some((p) => p.id === value);
return (
<select
value={value}
@@ -264,7 +188,6 @@ function RoleSelect({
className="min-w-0 flex-1 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-[11px] text-[var(--mu-text-hi)] outline-none transition-colors duration-200 focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
>
<option value="">继承默认角色</option>
{unknown ? <option value={value}>{value}(不在角色列表)</option> : null}
{personas.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
+6 -6
View File
@@ -75,17 +75,17 @@ export function createSubSession(opts: {
});
}
/** One PATCH /api/v1/sessions/{id} change set. At least one field must be set;
* the gateway rejects an empty body with 400. `personaId: ""` explicitly
* re-binds the session to "inherit the default role" (design/22 §4.5). */
/** One PATCH /api/v1/sessions/{id} change set. At least one field must be set; the
* gateway rejects an empty body with 400. `personaId` is deliberately absent: a
* session's role is fixed at creation (design/22 §8-1), so the gateway answers 400
* for any body carrying that key — switching roles means creating a new session. */
export interface SessionPatch {
title?: string;
pinned?: boolean;
personaId?: string;
}
/** PATCH /api/v1/sessions/{id} — updates a session's display metadata and/or its
* bound role. The reply echoes only the fields that were changed. */
/** PATCH /api/v1/sessions/{id} — updates a session's display metadata (rename /
* pin). The reply echoes only the fields that were changed. */
export function patchSession(
sessionId: string,
patch: SessionPatch
-50
View File
@@ -1,50 +0,0 @@
/**
* Re-binding one session to another role (design/22 §4.6).
*
* Picking a role is a two-step write: the local session list is updated first so
* the avatar / name / wallpaper / mood switch at once, then `PATCH
* /api/v1/sessions/{id}` persists it. A rejected request must not leave the UI
* showing a role the server never accepted, so the optimistic write is rolled
* back on failure — otherwise the next `GET /sessions` would silently snap every
* consumer back and the user would be looking at a lie in the meantime.
*
* The store and the REST call arrive as `io` rather than being imported here, so
* the ordering (write → persist → maybe roll back) is a plain function that can
* be driven by a logic test with fakes — this project ships no test runner.
*/
export interface SessionPersonaIO {
/** The locally-known bound role id for a session ("" = inherits the default). */
read: (sessionId: string) => string;
/** Write the locally-known bound role id (the optimistic and rollback write). */
write: (sessionId: string, personaId: string) => void;
/** Persist the binding remotely; the returned promise rejects on failure. */
persist: (sessionId: string, personaId: string) => Promise<unknown>;
}
/** Bind `sessionId` to `personaId` ("" = inherit the default role).
*
* Resolves true when the server accepted it — including the no-op case where the
* session is already bound to that role, which fires no request at all. Resolves
* false when the session id is empty or the request failed (the local value is
* then restored, so the store and the server list agree again). */
export async function setSessionPersona(
io: SessionPersonaIO,
sessionId: string,
personaId: string
): Promise<boolean> {
if (!sessionId) return false;
const previous = io.read(sessionId);
if (previous === personaId) return true;
io.write(sessionId, personaId);
try {
await io.persist(sessionId, personaId);
return true;
} catch {
// Roll back only while our optimistic value is still the one on screen: a
// second pick that landed meanwhile owns its own request and its own answer,
// and reverting it here would clobber a newer choice with a stale value.
if (io.read(sessionId) === personaId) io.write(sessionId, previous);
return false;
}
}
+12 -123
View File
@@ -1,12 +1,12 @@
// Logic test for per-session role selection (design/22 §4.6/§7, batch 1 frontend):
// the role the UI renders is *derived* from the active session — never a global
// value — an unbound (empty/absent) `personaId` falls back to the default role, a
// re-bind is written locally first and rolled back when the PATCH is rejected, and
// the role-scoped `mood` frame lands in its own role's bucket. The project has no
// frontend test runner and no jsdom, so this is a dependency-free plain-node check
// that drives the real resolver (lib/persona.ts), the real re-bind helper
// (lib/sessionPersona.ts), the real WS frame router (lib/ws.ts) and the real
// store, and throws on the first failure.
// value — an unbound (empty/absent) `personaId` falls back to the default role, and
// the role-scoped `mood` frame lands in its own role's bucket. Nothing here re-binds
// a session: a role is fixed at creation (design/22 §8-1), so picking a different
// role means creating a session. The project has no frontend test runner and no
// jsdom, so this is a dependency-free plain-node check that drives the real resolver
// (lib/persona.ts), the real WS frame router (lib/ws.ts) and the real store, and
// throws on the first failure.
//
// Run it with the esbuild that already ships with vite (no new dependency, no
// package.json change); the banner supplies the `window.localStorage` the store's
@@ -23,7 +23,6 @@
// imports nothing from node:*.
import { resolveMessagePersona, resolveSessionPersona } from "../lib/persona";
import { setSessionPersona, type SessionPersonaIO } from "../lib/sessionPersona";
import { dispatchFrame } from "../lib/ws";
import { selectCurrentPersona } from "./personaSelectors";
import { useAppStore } from "./useAppStore";
@@ -96,117 +95,9 @@ function testFallbackToDefaultRole(): void {
same(resolveSessionPersona(MAIN, fixtures, noFlag)?.id, "yuki", "no isDefault flag falls back to the first role");
}
/** 3. A re-bind writes locally first, then persists; a rejection restores it. */
async function testOptimisticRebind(): Promise<void> {
const calls: string[] = [];
const state: Record<string, string> = { [SUB_A]: "yuki" };
const io: SessionPersonaIO = {
read: (id) => state[id] ?? "",
write: (id, personaId) => {
state[id] = personaId;
calls.push(`write ${id}=${personaId}`);
},
persist: (id, personaId) => {
calls.push(`persist ${id}=${personaId}`);
return Promise.resolve();
},
};
same(await setSessionPersona(io, SUB_A, "cyrene"), true, "an accepted re-bind resolves true");
same(state[SUB_A], "cyrene", "local binding after a successful PATCH");
same(
calls,
[`write ${SUB_A}=cyrene`, `persist ${SUB_A}=cyrene`],
"the local write happens before the request (no request would mean no UI change)"
);
// Rejection: the local value must go back, or the UI would show a role the
// server never accepted until the next GET /sessions corrects it.
const failedCalls: string[] = [];
const failedState: Record<string, string> = { [SUB_A]: "yuki" };
const failing: SessionPersonaIO = {
read: (id) => failedState[id] ?? "",
write: (id, personaId) => {
failedState[id] = personaId;
failedCalls.push(`write ${id}=${personaId}`);
},
persist: (id, personaId) => {
failedCalls.push(`persist ${id}=${personaId}`);
return Promise.reject(new Error("patch failed"));
},
};
same(await setSessionPersona(failing, SUB_A, "cyrene"), false, "a rejected re-bind resolves false");
same(failedState[SUB_A], "yuki", "local binding restored after a failed PATCH");
same(
failedCalls,
[`write ${SUB_A}=cyrene`, `persist ${SUB_A}=cyrene`, `write ${SUB_A}=yuki`],
"the rollback writes the previous value back"
);
// Unchanged binding: no write, no request at all.
let writes = 0;
let persists = 0;
const noop: SessionPersonaIO = {
read: () => "yuki",
write: () => {
writes += 1;
},
persist: () => {
persists += 1;
return Promise.resolve();
},
};
same(await setSessionPersona(noop, SUB_A, "yuki"), true, "re-picking the bound role resolves true");
same([writes, persists], [0, 0], "re-picking the bound role fires no write and no request");
same(await setSessionPersona(noop, "", "yuki"), false, "an empty session id is refused");
// A second pick that lands while the first request is in flight owns the value:
// the older failure must not revert it.
let rejectFirst: (err: Error) => void = () => {};
let firstPersist = true;
const state2: Record<string, string> = { [SUB_A]: "yuki" };
const racing: SessionPersonaIO = {
read: (id) => state2[id] ?? "",
write: (id, personaId) => {
state2[id] = personaId;
},
persist: () =>
firstPersist
? new Promise<void>((_, reject) => {
firstPersist = false;
rejectFirst = reject;
})
: Promise.resolve(),
};
const first = setSessionPersona(racing, SUB_A, "cyrene");
const second = setSessionPersona(racing, SUB_A, "yuki");
rejectFirst(new Error("patch failed"));
same(await first, false, "the superseded request still reports its failure");
same(await second, true, "the newer pick is accepted");
same(state2[SUB_A], "yuki", "a stale failure does not revert the newer choice");
}
/** 4. The store's optimistic write re-resolves the rendered role at once. */
function testStoreRebind(): void {
useAppStore.setState({ personas: PERSONAS, sessions: sessionFixtures(), activeSessionId: SUB_B });
useAppStore.getState().setSessionPersona(SUB_B, "yuki");
same(selectCurrentPersona(useAppStore.getState())?.id, "yuki", "the optimistic re-bind re-resolves the rendered role");
same(useAppStore.getState().sessions[SUB_B].personaId, "yuki", "the session list carries the new binding");
const after = useAppStore.getState().sessions;
useAppStore.getState().setSessionPersona("web:missing", "yuki");
ok(useAppStore.getState().sessions === after, "an unknown session id is a no-op (map untouched)");
same(
Object.keys(useAppStore.getState().sessions).sort(),
[MAIN, SUB_A, SUB_B].sort(),
"an unknown session id cannot invent a row"
);
useAppStore.getState().setSessionPersona(SUB_A, "yuki");
ok(useAppStore.getState().sessions === after, "re-picking the already-bound role is a no-op (map untouched)");
}
/** 5. A message keeps the role that spoke it (design/22 §7-5): switching a
* session's role must not re-attribute turns that were already sent. */
/** 3. A message keeps the role that spoke it (design/22 §7-5): a reply authored by
* another role keeps its own authoring role, it is never re-attributed to the
* session's role. */
function testMessageAuthoringRole(): void {
const yuki = PERSONAS[1];
const cyrene = PERSONAS[0];
@@ -228,7 +119,7 @@ function testMessageAuthoringRole(): void {
same([gone.persona?.id, gone.switched], ["yuki", false], "an unknown role id falls back to the session's role");
}
/** 6. `mood` frames are role-scoped: each role keeps its own mood. */
/** 4. `mood` frames are role-scoped: each role keeps its own mood. */
function testMoodBuckets(): void {
useAppStore.setState({ moods: {}, personas: PERSONAS, sessions: sessionFixtures(), activeSessionId: SUB_A });
dispatchFrame({ type: "mood", data: { mood: "happy", personaId: "yuki" } });
@@ -242,11 +133,9 @@ function testMoodBuckets(): void {
same(useAppStore.getState().moods[""], "calm", "clearing one bucket leaves the others alone");
}
async function run(): Promise<void> {
function run(): void {
testResolutionFollowsActiveSession();
testFallbackToDefaultRole();
await testOptimisticRebind();
testStoreRebind();
testMessageAuthoringRole();
testMoodBuckets();
console.log(`personaSession.test: ${checks} checks passed`);
-13
View File
@@ -81,11 +81,6 @@ export interface AppStore {
/** Replace the session metadata list. */
setSessions: (list: SessionMeta[]) => void;
/** Re-bind one session to a role locally — the optimistic write and the rollback
* target of `lib/sessionPersona.ts#setSessionPersona`. `""` re-binds it to
* "inherit the default role". A no-op for an unknown session or one already
* bound to that role. */
setSessionPersona: (sessionId: string, personaId: string) => void;
/** Zero one session's unread badge locally (Android 协同 B3/D4). Called when the
* user opens the session and its page has loaded, and on the `session_read`
* frame another end broadcasts — a no-op for an already-read or unknown
@@ -179,14 +174,6 @@ export const useAppStore = create<AppStore>()(
set({
sessions: Object.fromEntries(list.map((s) => [s.id, s])),
}),
setSessionPersona: (sessionId, personaId) =>
set((s) => {
const meta = s.sessions[sessionId];
if (!meta || (meta.personaId ?? "") === personaId) return s;
return {
sessions: { ...s.sessions, [sessionId]: { ...meta, personaId } },
};
}),
setActiveSession: (id) => set({ activeSessionId: id, messages: [] }),
clearUnread: (sessionId) =>
set((s) => {
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
@@ -91,8 +92,10 @@ func gatewayAgentFor(t *testing.T, instructions string, ad model.Adapter) *agent
// TestChatTurnAnswersAsTheBoundRole is design/22 acceptance 3 seen from the process
// boundary: a sub session created and bound to role Y answers as Y (the first system
// message is Y's prompt and the persisted messages carry Y), and once the session is
// rebound to "inherit the default" the next turn answers as the default role again.
// message is Y's prompt and the persisted messages carry Y). Its role is fixed at
// creation (§8-1), so the PATCH that used to unbind it is refused and the session
// keeps answering as Y; the only way to get the default role is a session created
// without a binding.
func TestChatTurnAnswersAsTheBoundRole(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -140,16 +143,30 @@ func TestChatTurnAnswersAsTheBoundRole(t *testing.T) {
}
}
// Unbind the role: the session inherits the global default, so the next turn is
// the default role's again.
// Unbinding is refused (design/22 §8-1): a session's role is decided when it is
// created, so this PATCH answers 400 and the very next turn still answers as Y.
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+sub, map[string]any{"personaId": ""}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("unbind status = %d, want 200 (body=%s)", resp.StatusCode, body)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("unbind status = %d, want 400 (a session's role is immutable; body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{"sessionId": sub, "text": "再说一句"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("second chat status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := firstSystemText(ad.reqs[len(ad.reqs)-1]); got != "你是由纪。" {
t.Fatalf("prompt after the refused unbind = %q, want role Y's prompt", got)
}
// The default role is reached by a session created WITHOUT a binding — the only
// way to change role now.
unbound, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("NewSubSession: %v", err)
}
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{"sessionId": unbound, "text": "你好"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("default-role chat status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := firstSystemText(ad.reqs[len(ad.reqs)-1]); got != "你是昔涟。" {
t.Fatalf("unbound session prompt = %q, want the default role's prompt", got)
}
@@ -170,10 +187,12 @@ func firstSystemText(req model.Request) string {
return ""
}
// TestSessionPersonaHTTPContract covers the REST contract of design/22 §4.5: create
// with a personaId binds it, the session list reports it, PATCH rebinds and clears
// it, an unknown personaId is rejected (400) instead of binding a role that cannot
// answer, and a PATCH with none of title/pinned/personaId is still a 400.
// TestSessionPersonaHTTPContract covers the REST contract of design/22 §4.5 as
// amended by §8-1: create with a personaId binds it, the session list reports it, an
// unknown personaId on create is rejected (400) instead of binding a role that cannot
// answer, a PATCH with neither title nor pinned is a 400, and PATCH REFUSES the
// personaId key for every value — a session's role is fixed at creation, so there is
// no supported rebind and the refusal can never be mistaken for a silent no-op.
func TestSessionPersonaHTTPContract(t *testing.T) {
t.Parallel()
st := personaTestStore(t)
@@ -210,15 +229,11 @@ func TestSessionPersonaHTTPContract(t *testing.T) {
t.Fatalf("listed persona_id = %q, want yuki (sessions=%+v)", found, sessions)
}
// An unknown role is refused rather than silently ignored.
// An unknown role on create is refused rather than silently ignored.
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", map[string]any{"personaId": "nobody"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("create with unknown personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"personaId": "nobody"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("patch with unknown personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
// A body with none of the supported fields stays a 400.
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{}, tok)
@@ -226,20 +241,69 @@ func TestSessionPersonaHTTPContract(t *testing.T) {
t.Fatalf("empty patch status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
// Rebind to the other role, then clear the binding; both echo only the changed field.
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"personaId": "xilian"}, tok)
// PATCH refuses the personaId key for EVERY value (design/22 §8-1): the role is
// fixed when the session is created, so a rebind — to another role, to the bound
// role, to "", or to null — is not a supported operation. Each refusal must name
// personaId (so a client can tell it from "no field given") and must leave the
// stored binding exactly as it was.
for _, bad := range []any{"xilian", "yuki", "nobody", "", " ", nil} {
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"personaId": bad}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("patch personaId=%#v status = %d, want 400 (body=%s)", bad, resp.StatusCode, body)
}
if !strings.Contains(string(body), "personaId") {
t.Fatalf("patch personaId=%#v body = %s, want it to name personaId", bad, body)
}
if got := personaBindingOf(t, st, created.SessionID); got != "yuki" {
t.Fatalf("patch personaId=%#v moved the binding to %q, want it left at yuki", bad, got)
}
}
// The refusal beats a legal field in the same body: a client cannot smuggle a
// rebind alongside a rename, and the rename must not land either.
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": "偷偷改名", "personaId": "xilian"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("rename+personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if got := personaBindingOf(t, st, created.SessionID); got != "yuki" {
t.Fatalf("rename+personaId moved the binding to %q, want yuki", got)
}
metas, err := st.ListSessions(context.Background())
if err != nil {
t.Fatalf("ListSessions: %v", err)
}
for _, m := range metas {
if m.ID == created.SessionID && m.Title != "" {
t.Fatalf("the refused patch applied title %q, want the row untouched", m.Title)
}
}
// The personaId check runs BEFORE the session lookup: an unknown session carrying
// the key answers 400 (immutable role), not 404 — while a title-only patch for the
// very same unknown id is still the store's 404, which is what proves the 400 above
// comes from the persona rule rather than from a missing session. An empty body is
// the third corner of the same table: 400 for a missing field, again without ever
// looking the session up.
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{"personaId": "xilian"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("unknown session + personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("unknown session + empty body status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{"title": "x"}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown session + title status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
// title/pinned are untouched by the refusal.
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": "新名字"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("rebind status = %d, want 200 (body=%s)", resp.StatusCode, body)
t.Fatalf("title-only patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := personaBindingOf(t, st, created.SessionID); got != "xilian" {
t.Fatalf("after rebind persona_id = %q, want xilian", got)
}
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"personaId": ""}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("unbind status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := personaBindingOf(t, st, created.SessionID); got != "" {
t.Fatalf("after unbind persona_id = %q, want '' (inherit the default)", got)
if got := personaBindingOf(t, st, created.SessionID); got != "yuki" {
t.Fatalf("title-only patch moved the binding to %q, want yuki", got)
}
}
+32 -46
View File
@@ -19,15 +19,20 @@ import (
// broadcast their outcome over the WS hub (message_edited / message_deleted, see
// ws_hub.go) so the account's other browsers stay in sync without a manual re-read.
// patchSessionRequest is the PATCH /api/v1/sessions/{id} body. All fields are
// patchSessionRequest is the PATCH /api/v1/sessions/{id} body. Both fields are
// pointers so an absent field is distinguishable from an explicit zero value:
// title renames the session (A4), pinned sets/unets the pin (D5), personaId
// rebinds the session's role (design/22 §4.5, an explicit "" clears the binding
// back to "inherit the global default"), and at least one must be present.
// title renames the session (A4), pinned sets/unets the pin (D5), and at least one
// must be present.
//
// personaId is deliberately NOT a settable field: a session's role is fixed when the
// session is created (design/22 §8-1), so the key is rejected outright instead of
// being ignored. It stays in the struct as the raw JSON so the handler can tell
// "the key is absent" from "the key is present with any value" — `"yuki"`, `""` and
// `null` are all refused, and only a body without the key is accepted.
type patchSessionRequest struct {
Title *string `json:"title"`
Pinned *bool `json:"pinned"`
PersonaID *string `json:"personaId"`
Title *string `json:"title"`
Pinned *bool `json:"pinned"`
PersonaID json.RawMessage `json:"personaId"`
}
// maxSessionTitleRunes bounds a client-supplied session title so a display row never
@@ -36,14 +41,17 @@ type patchSessionRequest struct {
const maxSessionTitleRunes = 80
// handlePatchSession updates a persistent session's display metadata — its title
// (A4), its pinned flag (D5) — and/or its role binding (design/22 §4.5). All three
// are metadata: unlike DELETE they are allowed on the fixed main session. A request
// carrying none of the fields answers 400, as do an empty/whitespace or over-long
// title and an unknown personaId; an unknown session id answers 404. Rebinding a
// role does not touch the stored conversation — the messages keep the role that
// spoke them (design/22 §7-5) — it only changes which role answers from the next
// turn on. It is owner/admin-gated like the other history writes. The response
// echoes only the fields that were changed, so a title-only PATCH keeps its pre-D5
// (A4) and its pinned flag (D5). Both are metadata: unlike DELETE they are allowed
// on the fixed main session. A request carrying neither field answers 400, as do an
// empty/whitespace or over-long title and an unknown session id (404/500 as usual).
//
// A body carrying the personaId key answers 400 for EVERY value, and that check runs
// before the session is looked up: a session's role is fixed at creation (design/22
// §8-1), so switching the role of an existing conversation — including clearing it —
// is not a supported operation; create a new session instead. Refusing explicitly
// (rather than ignoring the key) is what keeps a client from believing a rebind
// landed. It is owner/admin-gated like the other history writes. The response echoes
// only the fields that were changed, so a title-only PATCH keeps its pre-D5
// {id,title} shape.
func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
@@ -60,8 +68,13 @@ func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if req.Title == nil && req.Pinned == nil && req.PersonaID == nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title, pinned or personaId is required"))
if req.PersonaID != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request",
"personaId cannot be changed: 会话角色在创建时确定,之后不可变,要换角色请新开会话 (design/22 §8-1)"))
return
}
if req.Title == nil && req.Pinned == nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title or pinned is required"))
return
}
resp := map[string]any{"id": id}
@@ -88,43 +101,16 @@ func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) {
}
resp["pinned"] = *req.Pinned
}
if req.PersonaID != nil {
personaID := strings.TrimSpace(*req.PersonaID)
if !s.requireKnownPersona(w, personaID) {
return
}
if err := s.setSessionPersona(r.Context(), id, personaID); err != nil {
s.writeSessionPatchError(w, err, id, "set session persona")
return
}
resp["personaId"] = personaID
}
writeJSON(w, http.StatusOK, resp)
}
// setSessionPersona rebinds a session's role through the store's optional
// role-writer. A store without it answers 501 rather than reporting a binding that
// was never written.
func (s *Server) setSessionPersona(ctx context.Context, sessionID, personaID string) error {
writer, ok := s.history.(sessionPersonaWriter)
if !ok {
return errPersonaBindingUnsupported
}
return writer.SetSessionPersona(ctx, sessionID, personaID)
}
// writeSessionPatchError maps a RenameSession/SetPinned/SetSessionPersona failure to
// its response: ErrSessionNotFound → 404, a store that cannot bind roles → 501,
// anything else → 500 (logged under op).
// writeSessionPatchError maps a RenameSession/SetPinned failure to its response:
// ErrSessionNotFound → 404, anything else → 500 (logged under op).
func (s *Server) writeSessionPatchError(w http.ResponseWriter, err error, id, op string) {
if errors.Is(err, history.ErrSessionNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "session %q not found", id))
return
}
if errors.Is(err, errPersonaBindingUnsupported) {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话角色绑定需要支持 personaId 的 HistoryStore", HTTPCode: http.StatusNotImplemented})
return
}
s.log.Error(op, "err", err, "session", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to update session"))
}
+7 -4
View File
@@ -43,10 +43,13 @@ type sessionPersonaReader interface {
SessionPersona(ctx context.Context, sessionID string) (string, error)
}
// sessionPersonaWriter is the optional narrower view of HistoryStore used to rebind
// a session's role (PATCH /sessions/{id}, design/22 §4.5). *history.Store
// implements it; a store without it answers 501 on a persona write rather than
// pretending the binding landed.
// sessionPersonaWriter is the optional narrower view of HistoryStore for writing a
// session's role binding. A session's role is decided at creation (design/22 §8-1),
// so no request path rebinds one any more — creation goes through subSessionCreator —
// and the gateway no longer type-asserts this interface. It stays next to the reader
// and the creator so the store's role-binding surface is documented in one place:
// *history.Store still implements SetSessionPersona (design/22 §4.1) and the
// admin/owner write path that would use it is a separate endpoint, not a PATCH.
type sessionPersonaWriter interface {
SetSessionPersona(ctx context.Context, sessionID, personaID string) error
}