feat(persona,web): 人格读侧(thinking 三态 + 角色级 mood)+ 面板表单 + mood 播种
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

按 design/04 §3.2(thinking 语义,showToUser 三态)+ design/01 §2.1 + 主人两条定论(心情跟角色不跟会话;mood 初值走口径 ①:由角色读端点暴露、前端加载播种)。
- 管理侧 GET /admin/personas 的 PersonaSummary 增 thinking(budgetTokens/effort/mode/showToUser)——**showToUser 保持三态**(未配置 ≠ false)
- 用户侧 GET /personas 增**角色级 mood**(取自 emotion 层该角色当前状态,非从消息推断)→ 前端加载播种 currentMood,消除「刷新后心情徽标短暂为空」
- 前端 Personas 页加 thinking 四控件(预算/effort/模式/showToUser 三态):只做输入校验,**provider 是否真支持由后端 WARN 决定,前端不假装能力**
- 先拆 useAppStore 选择器(原 494/500)到独立模块再做,避免越线
- Docs/api-client 版本号 +1(两处契约 + 三态与角色级 mood 语义)
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + 前端 build 绿 + personaSession/messageSync 逻辑测试通过
This commit is contained in:
AskaEth committed 2026-10-03 20:40:44 +08:00
1 parent 6dc1f218d5
commit 2aa3233f84
20 files changed
+1042 -61

No files matched your search

+11 -5
View File
@@ -34,7 +34,7 @@
| 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 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 |
| 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:[persona...]}` |
| 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"`,非空文本省略),客户端据此提示「没识别到声音」) |
| 11 | `/api/v1/audio/speech` | POST | JWT(owner) | ✅ | **文本→音频**:body `{text,voice?,format?,instruction?}` → `200` 音频字节(`Content-Type: audio/<format>`,非 JSON envelope,走后端 TTS,百炼 `cosyvoice-v3-flash`,复刻音色可带 `instruction` 情绪指令;**静默降级空音频时仍 `500 tts_empty`,并附 `X-Voice-Provider: silent` 响应头**,客户端据此提示「没生成声音」) |
| 12 | `/api/v1/audio/voice-clone` | POST | JWT(owner) | ✅ | **声音复刻建音色**:body `{url:"<公网参考音频URL>",prefix?}` → `{data:{voiceId}}`(走百炼 `voice-enrollment` create_voice) |
@@ -110,7 +110,7 @@
- Web 端镜像实现在 `frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 对未知值返回原值),权威仍以本端点为准。
- **反漂移保证(后端)**:后端有测试断言「每个内置适配器的 `Name()` 都在 `platforms` 表内」「`moods` 与 persona 情绪状态机的内置状态一致」「`modes` 与前端 `MODES` 九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。
> **契约版本 `v0.42`**(多角色并存 + 按会话选角:**新增两个可选字段(会话/消息 `personaId`)+ 两个可选请求字段(`POST /sessions` body、`PATCH /sessions/{id}` 的 `personaId`)**,向后兼容)。
> **契约版本 `v0.43`**(人格读侧补齐:**`GET /admin/personas` 每项新增可选 `thinking` 对象 + `GET /personas` 每项新增 `mood`**,均为新增字段,向后兼容。`thinking.showToUser` **保持三态**(`null` = 未配置),`mood` 为**角色级**当前情绪)。上一版 `v0.42`:多角色并存 + 按会话选角。
## 2. REST 端点(管理,`/api/v1/admin/*`)
@@ -141,7 +141,7 @@
| # | 端点 | 方法 | 状态 | 说明 |
|---|------|------|:---:|------|
| 1 | `/api/v1/admin/personas` | GET | ✅ | 人格列表 |
| 1 | `/api/v1/admin/personas` | GET | ✅ | 人格列表(`viewer` 可读)→ `{data:[PersonaSummary...]}`,每项含用户侧全部字段,**另加 admin 专属 `thinking?`(v0.43)**:`{budgetTokens,effort,showToUser,mode}`——见下方「人格 `thinking` 读侧契约」 |
| 2 | `/api/v1/admin/sessions` | GET | ✅ | 持久化会话列表(主会话 + 次级会话,design/16 §2);未注入 HistoryStore 时回退渠道会话视图。**v0.42 起每项含可选 `personaId`**(与会话级选角同源,见 §1 行 4) |
| 3 | `/api/v1/admin/memories` | GET | ✅ | 记忆列表(手动添加/检索已实现;编辑/删除/衰减随 M10 扩展) |
| 4 | `/api/v1/admin/plugins` | GET | ✅ | 插件/能力列表(含 MCP/渠道) |
@@ -171,9 +171,14 @@
> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` · `GET /api/v1/admin/observability/logs` · `GET /api/v1/admin/notifications` 已实现,返回真实数据(**日志 tail 见下方「可观测性契约」**;未注册 `GET /api/v1/admin/logs`)。**写操作已补齐**(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载**真实持久化或真实生效**;**系统设置安全与存储类**(吊销 API key / 轮换 JWT 密钥 / 备份 / 恢复)也已**真实实现**(见下方「系统设置契约」);仍为后置 M10:**新增用户**、**IoT 场景与规则**、**主人档案保存**、**编辑环境变量/重启服务/检查更新**——后端如实返回 `501 not_implemented`,不伪造成功。(**知识库删除已实现**:`POST /admin/rag/delete` 真实删除,见下方写操作契约;**插件安装已实现**:v1 为 MCP server 热安装。)
**管理面板写操作契约**(`POST/PUT/DELETE /api/v1/admin/*`,均 **owner-only**,读为 owner/admin;写操作经 audit 落 `audit_logs`;成功 `{data}` / 失败 `{error:{code,message}}`):
- `POST /admin/personas` body `{id,name,role,systemPrompt?,characterRef?,modes?/mode?,memoryNamespace?,thinking?}` → `201 {data:{id,name,character,isDefault,mode,memoryNamespace}}`(注册进 persona registry + 持久化到 `configs/personas/<id>.yaml`);id 已存在回 `409 duplicate`。
- `PUT /admin/personas/{id}` body 同上(不含 id)→ `200 {data:PersonaSummary}`;不存在回 `404`。
- `POST /admin/personas` body `{id,name,role,systemPrompt?,characterRef?,modes?/mode?,memoryNamespace?,thinking?}` → `201 {data:PersonaSummary}`(**v0.43 起与读端点同形状:含 `mood` + `thinking?`**;注册进 persona registry + 持久化到 `configs/personas/<id>.yaml`);id 已存在回 `409 duplicate`。
- `PUT /admin/personas/{id}` body 同上(不含 id)→ `200 {data:PersonaSummary}`(**v0.43 起与读端点同形状:含 `mood` + `thinking?`**,面板可直接用它回填);不存在回 `404`。
- `thinking`(可选,design/01 §2.1)`{budgetTokens?,effort?,showToUser?,mode?}`:写入 YAML 的 `thinking` 节,驱动该人格的思考预算/开关/呈现。`mode` ∈ `default|on|off`(缺省 `default`),非法 `mode` 或负 `budgetTokens` 回 **`400 bad_request`**(`invalid thinking config: …`)。**`showToUser` 是 bool 且可缺省**:**缺省 = 保持现行为(thinking 帧照常推客户端)**;显式 `false` = 只落库不回显。**`PUT` 省略整个 `thinking` 对象时保留已存的 `thinking` 节**(不因客户端不认识该字段而抹掉);显式传对象则整体替换(字段全缺省即清空)。该字段**不构成能力边界**。
- **人格 `thinking` 读侧契约(v0.43,回读形状)**:`GET /admin/personas` 每项按**与写侧同构**的形状回读 `thinking`,供面板回填:
- **未配置的人格:`thinking` 键完全不出现**(不是全零对象)——与写侧「省略 `thinking` 对象 = 保留已存」互为镜像,客户端可有样学样地「原样回填 / 原样省略」。
- 已配置时 **四个键都出现**:`budgetTokens`(number,未设为 `0`)、`effort`(string,未设为 `""`)、`showToUser`、`mode`(string,未设为 `""`)。
- **`showToUser` 是三态、且读侧如实三态**:**`null` = 未配置(保持现行为:thinking 帧照常推客户端)**;`true`/`false` = 显式选择(`false` = 只落库不回显)。**服务端不会把 `null` 拍平成 `false`**(写侧用的是 `*bool`,读侧同样保留指针语义),否则「没配过」会被面板误显示成「关」。
- **能力提示由服务端负责,客户端不要假装**:`effort` 的**有效白名单随当前生效 provider** 而定(OpenAI 兼容系 `none/minimal/low/medium/high/xhigh/max`;DeepSeek 仅 `none/low/high/max`,别名 `minimal→low`/`medium→high`/`xhigh→high`/`ultra→max`;未知 provider 不支持)。**服务端在模型层按 provider 校验,非法/不被支持时降级并记 WARN**(design/04 §3.2);读端点只回读**已存配置**,**不承诺**该值在上游真的生效。面板可给出提示与前端枚举校验,但**不得**把「配了 effort」呈现为「上游已启用」。
- `POST /admin/users` → `501 not_implemented`(**无用户表**:登录仅用 `.env` 单 admin `ADMIN_USERNAME`/`ADMIN_PASSWORD`,角色为 `owner>admin>viewer` 常量;多用户存储 + 登录接入需先设计——design/11 单用户为主。不做「建了却登不进」的假用户)。
- `POST /admin/users/apikey` body `{role:"owner|admin|viewer"}` → `201 {data:{id:<掩码>, key:<明文·仅此一次>, role}}`。`key` 仅此响应出现一次。
- `DELETE /admin/users/apikey/{id}`(`id`=列表掩码)→ `200 {data:{id,revoked:true}}`;未命中 `404`。
@@ -354,6 +359,7 @@
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
## 5. 变更记录
- `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.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「管理侧权限矩阵」。
+12 -3
View File
@@ -16,6 +16,7 @@ import {
} from "./lib/api";
import type { SessionMeta } from "./lib/types";
import { useAppStore } from "./store/useAppStore";
import { personaMoodSeed } from "./store/personaSelectors";
export default function App(): React.ReactNode {
// Apply the persisted (dark-first) theme and open the realtime channel only
@@ -23,6 +24,7 @@ export default function App(): React.ReactNode {
const [route, setRoute] = useState(() => parseAdminHash(window.location.hash));
const [menuOpen, setMenuOpen] = useState(false);
const setPersonas = useAppStore((s) => s.setPersonas);
const seedMoods = useAppStore((s) => s.seedMoods);
useEffect(() => {
initTheme();
@@ -37,12 +39,18 @@ export default function App(): React.ReactNode {
// Load the role catalog (design/22 §4.6). The rendered role is no longer one
// global value: it is derived per active session from this catalog plus that
// session's own `personaId` (see useAppStore's selectCurrentPersona), so this
// fetch only has to fill the catalog. It must not hinge on a single mount-time
// session's own `personaId` (see store/personaSelectors), so this fetch only has
// to fill the catalog. It must not hinge on a single mount-time
// request: a token may appear (login) after mount, or the fetch may transiently
// fail — in which case the avatar/name would stay on the neutral placeholder. We
// re-run when the token appears, and retry on failure. The catalog is persisted
// by useAppStore, so a refresh renders the right role before the fetch returns.
//
// The catalog also carries each role's *role-level* mood (design/01 §2: emotion
// follows the role), which seeds the mood bucket of every role it names — that is
// what stops the 心情 badge from flashing empty after a refresh. Seeding merges
// (see `seedMoods`): a role the backend reports no mood for is skipped, so a WS
// `mood` frame that landed first is never cleared.
const token = getAuthToken();
useEffect(() => {
if (!token) return;
@@ -55,6 +63,7 @@ export default function App(): React.ReactNode {
.then((list) => {
if (cancelled) return;
setPersonas(list);
seedMoods(personaMoodSeed(list));
})
.catch(() => {
// Transient failure — retry rather than leaving the placeholder forever.
@@ -67,7 +76,7 @@ export default function App(): React.ReactNode {
cancelled = true;
if (retry !== null) window.clearTimeout(retry);
};
}, [token, setPersonas]);
}, [token, setPersonas, seedMoods]);
const sessions = useAppStore((s) => s.sessions);
const activeSessionId = useAppStore((s) => s.activeSessionId);
+63 -17
View File
@@ -1,11 +1,19 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListPersonas, adminCreatePersona, adminUpdatePersona } from "../../lib/api";
import type { PersonaRecord } from "../../lib/types";
import type { PersonaRecord, PersonaUpsertRequest } from "../../lib/types";
import {
EMPTY_THINKING_DRAFT,
thinkingDraftFrom,
thinkingDraftToRequest,
thinkingValidationError,
type ThinkingDraft,
} from "../../lib/personaThinking";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
import { ThinkingFields } from "./personaThinkingForm";
const columns: AdminColumn<PersonaRecord>[] = [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
@@ -26,9 +34,18 @@ interface Draft {
systemPrompt: string;
mode: string;
memoryNamespace: string;
thinking: ThinkingDraft;
}
const emptyDraft: Draft = { id: "", name: "", role: "", systemPrompt: "", mode: "", memoryNamespace: "" };
const emptyDraft: Draft = {
id: "",
name: "",
role: "",
systemPrompt: "",
mode: "",
memoryNamespace: "",
thinking: EMPTY_THINKING_DRAFT,
};
export function PersonasModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListPersonas, []);
@@ -54,19 +71,38 @@ export function PersonasModule(): React.ReactNode {
systemPrompt: "",
mode: p.mode || "",
memoryNamespace: p.memoryNamespace || "",
// Backfill from the read side. An absent block yields the empty draft, which
// maps back to "omit `thinking`" — so opening a persona that configures no
// thinking and saving it leaves the stored block untouched.
thinking: thinkingDraftFrom(p.thinking),
});
const create = () => {
setBusy(true);
setFail(null);
adminCreatePersona({
id: draft.id,
// Only what the client can know: non-negative integer budget, enumerations.
// Whether the active provider honors `effort` is the backend's call (it
// validates against the provider whitelist and WARNs), so the panel does not
// pretend to gate on it.
const thinkingError = thinkingValidationError(draft.thinking);
/** Body shared by create/update. `thinking` is included only when the form
* configures something — an omitted object keeps whatever is stored, so a
* panel that ships zeros would never wipe a hand-edited block. */
const payload = (): PersonaUpsertRequest => {
const req: PersonaUpsertRequest = {
name: draft.name,
role: draft.role,
systemPrompt: draft.systemPrompt,
mode: draft.mode,
memoryNamespace: draft.memoryNamespace,
})
};
const thinking = thinkingDraftToRequest(draft.thinking);
if (thinking) req.thinking = thinking;
return req;
};
const create = () => {
setBusy(true);
setFail(null);
adminCreatePersona({ id: draft.id, ...payload() })
.then((p) => {
log("persona.create", "ok");
setCreating(false);
@@ -85,13 +121,7 @@ export function PersonasModule(): React.ReactNode {
if (!selected) return;
setBusy(true);
setFail(null);
adminUpdatePersona(selected.id, {
name: draft.name,
role: draft.role,
systemPrompt: draft.systemPrompt,
mode: draft.mode,
memoryNamespace: draft.memoryNamespace,
})
adminUpdatePersona(selected.id, payload())
.then((p) => {
log("persona.edit", "ok");
setSelected(p);
@@ -154,8 +184,16 @@ export function PersonasModule(): React.ReactNode {
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
</div>
<ThinkingFields
value={draft.thinking}
onChange={(thinking) => setDraft({ ...draft, thinking })}
/>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy || !draft.id || !draft.name || !draft.role} onClick={create}>
<Button
variant="primary"
disabled={busy || !draft.id || !draft.name || !draft.role || Boolean(thinkingError)}
onClick={create}
>
{busy ? "创建中…" : "创建"}
</Button>
<Button variant="ghost" disabled={busy} onClick={() => setCreating(false)}>
@@ -186,8 +224,16 @@ export function PersonasModule(): React.ReactNode {
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
</div>
<ThinkingFields
value={draft.thinking}
onChange={(thinking) => setDraft({ ...draft, thinking })}
/>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy || !draft.name || !draft.role} onClick={save}>
<Button
variant="primary"
disabled={busy || !draft.name || !draft.role || Boolean(thinkingError)}
onClick={save}
>
{busy ? "保存中…" : "保存"}
</Button>
<Button variant="ghost" disabled={busy} onClick={() => setSelected(null)}>
@@ -0,0 +1,115 @@
import type { ThinkingDraft } from "../../lib/personaThinking";
import {
THINKING_EFFORTS,
THINKING_MODES,
thinkingValidationError,
} from "../../lib/personaThinking";
import { Tag } from "../../components/primitives";
/** The panel's four `thinking` controls (design/01 §2.1). Split out of Personas.tsx
* (which is near its size ceiling) and kept dumb: it renders + validates the form
* draft, the request mapping lives in lib/personaThinking.ts.
*
* It deliberately does NOT claim a capability: `effort` is offered from the union
* of the values the providers behind the API recognize, but whether the *active*
* provider honors the value is decided by the backend (it validates against the
* provider whitelist and WARNs when it drops one). The hint says so instead of
* pretending the knob is on. */
const inputCls =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
export function ThinkingFields({
value,
onChange,
}: {
value: ThinkingDraft;
onChange: (next: ThinkingDraft) => void;
}): React.ReactNode {
const set = <K extends keyof ThinkingDraft>(key: K, v: ThinkingDraft[K]) =>
onChange({ ...value, [key]: v });
const err = thinkingValidationError(value);
return (
<div className="space-y-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-3)]/40 px-3 py-3">
<div className="flex flex-wrap items-baseline gap-2">
<span className="text-sm font-semibold">思考配置 thinking</span>
<span className="text-xs text-[var(--mu-text-lo)]">
只描述呈现/资源,不构成能力边界(design/01 §2.1);留空 = 不配置
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">预算 budgetTokens(非负整数,留空 = 不配置)</span>
<input
value={value.budgetTokens}
inputMode="numeric"
placeholder="4096"
onChange={(e) => set("budgetTokens", e.target.value)}
className={inputCls}
/>
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">强度 effort(可留空)</span>
<select
value={value.effort}
onChange={(e) => set("effort", e.target.value)}
className={inputCls}
>
<option value="">(不配置)</option>
{THINKING_EFFORTS.map((e) => (
<option key={e} value={e}>
{e}
</option>
))}
{/* A stored value outside the list is shown as-is rather than being
* silently displayed as "unconfigured" (Docs/api-client.md §1.1: an
* unknown value is never dropped or replaced by a placeholder). The
* validation error then asks the user to pick a recognized one. */}
{value.effort && !(THINKING_EFFORTS as readonly string[]).includes(value.effort) ? (
<option value={value.effort}>{value.effort}(未知取值,后端会 WARN 并忽略)</option>
) : null}
</select>
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">
回显 showToUser(未配置 = 保持现行为:思考帧照常推给客户端)
</span>
<select
value={value.showToUser}
onChange={(e) => set("showToUser", e.target.value as ThinkingDraft["showToUser"])}
className={inputCls}
>
<option value="unset">未配置(保持现行为)</option>
<option value="on">开(推帧)</option>
<option value="off">关(只落库不回显)</option>
</select>
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">开关 mode(硬开关,推理)</span>
<select
value={value.mode}
onChange={(e) => set("mode", e.target.value as ThinkingDraft["mode"])}
className={inputCls}
>
<option value="">(未配置)</option>
{THINKING_MODES.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</label>
</div>
<p className="text-xs text-[var(--mu-text-lo)]">
effort 的有效取值随当前生效 provider 而定(OpenAI 兼容系
none/minimal/low/medium/high/xhigh/max;DeepSeek none/low/high/max,别名
minimal→low、medium→high、xhigh→high、ultra→max;未知 provider 不支持)。面板只做前端枚举校验;
该值在上游是否真生效,由后端按 provider 校验并记 WARN 决定,此处不代为承诺。
</p>
{err ? (
<Tag tone="err">{err}</Tag>
) : null}
</div>
);
}
+2 -1
View File
@@ -3,7 +3,8 @@ import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { getAuthToken } from "../lib/api";
import { labelFor } from "../lib/enums";
import { personaAvatar } from "../lib/persona";
import { useAppStore, useCurrentMood, useCurrentPersona } from "../store/useAppStore";
import { useAppStore } from "../store/useAppStore";
import { useCurrentMood, useCurrentPersona } from "../store/personaSelectors";
import { Icon } from "./Icon";
import { NavItem } from "./NavItem";
import { SessionRail } from "./SessionRail";
+2 -1
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useAppStore, useCurrentMood, useCurrentPersona } from "../store/useAppStore";
import { useAppStore } from "../store/useAppStore";
import { useCurrentMood, useCurrentPersona } from "../store/personaSelectors";
import { useModeStore } from "../store/useModeStore";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { personaAvatar } from "../lib/persona";
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import type { ChatMessage, Mode, PersonaRecord, ToolUsed } from "../lib/types";
import { useAppStore, useCurrentPersona } from "../store/useAppStore";
import { useAppStore } from "../store/useAppStore";
import { useCurrentPersona } from "../store/personaSelectors";
import { personaAvatar, resolveMessagePersona } from "../lib/persona";
import { labelFor } from "../lib/enums";
import { Tag } from "./primitives";
@@ -0,0 +1,165 @@
// Logic test for the panel's `thinking` form ↔ request mapping (design/01 §2.1,
// v0.43 read side). The two rules it must not lose are the tri-state of
// `showToUser` (omitted = 未配置 = keep the current behavior; never flattened into
// `false`) and the omission rule: a form that configures nothing must leave the
// `thinking` key OUT of the body, because the write side reads an omitted object
// as "keep the stored block" (an all-default object would CLEAR it). The project
// has no frontend test runner and no jsdom, so this is a dependency-free
// plain-node check driving the real module (lib/personaThinking.ts) and throwing
// on the first failure.
//
// Run it with the esbuild that already ships with vite (no new dependency, no
// package.json change):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/lib/personaThinking.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-persona-thinking.test.mjs \
// && node /tmp/cyrene-persona-thinking.test.mjs
//
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
// imports nothing from node:*.
import {
EMPTY_THINKING_DRAFT,
THINKING_EFFORTS,
thinkingDraftFrom,
thinkingDraftToRequest,
thinkingValidationError,
type PersonaThinking,
type ThinkingDraft,
} from "./personaThinking";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same(got: unknown, want: unknown, what: string): void {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
ok(g === w, `${what} — got ${g}, want ${w}`);
}
/* --- 1. Backfill from the read side ----------------------------------------- */
// An absent block (persona configures no thinking) reads as the empty draft.
same(thinkingDraftFrom(undefined), EMPTY_THINKING_DRAFT, "absent read block → empty draft");
same(thinkingDraftFrom(null), EMPTY_THINKING_DRAFT, "null read block → empty draft");
// A configured block backfills field by field, including the tri-state arm.
const read: PersonaThinking = { budgetTokens: 4096, effort: "high", showToUser: false, mode: "off" };
same(
thinkingDraftFrom(read),
{ budgetTokens: "4096", effort: "high", showToUser: "off", mode: "off" },
"configured block backfills (explicit false stays 关)"
);
same(
thinkingDraftFrom({ budgetTokens: 0, effort: "", showToUser: null, mode: "" }),
EMPTY_THINKING_DRAFT,
"an all-unset read block backfills to the empty draft"
);
same(
thinkingDraftFrom({ budgetTokens: 1024, effort: "", showToUser: null, mode: "" }),
{ budgetTokens: "1024", effort: "", showToUser: "unset", mode: "" },
"null showToUser backfills as 未配置, not 关"
);
// An out-of-list stored value is preserved (never silently blanked).
same(
thinkingDraftFrom({ budgetTokens: 0, effort: "tiny", showToUser: null, mode: "always" }).effort,
"tiny",
"an unknown stored effort is preserved for display"
);
/* --- 2. Form → request body, incl. tri-state and omission -------------------- */
// The omission rule: nothing configured → no `thinking` key at all on the wire.
const untouched = thinkingDraftToRequest(EMPTY_THINKING_DRAFT);
ok(untouched === undefined, "an untouched form maps to undefined (omit the block)");
same(
JSON.stringify({ name: "昔涟", thinking: untouched }),
JSON.stringify({ name: "昔涟" }),
"an untouched form leaves no `thinking` key in the request body (省略保留)"
);
// …and a whitespace-only budget is still "untouched", not budget 0.
ok(
thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, budgetTokens: " " }) === undefined,
"a whitespace-only budget is treated as unconfigured"
);
// The tri-state of showToUser, verbatim on the wire.
const unset = thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, budgetTokens: "2048" });
ok(unset !== undefined, "a budget-only form still maps to a request");
ok(!("showToUser" in (unset as object)), "showToUser:未配置 omits the key (stays nil, not false)");
same(unset?.showToUser, undefined, "showToUser:未配置 is undefined, never false");
const off = thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, showToUser: "off" });
same(off, { showToUser: false }, "showToUser:关 maps to the boolean false");
same(JSON.stringify(off), JSON.stringify({ showToUser: false }), "an explicit false survives JSON.stringify");
const on = thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, showToUser: "on" });
same(on, { showToUser: true }, "showToUser:开 maps to the boolean true");
// Every other knob maps only when set.
same(
thinkingDraftToRequest({ budgetTokens: "4096", effort: "medium", showToUser: "unset", mode: "on" }),
{ budgetTokens: 4096, effort: "medium", mode: "on" },
"set knobs map, unset ones are omitted"
);
same(
thinkingDraftToRequest({ budgetTokens: "0", effort: "", showToUser: "unset", mode: "" }),
{ budgetTokens: 0 },
"an explicit 0 budget is a configured value (0 = 未设 for the backend)"
);
/* --- 3. Round-trip: read → form → request ---------------------------------- */
// What the panel sends back for a persona it just loaded must describe the same
// stored block, so opening a persona and saving it changes nothing.
const cases: PersonaThinking[] = [
{ budgetTokens: 4096, effort: "high", showToUser: false, mode: "off" },
{ budgetTokens: 2048, effort: "", showToUser: null, mode: "" },
{ budgetTokens: 0, effort: "", showToUser: true, mode: "on" },
{ budgetTokens: 0, effort: "low", showToUser: null, mode: "default" },
];
for (const c of cases) {
const back = thinkingDraftToRequest(thinkingDraftFrom(c));
// Built in the mapper's own key order (JSON.stringify is order-sensitive here).
const want: Record<string, unknown> = {};
if (c.budgetTokens) want.budgetTokens = c.budgetTokens;
if (c.effort) want.effort = c.effort;
if (c.mode) want.mode = c.mode;
if (c.showToUser !== null) want.showToUser = c.showToUser;
same(back, want, `round-trip ${JSON.stringify(c)}`);
}
/* --- 4. Input validation (only what the client can know) -------------------- */
same(thinkingValidationError(EMPTY_THINKING_DRAFT), null, "empty draft is valid");
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "4096" }), null, "4096 is valid");
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "0" }), null, "0 is valid (non-negative)");
ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "-1" }) !== null, "a negative budget is rejected");
ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "1.5" }) !== null, "a fractional budget is rejected");
ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "4096t" }) !== null, "a non-numeric budget is rejected");
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, mode: "on" }), null, "mode on is valid");
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, mode: "default" }), null, "mode default is valid");
ok(
thinkingValidationError({ ...EMPTY_THINKING_DRAFT, mode: "always" as ThinkingDraft["mode"] }) !== null,
"an unknown mode is rejected"
);
for (const e of THINKING_EFFORTS) {
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, effort: e }), null, `effort ${e} is valid`);
}
ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, effort: "tiny" }) !== null, "an unknown effort is rejected");
// The panel must not claim a capability: no error is raised merely because the
// value may be unsupported by the *active* provider (the backend WARNs instead).
same(
thinkingValidationError({ ...EMPTY_THINKING_DRAFT, effort: "none", mode: "off" }),
null,
"a provider-dependent combination is not blocked client-side"
);
console.log(`personaThinking: ${checks} checks passed`);
+135
View File
@@ -0,0 +1,135 @@
/** Persona `thinking` config — the wire shapes and the panel's form mapping
* (design/01 §2.1; provider mapping in design/04 §3.2).
*
* Kept out of lib/types.ts (which is at its size ceiling) and, more importantly,
* out of the panel component: the mapping is pure, so the tri-state rules are
* testable without a browser.
*
* Two rules the read/write sides share:
* - **`showToUser` is tri-state all the way.** `null`/absent = 未配置 = keep the
* current behavior (thinking frames keep flowing); `true`/`false` = an explicit
* choice. Flattening "unset" onto `false` would silently turn display off.
* - **An all-unset block is omitted, not sent as zeros.** The write side reads an
* omitted `thinking` object as "keep the stored one", so a panel that ships
* defaults would wipe a hand-edited config.
*/
/** Read shape of the `thinking` block (`GET /api/v1/admin/personas`, v0.43). The
* key is absent entirely when the persona configures none. */
export interface PersonaThinking {
budgetTokens: number;
effort: string;
/** `null` = 未配置(保持现行为), `true`/`false` = 显式选择. */
showToUser: boolean | null;
mode: string;
}
/** Write shape (`POST`/`PUT /api/v1/admin/personas`). An omitted key = unset; an
* omitted whole object = keep the stored block. */
export interface PersonaThinkingRequest {
budgetTokens?: number;
effort?: string;
showToUser?: boolean;
mode?: string;
}
/** Explicit reasoning switch, matching the backend's `ThinkingMode` constants
* (`internal/persona/loader`). */
export const THINKING_MODES = ["default", "on", "off"] as const;
/** `reasoning_effort` values the model layer recognizes across the providers it
* maps (design/04 §3.2). The **effective whitelist still depends on the active
* provider** — OpenAI-compatible takes `none…max`; DeepSeek takes
* `none/low/high/max` and normalizes the aliases (`minimal→low`, `medium→high`,
* `xhigh→high`, `ultra→max`) — and only the backend can see which provider is in
* play, so this list drives a select, not a promise. `ultra` is listed because
* DeepSeek accepts it as an alias; on an OpenAI-compatible provider the backend
* drops it and WARNs. */
export const THINKING_EFFORTS = [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
"ultra",
] as const;
/** Form state: everything is a string so a half-typed number never coerces to 0
* and the tri-state select has an explicit "unset" arm. */
export interface ThinkingDraft {
budgetTokens: string;
effort: string;
showToUser: "unset" | "on" | "off";
mode: "" | (typeof THINKING_MODES)[number];
}
export const EMPTY_THINKING_DRAFT: ThinkingDraft = {
budgetTokens: "",
effort: "",
showToUser: "unset",
mode: "",
};
/** Backfill the form from the read side. An absent block (and an unknown mode)
* yields the empty draft, which maps back to "omitted" — so opening a persona
* that configures no thinking and saving it changes nothing. */
export function thinkingDraftFrom(read: PersonaThinking | null | undefined): ThinkingDraft {
if (!read) return EMPTY_THINKING_DRAFT;
return {
budgetTokens: read.budgetTokens ? String(read.budgetTokens) : "",
effort: read.effort ?? "",
showToUser: read.showToUser === true ? "on" : read.showToUser === false ? "off" : "unset",
mode: (THINKING_MODES as readonly string[]).includes(read.mode)
? (read.mode as ThinkingDraft["mode"])
: "",
};
}
/** Input validation only — what the client can actually know. Non-negative
* integer budget, enumerations for mode/effort. Whether the provider really
* honors the value is the backend's call (it validates against the active
* provider's whitelist and WARNs when it drops one); the panel does not pretend
* to know that, so it does not block on it. Returns an error message, or null. */
export function thinkingValidationError(draft: ThinkingDraft): string | null {
const budget = draft.budgetTokens.trim();
if (budget !== "") {
if (!/^\d+$/.test(budget)) return "思考预算必须是非负整数";
if (Number(budget) > Number.MAX_SAFE_INTEGER) return "思考预算过大";
}
if (draft.mode !== "" && !(THINKING_MODES as readonly string[]).includes(draft.mode)) {
return `思考开关取值非法:${draft.mode}(仅 default/on/off)`;
}
if (draft.effort !== "" && !(THINKING_EFFORTS as readonly string[]).includes(draft.effort)) {
return `思考强度取值非法:${draft.effort}`;
}
return null;
}
/** Whether the form holds anything at all. An untouched form must not be sent:
* the write side treats an omitted `thinking` object as "keep the stored block". */
export function thinkingDraftIsEmpty(draft: ThinkingDraft): boolean {
return (
draft.budgetTokens.trim() === "" &&
draft.effort === "" &&
draft.mode === "" &&
draft.showToUser === "unset"
);
}
/** Map the form onto the request body. Returns `undefined` when nothing is
* configured — the caller then omits `thinking` entirely, preserving whatever is
* stored (write-side semantics, unchanged). `showToUser: "unset"` omits the key
* so it stays `nil` in the backend, never a flattened `false`. Callers validate
* with `thinkingValidationError` first. */
export function thinkingDraftToRequest(draft: ThinkingDraft): PersonaThinkingRequest | undefined {
if (thinkingDraftIsEmpty(draft)) return undefined;
const req: PersonaThinkingRequest = {};
const budget = draft.budgetTokens.trim();
if (budget !== "") req.budgetTokens = Number(budget);
if (draft.effort !== "") req.effort = draft.effort;
if (draft.mode !== "") req.mode = draft.mode;
if (draft.showToUser !== "unset") req.showToUser = draft.showToUser === "on";
return req;
}
+15
View File
@@ -1,6 +1,8 @@
// Shared wire & UI model types for the Cyrene v2 user frontend (M9).
// Strict mode: no `any`; unknown payloads are narrowed at the parse boundary.
import type { PersonaThinking, PersonaThinkingRequest } from "./personaThinking";
/** The 9 behavior modes. Switching only changes the presentation layer. */
export type Mode =
| "chat"
@@ -218,6 +220,14 @@ export interface PersonaRecord {
avatar?: string;
/** Per-orientation chat wallpaper; a role's own, never another's. Omitted when unset. */
chatBackground?: { landscape?: string; portrait?: string };
/** The role's current mood (v0.43, role-level: design/01 §2 — emotion follows
* the role, never a session, so every session of a role shows the same value).
* `""`/absent = the role has no tracked emotion; the UI keeps its neutral
* placeholder rather than inventing one. Used to seed the mood badge on load. */
mood?: string;
/** The optional `thinking` block (v0.43). Admin list (`GET /admin/personas`)
* only; absent when the persona configures none. */
thinking?: PersonaThinking;
}
export interface LoginResult {
@@ -557,6 +567,11 @@ export interface PersonaUpsertRequest {
modes?: string[];
mode?: string;
memoryNamespace?: string;
/** Optional reasoning/presentation block (design/01 §2.1). **Omit the key when
* the form configures nothing**: an omitted object keeps the stored block,
* while an all-default object clears it. `showToUser` stays tri-state — omit it
* (= not configured) rather than sending `false`. */
thinking?: PersonaThinkingRequest;
}
/** POST /api/v1/admin/users/apikey — the plaintext key is returned exactly once. */
+2 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useAppStore, useCurrentPersona } from "../store/useAppStore";
import { useAppStore } from "../store/useAppStore";
import { useCurrentPersona } from "../store/personaSelectors";
import { useModeStore } from "../store/useModeStore";
import { useSendChat } from "../lib/useChat";
import { sendUp } from "../lib/ws";
@@ -0,0 +1,143 @@
// Logic test for seeding the mood badge from the role catalog (v0.43 read side,
// design/01 §2): `GET /personas` reports each role's *role-level* mood, and the
// frontend seeds `moods[roleId]` from it on load so a refresh no longer flashes an
// empty 心情 badge. The project has no frontend test runner and no jsdom, so this
// is a dependency-free plain-node check driving the real seed helper
// (store/personaSelectors.ts), the real store and the real WS frame router
// (lib/ws.ts), and throwing 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
// persist middleware probes, which is simply absent under node:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/store/personaMoodSeed.test.ts \
// --bundle --platform=node --format=esm \
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
// --outfile=/tmp/cyrene-persona-mood-seed.test.mjs \
// && node /tmp/cyrene-persona-mood-seed.test.mjs
//
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
// imports nothing from node:*.
import { dispatchFrame } from "../lib/ws";
import type { PersonaRecord, SessionMeta } from "../lib/types";
import { personaMoodSeed, selectCurrentMood } from "./personaSelectors";
import { useAppStore } from "./useAppStore";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same(got: unknown, want: unknown, what: string): void {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
ok(g === w, `${what} — got ${g}, want ${w}`);
}
const MAIN = "web";
const SUB_A = "web:sub-a";
const SUB_B = "web:sub-b";
/** A catalog shaped like `GET /api/v1/personas` v0.43: 昔涟 has a tracked mood,
* 雪 has none (the backend reports ""), and the third role carries no mood field
* at all (an older backend). */
const CATALOG: PersonaRecord[] = [
{ id: "cyrene", name: "昔涟", character: "cyrene", isDefault: true, mode: "chat", memoryNamespace: "persona.cyrene", mood: "happy" },
{ id: "yuki", name: "雪", character: "yuki", isDefault: false, mode: "chat", memoryNamespace: "persona.yuki", mood: "" },
{ id: "old", name: "旧端点", character: "old", isDefault: false, mode: "chat", memoryNamespace: "persona.old" },
];
/** Main session → default role (昔涟); sub-a → 雪; sub-b → default again. */
function sessions(): Record<string, SessionMeta> {
return {
[MAIN]: { id: MAIN, type: "main", createdAt: 1 },
[SUB_A]: { id: SUB_A, type: "sub", createdAt: 2, personaId: "yuki" },
[SUB_B]: { id: SUB_B, type: "sub", createdAt: 3, personaId: "" },
};
}
/** 1. The pure seed: role id → role-level mood, and nothing for a role the
* backend reported no mood for. */
function testSeedShape(): void {
same(personaMoodSeed(CATALOG), { cyrene: "happy" }, "only roles with a mood are seeded");
same(personaMoodSeed([]), {}, "an empty catalog seeds nothing");
same(personaMoodSeed([{ ...CATALOG[0], mood: "" }]), {}, "an empty mood seeds nothing (never a placeholder)");
same(personaMoodSeed([{ ...CATALOG[0], id: "" , mood: "happy" }]), {}, "a role without an id seeds nothing");
// Seeding is keyed by role id, so it can never key onto a session.
ok(!Object.keys(personaMoodSeed(CATALOG)).includes(MAIN), "no session id ever becomes a mood bucket");
}
/** 2. The refresh scenario this batch exists for: after loading the catalog the
* active role's mood is already known — no blank badge while the next WS frame
* is awaited. */
function testSeedFillsBadgeOnLoad(): void {
useAppStore.setState({ moods: {}, personas: [], sessions: {}, activeSessionId: "" });
same(selectCurrentMood(useAppStore.getState()), null, "before the catalog loads there is no mood");
useAppStore.setState({ personas: CATALOG, sessions: sessions(), activeSessionId: MAIN });
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
same(useAppStore.getState().moods, { cyrene: "happy" }, "the catalog's role mood lands in its own bucket");
same(selectCurrentMood(useAppStore.getState()), "happy", "the badge is filled on load (main session → 昔涟)");
}
/** 3. The mood is role-level: two sessions of the same role show the same mood,
* and switching to another role's session shows that role's own value. */
function testMoodIsRoleLevel(): void {
useAppStore.setState({
moods: {},
personas: CATALOG,
sessions: sessions(),
activeSessionId: MAIN,
});
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
same(selectCurrentMood(useAppStore.getState()), "happy", "main session (default role 昔涟) shows happy");
useAppStore.setState({ activeSessionId: SUB_B });
same(selectCurrentMood(useAppStore.getState()), "happy", "another session of the same role shows the SAME mood");
useAppStore.setState({ activeSessionId: SUB_A });
same(selectCurrentMood(useAppStore.getState()), null, "a role with no tracked mood shows nothing (no invented placeholder)");
// A live frame for the unseeded role fills it, and the role-scoped keying keeps
// the two roles apart.
dispatchFrame({ type: "mood", data: { mood: "sad", personaId: "yuki" } });
same(selectCurrentMood(useAppStore.getState()), "sad", "a mood frame updates the active role's badge");
useAppStore.setState({ activeSessionId: MAIN });
same(selectCurrentMood(useAppStore.getState()), "happy", "the other role's badge is untouched");
}
/** 4. Seeding merges: it overwrites with the server's truth but never clears a
* bucket, so a WS frame that arrived before the catalog loaded survives. */
function testSeedMergesAndNeverClears(): void {
useAppStore.setState({ moods: { yuki: "excited" }, personas: [], sessions: {}, activeSessionId: MAIN });
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
same(useAppStore.getState().moods, { yuki: "excited", cyrene: "happy" }, "an unmentioned bucket is kept, the seeded one added");
// The server is the truth for a role it does report a mood for.
useAppStore.setState({ moods: { cyrene: "angry" } });
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
same(useAppStore.getState().moods.cyrene, "happy", "a reported role mood overwrites the stale local value");
// "" / absent (an untracked role) must not wipe a live bucket.
useAppStore.setState({ moods: { yuki: "excited" } });
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
same(useAppStore.getState().moods.yuki, "excited", "a role the seed skips keeps its bucket");
// An empty seed is a no-op (no needless state churn).
const before = useAppStore.getState().moods;
useAppStore.getState().seedMoods({});
ok(useAppStore.getState().moods === before, "an empty seed leaves the state object untouched");
}
function run(): void {
testSeedShape();
testSeedFillsBadgeOnLoad();
testMoodIsRoleLevel();
testSeedMergesAndNeverClears();
console.log(`personaMoodSeed.test: ${checks} checks passed`);
}
run();
@@ -0,0 +1,50 @@
import { resolveSessionPersona } from "../lib/persona";
import type { PersonaRecord } from "../lib/types";
import { useAppStore, type AppStore } from "./useAppStore";
/** The role the UI renders (design/22 §4.6): the active session's bound role,
* else the global default. THE single resolution point — the avatar, name,
* wallpaper, mood bucket and message labels all read it, so none of them can
* disagree about "who is speaking" (design/22 §5 risk row 1).
*
* Lives in its own module (not in useAppStore.ts, which is at its size ceiling)
* so the store file keeps holding state/actions only. */
export function selectCurrentPersona(s: AppStore): PersonaRecord | null {
return resolveSessionPersona(s.activeSessionId, s.sessions, s.personas);
}
/** The active role's mood (design/22 §4.6). `mood` frames are role-scoped and
* carry no sessionId, so a role's mood is only shown above its own sessions —
* the bucket is keyed by the *resolved* role id, never a global value. */
export function selectCurrentMood(s: AppStore): string | null {
const personaId = selectCurrentPersona(s)?.id ?? "";
return personaId ? s.moods[personaId] ?? null : null;
}
/** Subscribe to the active session's role. */
export function useCurrentPersona(): PersonaRecord | null {
return useAppStore(selectCurrentPersona);
}
/** Subscribe to the active role's mood. */
export function useCurrentMood(): string | null {
return useAppStore(selectCurrentMood);
}
/** Mood buckets to seed from the role catalog (`GET /api/v1/personas`): the role
* id → its current role-level mood (design/01 §2: mood follows the role).
*
* Two rules, both about not lying with a placeholder:
* - a role the backend reports no mood for (`""`/absent) contributes NOTHING, so
* seeding never clears a bucket a WS `mood` frame already filled;
* - only roles the catalog actually lists are seeded, so no bucket is invented.
*
* Pure and exported so the seeding rule is testable without a browser. */
export function personaMoodSeed(personas: PersonaRecord[]): Record<string, string> {
const seed: Record<string, string> = {};
for (const p of personas) {
if (p.id && p.mood) seed[p.id] = p.mood;
}
return seed;
}
@@ -25,7 +25,8 @@
import { resolveMessagePersona, resolveSessionPersona } from "../lib/persona";
import { setSessionPersona, type SessionPersonaIO } from "../lib/sessionPersona";
import { dispatchFrame } from "../lib/ws";
import { selectCurrentPersona, useAppStore } from "./useAppStore";
import { selectCurrentPersona } from "./personaSelectors";
import { useAppStore } from "./useAppStore";
import type { PersonaRecord, SessionMeta } from "../lib/types";
let checks = 0;
+10 -22
View File
@@ -13,7 +13,6 @@ import type {
ToolUsed,
WsStatus,
} from "../lib/types";
import { resolveSessionPersona } from "../lib/persona";
import { now, uid } from "../lib/util";
interface ChatContext {
@@ -37,7 +36,8 @@ export interface AppStore {
* persona-scoped and carries no `sessionId`, so each role gets its own bucket
* and the UI shows the bucket of the *active session's* role — role X's mood
* must not sit above role Y's conversation. `""` holds events that arrived
* without a persona (legacy/unscoped frames). */
* without a persona (legacy/unscoped frames). Seeded from the role catalog's
* role-level mood on load (`seedMoods` + `personaMoodSeed`). */
moods: Record<string, string>;
context: ChatContext;
agents: AgentInfo[];
@@ -72,6 +72,12 @@ export interface AppStore {
/** Record one role's new mood. `personaId` is the role the event named; "" keeps
* an unscoped/legacy frame in its own bucket. A null mood drops the bucket. */
setMood: (mood: string | null, personaId?: string) => void;
/** Merge role-level moods read from `GET /personas` into the buckets (design/01
* §2: mood follows the role, so the catalog is an authoritative seed). Values
* already known are overwritten by the server's truth; buckets the seed does
* not mention are left alone, so a WS frame that arrived first is never
* cleared. Build the seed with `personaMoodSeed`. */
seedMoods: (seed: Record<string, string>) => void;
/** Replace the session metadata list. */
setSessions: (list: SessionMeta[]) => void;
@@ -166,6 +172,8 @@ export const useAppStore = create<AppStore>()(
else delete moods[personaId];
return { moods };
}),
seedMoods: (seed) =>
set((s) => (Object.keys(seed).length ? { moods: { ...s.moods, ...seed } } : s)),
setSessions: (list) =>
set({
@@ -450,26 +458,6 @@ export const useAppStore = create<AppStore>()(
)
);
/** The role the UI renders (design/22 §4.6): the active session's bound role,
* else the global default. THE single resolution point — the avatar, name,
* wallpaper, mood bucket and message labels all read it, so none of them can
* disagree about "who is speaking" (design/22 §5 risk row 1). */
export function selectCurrentPersona(s: AppStore): PersonaRecord | null {
return resolveSessionPersona(s.activeSessionId, s.sessions, s.personas);
}
/** Subscribe to the active session's role. */
export function useCurrentPersona(): PersonaRecord | null {
return useAppStore(selectCurrentPersona);
}
/** Subscribe to the active role's mood. `mood` frames are role-scoped and carry
* no sessionId, so a role's mood is only shown above its own sessions. */
export function useCurrentMood(): string | null {
const personaId = useAppStore((s) => selectCurrentPersona(s)?.id ?? "");
return useAppStore((s) => (personaId ? s.moods[personaId] ?? null : null));
}
/** Convenience selector for the last streaming assistant message id. */
export function lastStreamingMessage(s: AppStore): ChatMessage | undefined {
const last = s.messages[s.messages.length - 1];
+3 -2
View File
@@ -107,9 +107,10 @@ type AdminBackthinkItem struct {
At int64 `json:"at"`
}
// adminListPersonas lists persona summaries (admin read).
// adminListPersonas lists persona summaries (admin read). It is the admin shape:
// the role-level mood plus the `thinking` knobs the panel edits.
func (s *Server) adminListPersonas(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.collectPersonas())
writeJSON(w, http.StatusOK, s.collectPersonas(true))
}
// adminListSessions lists the persistent chat-history sessions (main + sub) when a
@@ -100,7 +100,7 @@ func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) {
return
}
s.auditClaims(r, "persona.create", "ok")
writeJSON(w, http.StatusCreated, personaSummaryOf(p, characterFromRequest(req)))
writeJSON(w, http.StatusCreated, s.personaSummaryOf(p, characterFromRequest(req)))
}
// adminUpdatePersona edits an existing persona, persisting the YAML and
@@ -157,7 +157,7 @@ func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) {
return
}
s.auditClaims(r, "persona.update", "ok")
writeJSON(w, http.StatusOK, personaSummaryOf(p, characterFromRequest(req)))
writeJSON(w, http.StatusOK, s.personaSummaryOf(p, characterFromRequest(req)))
}
// adminCreateUser answers honestly: there is no persistent user table and no
@@ -300,8 +300,11 @@ func characterFromRequest(req personaUpsertRequest) *personareg.Character {
return &personareg.Character{ID: req.ID, Name: req.Name, PersonaID: req.ID, Modes: modes}
}
// personaSummaryOf maps a persona+character to its wire summary.
func personaSummaryOf(p *loader.Persona, c *personareg.Character) PersonaSummary {
// personaSummaryOf maps a persona+character to its wire summary. It is the same
// shape the admin list returns — the role-level mood plus the `thinking` block —
// so a write response and a subsequent read describe the persona identically
// (a client can trust either for backfill).
func (s *Server) personaSummaryOf(p *loader.Persona, c *personareg.Character) PersonaSummary {
return PersonaSummary{
ID: p.ID,
Name: c.Name,
@@ -309,6 +312,8 @@ func personaSummaryOf(p *loader.Persona, c *personareg.Character) PersonaSummary
IsDefault: c.Default,
Mode: firstMode(c.Modes),
MemoryNS: p.MemoryNamespace(),
Mood: s.moodOf(p.ID),
Thinking: thinkingSummaryOf(p.Thinking, true),
}
}
+12
View File
@@ -10,6 +10,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
@@ -74,6 +75,17 @@ type PersonaStore interface {
Persona(id string) (*loader.Persona, bool)
}
// PersonaMoodStore is the *optional* capability of a persona store that also owns
// the per-persona emotion state (design/01 §2: emotion follows the persona). The
// production *personareg.Registry implements it; the gateway reads a role's mood
// through an assertion on this narrow interface so a store that only lists
// personas (a test double, a future read-only catalog) keeps satisfying
// PersonaStore without owning an emotion machine. When a store does not implement
// it, a role's mood simply reads as "" rather than being invented.
type PersonaMoodStore interface {
Emotions() *emotion.Manager
}
// MemoryStore lists memory entries. *runtime/memory.Store is the production
// implementation. The gateway maps each MemoryItem to its wire summary.
type MemoryStore interface {
@@ -0,0 +1,207 @@
package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
)
// moodRegistry builds a real persona registry (the production PersonaStore, which
// also owns each role's emotion machine) from the given personas, registering the
// matching character too.
func moodRegistry(t *testing.T, personas ...*loader.Persona) *personareg.Registry {
t.Helper()
reg := personareg.New()
for _, p := range personas {
if err := reg.RegisterPersona(p); err != nil {
t.Fatalf("RegisterPersona(%s): %v", p.ID, err)
}
if err := reg.RegisterCharacter(&personareg.Character{ID: p.ID, Name: p.Name, PersonaID: p.ID}); err != nil {
t.Fatalf("RegisterCharacter(%s): %v", p.ID, err)
}
}
return reg
}
// personaList fetches a persona list endpoint and returns each row as a raw map,
// so the test can tell a missing key from a present null/false.
func personaList(t *testing.T, ts *httptest.Server, jwt *auth.JWTService, path string) []map[string]any {
t.Helper()
resp, body := doReq(t, http.MethodGet, ts.URL+path, nil, mustSign(t, jwt))
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s status = %d, want 200 (body=%s)", path, resp.StatusCode, body)
}
var rows []map[string]any
if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return rows
}
// mustSign mints an owner token for the read endpoints.
func mustSign(t *testing.T, jwt *auth.JWTService) string {
t.Helper()
return signAny(t, jwt, "acct", auth.RoleOwner)
}
// TestAdminListPersonasThinkingKeepShowToUserTriState pins the read-side contract
// of the panel's `thinking` block: an unconfigured persona carries NO `thinking`
// key (nil, not an all-zero object), an explicitly false `showToUser` stays
// `false`, and an unset one stays `null` — never flattened into false, which is
// the distinction the write side keeps with *bool.
func TestAdminListPersonasThinkingKeepShowToUserTriState(t *testing.T) {
t.Parallel()
off := false
on := true
reg := moodRegistry(t,
&loader.Persona{ID: "plain", Name: "素面", Emotion: loader.EmotionParams{InitialState: emotion.Calm}},
&loader.Persona{ID: "full", Name: "全能", Thinking: loader.ThinkingParams{
BudgetTokens: 4096, Effort: "high", ShowToUser: &off, Mode: loader.ThinkingModeOff,
}},
&loader.Persona{ID: "budget-only", Name: "只配预算", Thinking: loader.ThinkingParams{BudgetTokens: 2048}},
&loader.Persona{ID: "forced-on", Name: "强制开", Thinking: loader.ThinkingParams{ShowToUser: &on, Mode: loader.ThinkingModeOn}},
)
_, ts, jwt := buildEnv(t, nil, Deps{Personas: reg})
rows := personaList(t, ts, jwt, "/api/v1/admin/personas")
byID := map[string]map[string]any{}
for _, row := range rows {
byID[row["id"].(string)] = row
}
if len(byID) != 4 {
t.Fatalf("rows = %d, want 4", len(byID))
}
if _, present := byID["plain"]["thinking"]; present {
t.Fatalf("unconfigured persona carries thinking = %#v, want the key absent", byID["plain"]["thinking"])
}
full, ok := byID["full"]["thinking"].(map[string]any)
if !ok {
t.Fatalf("full.thinking = %#v, want an object", byID["full"]["thinking"])
}
if full["budgetTokens"] != float64(4096) || full["effort"] != "high" || full["mode"] != "off" {
t.Fatalf("full.thinking = %#v, want 4096/high/off", full)
}
if show, ok := full["showToUser"].(bool); !ok || show {
t.Fatalf("full.thinking.showToUser = %#v, want the boolean false", full["showToUser"])
}
budgetOnly, ok := byID["budget-only"]["thinking"].(map[string]any)
if !ok {
t.Fatalf("budget-only.thinking = %#v, want an object", byID["budget-only"]["thinking"])
}
show, present := budgetOnly["showToUser"]
if !present {
t.Fatal("budget-only.thinking has no showToUser key, want an explicit null")
}
if show != nil {
t.Fatalf("budget-only.thinking.showToUser = %#v, want null (unconfigured)", show)
}
forced, ok := byID["forced-on"]["thinking"].(map[string]any)
if !ok {
t.Fatalf("forced-on.thinking = %#v, want an object", byID["forced-on"]["thinking"])
}
if show, ok := forced["showToUser"].(bool); !ok || !show {
t.Fatalf("forced-on.thinking.showToUser = %#v, want the boolean true", forced["showToUser"])
}
if forced["mode"] != "on" {
t.Fatalf("forced-on.thinking.mode = %#v, want on", forced["mode"])
}
}
// TestUserListPersonasReportsRoleMoodOnly pins the user-side shape: the role-level
// mood rides along (the frontend seeds its badge from it), while the admin-only
// thinking knobs stay out of the user contract.
func TestUserListPersonasReportsRoleMoodOnly(t *testing.T) {
t.Parallel()
off := false
reg := moodRegistry(t,
&loader.Persona{ID: "xilian", Name: "昔涟", Emotion: loader.EmotionParams{InitialState: emotion.Happy},
Thinking: loader.ThinkingParams{BudgetTokens: 1024, ShowToUser: &off}},
&loader.Persona{ID: "yuki", Name: "雪"},
)
_, ts, jwt := buildEnv(t, nil, Deps{Personas: reg})
rows := personaList(t, ts, jwt, "/api/v1/personas")
byID := map[string]map[string]any{}
for _, row := range rows {
byID[row["id"].(string)] = row
}
if got := byID["xilian"]["mood"]; got != "happy" {
t.Fatalf("xilian mood = %#v, want happy", got)
}
if got := byID["yuki"]["mood"]; got != "" {
t.Fatalf("yuki mood = %#v, want the honest empty (no tracked state)", got)
}
for id, row := range byID {
if _, present := row["thinking"]; present {
t.Fatalf("%s carries thinking on the user-side list, want the key absent", id)
}
}
}
// TestPersonaMoodFollowsTheRole is the "mood is role-level, not per-session" rule:
// it reads the live emotion machine, so a transition on one role's machine shows
// up for that role and leaves every other role untouched, with no session input
// anywhere in the path.
func TestPersonaMoodFollowsTheRole(t *testing.T) {
t.Parallel()
reg := moodRegistry(t,
&loader.Persona{ID: "xilian", Name: "昔涟", Emotion: loader.EmotionParams{InitialState: emotion.Calm}},
&loader.Persona{ID: "yuki", Name: "雪", Emotion: loader.EmotionParams{InitialState: emotion.Calm}},
)
_, ts, jwt := buildEnv(t, nil, Deps{Personas: reg})
readMoods := func() (string, string) {
rows := personaList(t, ts, jwt, "/api/v1/personas")
byID := map[string]string{}
for _, row := range rows {
byID[row["id"].(string)], _ = row["mood"].(string)
}
return byID["xilian"], byID["yuki"]
}
if x, y := readMoods(); x != "calm" || y != "calm" {
t.Fatalf("initial moods = (%q, %q), want (calm, calm)", x, y)
}
// Move only 昔涟's machine (the machine a settled turn's observer would move).
if err := reg.Emotions().Transition("xilian", emotion.Happy); err != nil {
t.Fatalf("Transition: %v", err)
}
if x, y := readMoods(); x != "happy" || y != "calm" {
t.Fatalf("moods after one role moved = (%q, %q), want (happy, calm)", x, y)
}
}
// TestPersonaMoodEmptyWithoutEmotionStore is the honest degrade: a persona store
// that does not own emotion (it only lists personas) reports an empty mood rather
// than inventing one.
func TestPersonaMoodEmptyWithoutEmotionStore(t *testing.T) {
t.Parallel()
reg := moodRegistry(t, &loader.Persona{ID: "xilian", Name: "昔涟", Emotion: loader.EmotionParams{InitialState: emotion.Happy}})
_, ts, jwt := buildEnv(t, nil, Deps{Personas: &stubPersonas{list: reg.List()}})
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/personas", nil, mustSign(t, jwt))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var rows []map[string]any
if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil {
t.Fatalf("decode: %v", err)
}
if len(rows) != 1 || rows[0]["mood"] != "" {
t.Fatalf("rows = %#v, want one row with an empty mood", rows)
}
}
+82 -3
View File
@@ -9,6 +9,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
@@ -27,6 +28,35 @@ type PersonaSummary struct {
Avatar string `json:"avatar,omitempty"`
// ChatBackground holds per-orientation wallpaper; a role's own, never another's.
ChatBackground ChatBackgroundSummary `json:"chatBackground,omitempty"`
// Mood is the role's current emotion state (design/01 §2: emotion follows the
// role, never a session). "" means the role has no tracked emotion — an honest
// empty, never a fabricated default. Clients seed their mood badge from it so a
// refresh no longer flashes an empty mood.
Mood string `json:"mood"`
// Thinking carries the persona's optional reasoning/presentation block
// (design/01 §2.1). It is nil — and so absent from the JSON — when the persona
// configures none, mirroring the write side's "an omitted block keeps the
// stored one". Admin list only: these are model-call knobs, not user-UI state
// (the user list takes Mood and leaves the knobs to the panel).
Thinking *PersonaThinkingSummary `json:"thinking,omitempty"`
}
// PersonaThinkingSummary mirrors a persona's `thinking` block on the wire
// (design/01 §2.1; provider mapping in design/04 §3.2).
type PersonaThinkingSummary struct {
// BudgetTokens is the reasoning budget (Anthropic thinking.budget_tokens).
BudgetTokens int `json:"budgetTokens"`
// Effort is the provider-specific reasoning strength (reasoning_effort). The
// model layer validates it against the active provider's whitelist and WARNs
// when it is dropped — this side just reports what is stored.
Effort string `json:"effort"`
// ShowToUser is TRI-STATE and stays that way on the wire: null = not
// configured (thinking frames keep flowing — the pre-existing behavior),
// true/false = an explicit choice. A plain bool would flatten "unset" onto
// "off" and lose the distinction the write side keeps with *bool.
ShowToUser *bool `json:"showToUser"`
// Mode is the explicit hard switch: default | on | off ("" = default).
Mode string `json:"mode"`
}
// ChatBackgroundSummary mirrors the persona chatBackground on the wire.
@@ -224,13 +254,17 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// handleListPersonas returns the personas (or [] when no store is wired).
// handleListPersonas returns the personas (or [] when no store is wired). It is
// the user-side shape: the role-level mood rides along, the admin-only thinking
// knobs do not.
func (s *Server) handleListPersonas(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.collectPersonas())
writeJSON(w, http.StatusOK, s.collectPersonas(false))
}
// collectPersonas returns a non-nil persona slice from the configured store.
func (s *Server) collectPersonas() []PersonaSummary {
// withThinking adds the admin-only `thinking` block; mood is always reported
// because the user UI renders the badge.
func (s *Server) collectPersonas(withThinking bool) []PersonaSummary {
out := make([]PersonaSummary, 0)
if s.personas == nil {
return out
@@ -246,6 +280,7 @@ func (s *Server) collectPersonas() []PersonaSummary {
Character: c.Name,
IsDefault: c.Default,
Mode: firstMode(c.Modes),
Mood: s.moodOf(id),
}
// Read this persona's own visual assets (avatar / chat background). Each
// role carries its own; a missing persona or unset visual yields empty,
@@ -260,12 +295,56 @@ func (s *Server) collectPersonas() []PersonaSummary {
Portrait: p.Visual.ChatBackground.Portrait,
}
}
sum.Thinking = thinkingSummaryOf(p.Thinking, withThinking)
}
out = append(out, sum)
}
return out
}
// moodOf reports the role's current emotion state. Emotion is owned by the
// persona store (design/01 §2), read through the optional PersonaMoodStore
// capability; a store that does not track emotion, or a role with no machine,
// reports "" instead of a made-up mood.
func (s *Server) moodOf(id string) string {
store, ok := s.personas.(PersonaMoodStore)
if !ok {
return ""
}
emotions := store.Emotions()
if emotions == nil {
return ""
}
machine, _, ok := emotions.Get(id)
if !ok || machine == nil {
return ""
}
return string(machine.Current())
}
// thinkingSummaryOf maps a stored thinking block onto the wire. It returns nil
// when the persona configures none — the key is then absent, so a client can tell
// "not configured" from an explicit value (the read-side mirror of the write-side
// rule that an omitted block keeps the stored one). include is false for the
// user-side list, which does not carry these knobs.
func thinkingSummaryOf(t loader.ThinkingParams, include bool) *PersonaThinkingSummary {
if !include || !thinkingConfigured(t) {
return nil
}
return &PersonaThinkingSummary{
BudgetTokens: t.BudgetTokens,
Effort: t.Effort,
ShowToUser: t.ShowToUser,
Mode: t.Mode,
}
}
// thinkingConfigured reports whether any thinking knob is set. An empty block
// must read as "not configured" rather than as an all-zero object.
func thinkingConfigured(t loader.ThinkingParams) bool {
return t.BudgetTokens != 0 || t.Effort != "" || t.ShowToUser != nil || t.Mode != ""
}
// firstMode returns the first declared behavior mode, or "" when none are set.
func firstMode(modes []string) string {
if len(modes) == 0 {