diff --git a/Docs/api-client.md b/Docs/api-client.md index 08d827b..3ce3b0d 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -41,11 +41,77 @@ | 13 | `/api/v1/upload` | POST | JWT(owner/admin) | ✅ | **图片上传**(B1):multipart/form-data,字段 `file`(raster image,5MB 上限)→ `201 {data:{url,size,mime,name}}`;`url` 为**同源访问地址**(`/api/v1/files/`,可直接作 `chat.image`)。校验:`MaxBytesReader` 防超大 → `DetectContentType` 嗅探(**不信客户端 Content-Type**)→ 白名单 png/jpeg/gif/webp/bmp(**拒 SVG/HTML**);文件名用 **crypto/rand 128-bit 随机名**(原始文件名丢弃),存 `/uploads/`(0700)。错误码:缺文件 `400 bad_request`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`、未授权 `401/403` | | 14 | `/api/v1/files/{name}` | GET | 无需(能力URL) | ✅ | **上传文件访问**(B1):从 `/uploads/` 读取,同名端口服务。`name` 仅接受「32 位小写 hex + 白名单扩展名」;**路径穿越全形态拒绝**(`..`/`%2f`/`%2e%2e` 等)。返回 `Content-Type`/`Content-Length`/`Cache-Control: private,max-age=86400,immutable`/`X-Content-Type-Options: nosniff`(支持 Range)。**设计取舍**:GET 不鉴权——该 URL 是**128-bit 随机能力地址**(`chat.image`/`` 无法带 Bearer),POST 仍 owner/admin+审计;任何持有该 URL 者都能取文件,勿外泄到他人 | +| 15 | `/api/v1/meta/enums` | GET | JWT(任意角色) | ✅ | **共享枚举权威表**(B4):`{data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`——`platform`/`mood`/`mode` 的**唯一事实来源**,客户端**不必硬编码**即可拿到稳定映射(来源角标 / 情绪标签不再漂移)。只读、无敏感信息,与 `GET /sessions` 同级(owner/admin/viewer 均可读;无 token 回 `401`)。详见 §1.1 | + > **语音 REST 源(`/api/v1/audio/*`,均 owner-only + 审计)**:原生/移动端用它上传/获取音频,**绕开 WS 4KB 帧限**。 > - `POST /api/v1/audio/transcribe`:body `{audio:"", format?:"wav", deviceId?}` → `200 {data:{text}}`。`audio` 为 base64(或 data: URI);`format` 为编码(`wav`/`mp3`/`ogg`,缺省 `wav`);空 audio 回 `400`;未注入语音服务 `500 voice_unavailable`、识别失败 `500 stt_failed`。 > - `POST /api/v1/audio/speech`:body `{text, voice?, format?, instruction?}` → `200` 原始音频字节(`Content-Type: audio/`),**非 JSON envelope**。`voice`/`format` 目前遵循服务端 `VOICE_TTS_VOICE_ID`/`VOICE_TTS_FORMAT` 配置(后续支持按请求覆盖);`instruction` 为**可选** per-request 情绪/语气指令(如 `"语气开朗俏皮,像女朋友撒娇"`),只对**复刻/设计音色**生效——**系统音色 `longxiaochun_v3` 被服务端忽略,永不发送自由指令**(CosyVoice 系统音色需固定格式);长度 ≤100 字符(汉字记 2);未传时回退到服务端 `VOICE_TTS_INSTRUCTION`。空 text 回 `400`;未注入语音服务 `500 voice_unavailable`、合成失败 `500 tts_failed`、静默空音频 `500 tts_empty`。 > - `POST /api/v1/audio/voice-clone`:body `{url:"<公网参考音频URL>", prefix?:"cyrene"}` → `200 {data:{voiceId}}`。`url` 为**公网可访问的参考音频 URL**(create_voice **只收 `input.url`,不收 base64**),音频要求 10~20s 连续清晰朗读、≤10MB、≥16kHz;`prefix` 为音色名前缀(**≤10 字母/数字**,缺省 `cyrene`)。返回的 `voiceId` 填到 `VOICE_TTS_VOICE_ID` 即可用该复刻音色合成(`target_model` 固定为 `VOICE_TTS_MODEL`,须与合成模型一致)。**URL 不写审计/日志(红线,绝不打日志)**。空 url 回 `400`;未注入语音服务 `500 voice_unavailable`;建音色失败 `500 voice_clone_failed`。**⚠️ 真建音色需主人提供公网参考音频 URL(当前无,断言待提供);且需北京地域 API Key(CosyVoice 声音复刻仅华北2北京地域)。** +### 1.1 枚举权威表(`GET /api/v1/meta/enums`,B4) + +> **背景**:`platform` / `mood` / `mode` 此前只在文档里给示例(`web`/`telegram`/…;`calm`;`normal`),客户端只能自建映射表——来源角标与情绪标签因此会**随客户端漂移**。本端点发布**权威枚举表 + 未知值回落约定**:Web / Android / IM bridge **共用同一份**,客户端不必硬编码。 + +- **鉴权**:`Authorization: Bearer `,**任意已认证角色可读**(`owner`/`admin`/`viewer`),与 `GET /api/v1/sessions` 同级——只读、无敏感信息。无 token / 令牌无效回 `401`。无其它错误码(纯内存投影,无存储、无 501)。 +- **响应**:`200` + ```json + {"data":{ + "platforms":[{"id":"web","label":"网页"}, ...], + "moods":[{"id":"calm","label":"平静"}, ...], + "modes":[{"id":"chat","label":"对话"}, ...], + "unknown":"display-raw" + }} + ``` + 每项固定为 `{"id":"<枚举 id>","label":"<默认中文标签>"}`。`label` 是**默认值**,自带 i18n 的客户端可覆盖显示;`id` 才是必须比对的值。 +- **`unknown`(未知值回落约定,机器可读)**:恒为 `"display-raw"`。语义见下。 + +**platform(消息来源平台,`{id:label}` 逐值)** + +| id | label | 说明 | +|----|-------|------| +| `web` | 网页 | Web 主会话来源(`chat_messages.platform` 默认值) | +| `kook` | KOOK | KOOK 适配器 | +| `qq-obv11` | QQ | OneBot v11(QQ)适配器 | +| `weixin-ilink` | 微信 | 微信 iLink 适配器 | +| `telegram` | Telegram | Telegram Bot API 适配器 | +| `webhook` | Webhook | 通用 Webhook 适配器 | + +**mood(情绪,`{id:label}` 逐值)** + +| id | label | +|----|-------| +| `calm` | 平静 | +| `happy` | 开心 | +| `excited` | 兴奋 | +| `sad` | 低落 | +| `angry` | 生气 | + +> ⚠️ 情绪状态机**可扩展**(persona 可注册更多状态),因此上表**不是封闭集合**——未知 mood 必须按回落约定原样显示。 + +**mode(行为/呈现模式,`{id:label}` 逐值)** + +| id | label | +|----|-------| +| `chat` | 对话 | +| `code` | 代码 | +| `home` | 家居 | +| `work` | 工作 | +| `docs` | 文档 | +| `voice` | 语音 | +| `vision` | 视觉 | +| `agents` | 智能体 | +| `settings` | 设置 | + +**未知值回落约定(红线)** + +- 客户端遇到**不在表中**的取值(`platform` / `mood` / `mode` 任一):**原样显示该原始字符串**——**不丢弃、不报错、不替换占位符、不猜测**。 +- **服务端新增枚举取值是非破坏性变更**(forward-compatible):表中新增一项不改变既有字段/端点形状,旧客户端无需升级;**不要**因为读到未知值而清空角标、抛异常或中断渲染。 +- 判定**只比对 `id`**(大小写敏感,契约 id 为小写);`label` 仅作显示默认值。 +- Web 端镜像实现在 `frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 对未知值返回原值),权威仍以本端点为准。 +- **反漂移保证(后端)**:后端有测试断言「每个内置适配器的 `Name()` 都在 `platforms` 表内」「`moods` 与 persona 情绪状态机的内置状态一致」「`modes` 与前端 `MODES` 九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。 + +> **契约版本 `v0.38`**(新增端点,向后兼容)。 + ## 2. REST 端点(管理,`/api/v1/admin/*`) > 仅供管理面板与授权客户端;RBAC:`owner`/`admin`/`viewer`(viewer 只读、写 403);**写操作全程审计**。 @@ -261,6 +327,7 @@ - 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。 ## 5. 变更记录 +- `v0.38`(**B4 客户端共享枚举权威表 + 未知值回落约定**,对客户端**新增一个只读 REST 端点**):新增 **`GET /api/v1/meta/enums`**(**任意已认证角色可读**,与 `GET /sessions` 同级;无 token `401`)→ `200 {data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`:① **`platforms`**(6)= `web`/`kook`/`qq-obv11`/`weixin-ilink`/`telegram`/`webhook`——即各内置适配器 `Name()` + Web 主会话来源 `web`;② **`moods`**(5)= `calm`/`happy`/`excited`/`sad`/`angry`,与 persona 情绪状态机内置状态一致(该状态机**可扩展**,故非封闭集合);③ **`modes`**(9)= `chat`/`code`/`home`/`work`/`docs`/`voice`/`vision`/`agents`/`settings`(design/07 §1);④ **`unknown`** 恒 `"display-raw"` = **未知值回落约定机器可读**:客户端遇不在表中的取值**原样显示原始字符串**(**不丢弃、不报错、不占位符**),**服务端新增取值视为非破坏性变更**(forward-compatible)。**单一事实来源在 Go 侧**(`internal/contract/enums`),并有**反漂移测试**断言适配器 `Name()` / 情绪内置状态 / 前端九模式与三张表一致,漏登记即 CI FAIL。Web 侧镜像 `frontend/web/src/lib/enums.ts`(`labelFor` 未知值返回原值),顶栏/侧栏/消息气泡的「心情」标签已改用它渲染。**向后兼容**:仅新增端点,既有契约形状不变。详见 §1 行 15 与 §1.1。 - `v0.37`(**D4 未读状态 / 已读游标**,对客户端**新增一个语义字段、一个 REST 端点、一个下行事件**):① `chat_sessions` 新增 **`read_at INTEGER NOT NULL DEFAULT 0`**(迁移 **0010**,down 可逆;**毫秒 Unix 时间戳**,与 `chat_messages.created_at` **同单位**)。② **语义**:**会话级单一已读游标 + 单 owner 账号**——同一账号所有端共用一个游标,**读一处 = 全端已读**(不做 per-device/per-user 游标,与「同一账号所有浏览器都属于同一 owner」一致);`createdAt <= readAt` 的消息算已读,未读数 = `createdAt > readAt` 的条数。③ **`GET /api/v1/sessions` 每项新增恒返回的 `unreadCount`**(整数,0 = 无未读):与 `lastMessage` **同一条 SQL 派生**(关联子查询 `COUNT(*) WHERE session_id = s.id AND created_at > s.read_at`,走已有 `idx_chat_msgs_session_created`,**无 N+1、不新增索引**)。④ **新增 `POST /api/v1/sessions/{id}/read`**(owner/admin,与 `PATCH /sessions/{id}` 同鉴权):body `{before?}`(可选毫秒;缺省= 现在)→ `200 {data:{sessionId,readAt}}`;`before` 为 0/负数/非数字/body 非对象 → `400`,未知会话(含隔离 `sess-xxx`)→ `404`,未注入 HistoryStore → `501`。**游标只前进不后退**(`read_at = MAX(read_at, before)`),乱序/并发/重试请求幂等安全。⑤ **新增下行事件 `session_read`**:`{type:"session_read", sessionId, data:{readAt}}`,全连接广播 + 客户端按 `sessionId` 过滤,用于同账号其它端清红点;失败路径不广播(§3.9)。⑥ **升级回填**:0010 把既有会话游标回填为「该会话现有最新消息时间」,**升级当天不误报未读**(既有历史全部视为已读)。⑦ `HistoryStore`(网关消费侧契约)新增 `MarkSessionRead(ctx, sessionID, before)`;`SessionMeta` 新增 `UnreadCount`。**向后兼容**:仅新增字段/端点/事件,既有契约形状不变;不消费 `unreadCount`/`session_read` 的客户端行为不变。详见 §1 行 4/8a 与 §3.9。 - `v0.36`(**A1 流式帧附加会话 / 回合标识**,对客户端**新增两个可选字段、无端点变更**):结构化流帧 `message.start/delta/end`、`thinking.start/delta/end`、`tool_call.start/delta/end`、`tool_execution.update` 现在带 `sessionId` + `turnId`(**向后兼容**:只新增帧字段,`data` 结构与既有事件形状不变)。`sessionId` 由 loop 在**回合边界**从运行上下文注入(模型适配器在 loop 之下、天然不感知会话,保持零值;loop 外无会话上下文时该字段为空);`turnId` 由网关**每回合**新生成(`newTurnID`)并经 `loop.WithTurnID` 挂进运行上下文,`runTurn` 是编排层与 legacy 直连路径**唯一**的回合收口点,两路都覆盖——同一会话同时只有一个 in-flight 回合,故同轮内所有帧的 `turnId` 一致。此前这几帧属于 design/16 §4.2 标注的「若知道就带上」缺口(v0.10 起记录):**多会话并发流式时前端会把别的会话的 delta 接进当前会话气泡**,现在客户端按 `sessionId === activeSessionId` 过滤即可路由到激活会话。`stream_chunk` / `stream_end` 的 `sessionId`/`turnId` 行为不变。详见 §3.1。 - `v0.35`(**可观测性收尾:后端日志 tail 端点**,对客户端**新增一个 admin 只读端点**):`GET /api/v1/admin/observability/logs?level=&limit=`(owner/admin)→ `200 {data:[{time,level,source,msg}]}`,**最新在前**。数据源为 logger 新增的**有界环形缓冲**(`internal/logger`,容量 2000,满则丢最旧、并发安全、不阻塞、不改动既有控制台/文件输出),组合根在构建 logger 时挂上、在网关就绪后经 `SetLogBuffer` 注入;缓冲未注入回 `501 not_implemented`(不伪造空 tail)。`time` 为 **RFC3339 UTC(秒精度)**,与 `trace.startedAt`/`audit.when` 一致;`level ∈ debug|info|warn|error`(大小写不敏感)为**最低等级过滤**,空 = 全部,未知值 `400 invalid_level`;`limit` 缺省 `200`、上限 `1000`。`source` 取 `logger.WithSource` 的 `source=`(`Scoped` 记录回落 `module/sub`),无来源为空串。详见 §2「可观测性契约」。 diff --git a/Docs/api-reference.md b/Docs/api-reference.md index 964a5de..98b6b96 100644 --- a/Docs/api-reference.md +++ b/Docs/api-reference.md @@ -68,6 +68,7 @@ | 9 | PATCH | `/api/v1/sessions/{id}` | owner\|admin | 200 | 会话改名(A4) + 置顶(`pinned`, D5) body `{title?,pinned?}` | M16 | | 10 | PATCH | `/api/v1/messages/{id}` | owner\|admin | 200 | 编辑单条消息(D3) body `{content}` | M16 | | 11 | DELETE | `/api/v1/messages/{id}` | owner\|admin | 200 | 删除单条消息(D3) | M16 | +| 12 | GET | `/api/v1/meta/enums` | JWT | 200 | 客户端共享枚举权威表(B4):`platform`/`mood`/`mode` + `unknown` 回落约定 | M8 | ### 1.3 管理后台(`/api/v1/admin/*`) @@ -364,6 +365,40 @@ curl -s -X DELETE http://:8080/api/v1/sessions/web:0123abcd... \ --- +### 3.7 GET /api/v1/meta/enums +- **鉴权**:`Authorize()`(**任意已认证角色**:owner/admin/viewer)——只读、无敏感信息,与 `GET /api/v1/sessions`(§3.2)同级。无 token 回 `401`。 +- **用途**:客户端共享枚举权威表(Android 协同 B4,design/07 §6)。`platform`(消息来源平台)/ `mood`(情绪)/ `mode`(行为·呈现模式)此前只在文档里给示例,客户端各自硬编码映射导致来源角标与情绪标签漂移;本端点把三张表做成**机器可读的单一事实来源**,Web / Android / IM bridge 共用。 +- **响应**:`200` + ```json + {"data":{ + "platforms":[{"id":"web","label":"网页"},{"id":"kook","label":"KOOK"},{"id":"qq-obv11","label":"QQ"},{"id":"weixin-ilink","label":"微信"},{"id":"telegram","label":"Telegram"},{"id":"webhook","label":"Webhook"}], + "moods":[{"id":"calm","label":"平静"},{"id":"happy","label":"开心"},{"id":"excited","label":"兴奋"},{"id":"sad","label":"低落"},{"id":"angry","label":"生气"}], + "modes":[{"id":"chat","label":"对话"},{"id":"code","label":"代码"},{"id":"home","label":"家居"},{"id":"work","label":"工作"},{"id":"docs","label":"文档"},{"id":"voice","label":"语音"},{"id":"vision","label":"视觉"},{"id":"agents","label":"智能体"},{"id":"settings","label":"设置"}], + "unknown":"display-raw" + }} + ``` +- **字段**: + | 字段 | 类型 | 说明 | + |------|------|------| + | `platforms` | `[]{id,label}` | 来源平台:各内置适配器 `Name()`(`kook`/`qq-obv11`/`weixin-ilink`/`telegram`/`webhook`)+ Web 主会话来源 `web`(`history.PlatformWeb`) | + | `moods` | `[]{id,label}` | 情绪:`internal/persona/emotion` 内置状态。状态机**可扩展**(persona 可经 `AddTransition` 注册更多状态),此表**非封闭集合** | + | `modes` | `[]{id,label}` | 九模式(design/07 §1);mode 只驱动呈现,不构成能力边界 | + | `unknown` | string | 恒 `"display-raw"`:未知值回落约定(机器可读) | + | `label` | string | **默认中文标签**,客户端可直接显示、也可按 i18n 覆盖;客户端比对应以 `id` 为准(小写、大小写敏感) | +- **错误**:仅 `401`(未认证)。无存储依赖,不会 `501`/`500`。 +- **备注**: + - **未知值回落约定(红线)**:客户端遇不在表中的取值**原样显示原始字符串**——**不丢弃、不报错、不替换占位符**;**服务端新增取值是非破坏性变更**(forward-compatible),旧客户端无需升级,也不得因未知值清空角标或抛错。 + - **单一事实来源在 Go 侧**:`internal/contract/enums`(`Platforms()`/`Moods()`/`Modes()`/`UnknownFallback`),网关只做投影(`internal/services/gateway/meta.go`)。 + - **反漂移**:`internal/contract/enums` 的测试断言三张表与「适配器 `Name()`」「`emotion` 内置状态」「前端 `types.ts` 的 `MODES`」一致;`cmd/cyrene` 另断言表内每个平台都能被渠道工厂构建——漏登记或幽灵条目即 FAIL。 + - Web 侧镜像:`frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 未知值返回原值),顶栏 / 侧栏 / 消息气泡的心情标签已改用。 + - curl: + ```bash + curl -s http://:8080/api/v1/meta/enums -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1' + # => {"data":{"platforms":[...],"moods":[...],"modes":[...],"unknown":"display-raw"}} + ``` + +--- + ## 4. 管理后台 · 读端点(`owner|admin`) > 以下端点 `鉴权 = Authorize(owner, admin)`(除特别标注 owner)。所有管理**读**不审计;管理**写**(§5)全程审计(who/what/when/result,见 §4.15)。 diff --git a/cmd/cyrene/enums_factory_test.go b/cmd/cyrene/enums_factory_test.go new file mode 100644 index 0000000..de61c1e --- /dev/null +++ b/cmd/cyrene/enums_factory_test.go @@ -0,0 +1,46 @@ +package main + +// Wiring-level anti-drift test for the shared client enum table (design/07 §6). +// The enums package test pins the platform table to the adapters' Name() values; +// this one pins it to the composition root's factory — every platform the table +// publishes must be one buildAccountAdapter actually knows how to build, so a +// phantom entry (a platform listed for clients but never wired up) fails here. + +import ( + "io" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/contract/enums" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" +) + +// TestChannelFactoryBuildsEnumeratedPlatforms builds every adapter the published +// platform table lists (other than the Web main-session source, which is not an +// adapter) through the real factory and asserts the built adapter's Name() +// resolves back to that platform. Adapter construction never dials out, so the +// minimal configs below are enough. +func TestChannelFactoryBuildsEnumeratedPlatforms(t *testing.T) { + t.Parallel() + log := logger.New(logger.Options{Out: io.Discard}) + minimalConfig := map[string]map[string]any{ + "kook": {"bot_token": "test"}, + "telegram": {"bot_token": "test"}, + } + + for _, p := range enums.Platforms() { + if p.ID == history.PlatformWeb { + continue + } + acct := config.ChannelAccount{Config: minimalConfig[p.ID]} + built, err := buildAccountAdapter(p.ID, acct, log) + if err != nil { + t.Errorf("platform %q is published to clients but the factory cannot build it: %v", p.ID, err) + continue + } + if got := barePlatform(built.Name()); got != p.ID { + t.Errorf("buildAccountAdapter(%q).Name() = %q, want %q", p.ID, got, p.ID) + } + } +} diff --git a/internal/contract/enums/enums.go b/internal/contract/enums/enums.go new file mode 100644 index 0000000..86620b7 --- /dev/null +++ b/internal/contract/enums/enums.go @@ -0,0 +1,81 @@ +// Package enums is the single Go-side source of truth for the shared client +// enumerations published at GET /api/v1/meta/enums (Android collaboration B4, +// design/07 §6). It carries three tables — platform (a message's source +// platform), mood (a persona's emotion) and mode (the presentation mode a +// message was produced in) — so every client maps ids to labels from the same +// place instead of hardcoding its own, which is what made source badges and mood +// labels drift per client. +// +// Adding a value is a non-breaking contract change. A value absent from a table +// must be rendered raw by the client — never dropped, never an error — and that +// policy is published alongside the tables (UnknownFallback). The anti-drift +// tests in this package pin each table to its real source of truth: the platform +// table to the built-in adapters' Name() values plus the Web main-session source +// (internal/runtime/history), the mood table to internal/persona/emotion's +// built-in states, and the mode table to the nine modes design/07 §1 defines +// (mirrored by frontend/web/src/lib/types.ts MODES). +package enums + +// Item is one enumeration entry: the machine-readable ID a client compares +// against, plus the default Simplified-Chinese label it may display directly. +// The label is a default, not a contract: a client with i18n may override it. +type Item struct { + ID string `json:"id"` + Label string `json:"label"` +} + +// UnknownFallback is the machine-readable fallback policy a client applies to a +// value that is not in the published tables: display the value exactly as +// received — no label substitution, no drop, no error. It is served in the same +// response as the tables so a client reads the policy and the data together. +const UnknownFallback = "display-raw" + +// Platforms returns the authoritative message-source platforms: the Web main +// session plus every built-in IM adapter, keyed by the adapter's Name() value +// (kook, qq-obv11, weixin-ilink, telegram, webhook). +// +// Each call builds a fresh slice, so a caller may sort or otherwise mutate its +// copy without affecting another — the table itself is never package-level +// mutable state. +func Platforms() []Item { + return []Item{ + {ID: "web", Label: "网页"}, + {ID: "kook", Label: "KOOK"}, + {ID: "qq-obv11", Label: "QQ"}, + {ID: "weixin-ilink", Label: "微信"}, + {ID: "telegram", Label: "Telegram"}, + {ID: "webhook", Label: "Webhook"}, + } +} + +// Moods returns the authoritative persona-emotion values, mirroring +// internal/persona/emotion's built-in states (calm/happy/excited/sad/angry). The +// emotion state machine is extensible — a persona may register further states via +// AddTransition — so a client must treat an unlisted mood as display-raw rather +// than assuming this table is closed. +func Moods() []Item { + return []Item{ + {ID: "calm", Label: "平静"}, + {ID: "happy", Label: "开心"}, + {ID: "excited", Label: "兴奋"}, + {ID: "sad", Label: "低落"}, + {ID: "angry", Label: "生气"}, + } +} + +// Modes returns the authoritative behavior/presentation modes (design/01 §3, +// design/07 §1). A mode drives how a message is rendered, never what the persona +// is able to do: it is a presentation dimension, not a capability boundary. +func Modes() []Item { + return []Item{ + {ID: "chat", Label: "对话"}, + {ID: "code", Label: "代码"}, + {ID: "home", Label: "家居"}, + {ID: "work", Label: "工作"}, + {ID: "docs", Label: "文档"}, + {ID: "voice", Label: "语音"}, + {ID: "vision", Label: "视觉"}, + {ID: "agents", Label: "智能体"}, + {ID: "settings", Label: "设置"}, + } +} diff --git a/internal/contract/enums/enums_test.go b/internal/contract/enums/enums_test.go new file mode 100644 index 0000000..0768c24 --- /dev/null +++ b/internal/contract/enums/enums_test.go @@ -0,0 +1,202 @@ +package enums_test + +// Anti-drift tests for the shared client enum tables (design/07 §6, requirement +// 4): each table is pinned to the real code that defines its values, so a value +// added to (or renamed in) an adapter, the emotion state machine or the frontend +// mode union without registering it here fails the build rather than silently +// reaching clients as an unmapped string. +// +// The platform test builds the real built-in adapters and asserts the table and +// their Name() values are the same set — not merely that the table holds some +// strings. The adapters are constructed with empty configs, which never dials +// out (Connect does); see cmd/cyrene's factory test for the wiring-level anchor. + +import ( + "errors" + "io/fs" + "os" + "regexp" + "slices" + "sort" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/contract/enums" + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" +) + +// ids maps a table to its id set. +func ids(items []enums.Item) map[string]bool { + out := make(map[string]bool, len(items)) + for _, it := range items { + out[it.ID] = true + } + return out +} + +// sortedKeys returns the keys of a set in a stable order for diffs. +func sortedKeys(set map[string]bool) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// adapterPlatformNames returns the Name() of every built-in IM adapter plus the +// Web main-session source. It is the authority the platform table mirrors: if an +// adapter is renamed and the table is not, the two sets diverge and the test +// fails. +func adapterPlatformNames(t *testing.T) []string { + t.Helper() + weixin, err := weixin_ilink.New(weixin_ilink.Config{}, nil, nil) + if err != nil { + t.Fatalf("build weixin-ilink adapter: %v", err) + } + return []string{ + history.PlatformWeb, + kook.New(kook.Config{}).Name(), + qq_obv11.NewAdapter(qq_obv11.Config{}).Name(), + weixin.Name(), + telegram.New(telegram.Config{}).Name(), + webhook.New().Name(), + } +} + +// TestPlatformTableMatchesAdapterNames asserts the published platform table and +// the adapters the app actually builds are the same set, in both directions: an +// unregistered adapter (missing entry) and a phantom entry (a platform no +// adapter provides) both fail. +func TestPlatformTableMatchesAdapterNames(t *testing.T) { + t.Parallel() + want := make(map[string]bool) + for _, name := range adapterPlatformNames(t) { + want[name] = true + } + got := ids(enums.Platforms()) + for _, name := range sortedKeys(want) { + if !got[name] { + t.Errorf("adapter Name() %q is missing from enums.Platforms()", name) + } + } + for _, id := range sortedKeys(got) { + if !want[id] { + t.Errorf("enums.Platforms() lists %q, which no built-in adapter provides", id) + } + } + if len(want) != len(got) { + t.Fatalf("platform count = %d, want %d (table=%v adapters=%v)", len(got), len(want), sortedKeys(got), sortedKeys(want)) + } +} + +// TestMoodTableMatchesEmotionBuiltins asserts the mood table is exactly the +// emotion state machine's built-in states, referencing the constants rather than +// re-typing the strings, and that every state the default transition table +// mentions is therefore labeled (a transition to an unlabeled state would reach +// clients as a display-raw string). +func TestMoodTableMatchesEmotionBuiltins(t *testing.T) { + t.Parallel() + builtins := []emotion.EmotionState{emotion.Calm, emotion.Happy, emotion.Excited, emotion.Sad, emotion.Angry} + want := make(map[string]bool, len(builtins)) + for _, s := range builtins { + want[string(s)] = true + } + got := ids(enums.Moods()) + if !slices.Equal(sortedKeys(got), sortedKeys(want)) { + t.Fatalf("mood table ids = %v, want the emotion built-ins %v", sortedKeys(got), sortedKeys(want)) + } + for _, entry := range enums.Moods() { + if entry.Label == "" { + t.Errorf("mood %q has no label", entry.ID) + } + } + for from, tos := range emotion.DefaultTransitions() { + if !got[string(from)] { + t.Errorf("default transition source %q is not in the mood table", from) + } + for _, to := range tos { + if !got[string(to)] { + t.Errorf("default transition target %q is not in the mood table", to) + } + } + } +} + +// TestModeTableMatchesFrontendModes asserts the mode table matches the frontend's +// Mode union / MODES array (frontend/web/src/lib/types.ts), the only code-level +// definition of the nine modes. This is the cross-language drift guard: adding a +// mode on either side without the other fails here. +func TestModeTableMatchesFrontendModes(t *testing.T) { + t.Parallel() + const typesPath = "../../../frontend/web/src/lib/types.ts" + raw, err := os.ReadFile(typesPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + t.Skipf("%s not present (backend-only checkout)", typesPath) + } + t.Fatalf("read %s: %v", typesPath, err) + } + block := regexp.MustCompile(`(?s)export const MODES\s*:[^=]*=\s*\[(.*?)\]`).FindSubmatch(raw) + if block == nil { + t.Fatalf("MODES array not found in %s", typesPath) + } + quoted := regexp.MustCompile(`"([^"]+)"`).FindAllStringSubmatch(string(block[1]), -1) + frontend := make([]string, 0, len(quoted)) + for _, m := range quoted { + frontend = append(frontend, m[1]) + } + backend := make([]string, 0, len(enums.Modes())) + for _, m := range enums.Modes() { + backend = append(backend, m.ID) + } + if !slices.Equal(backend, frontend) { + t.Fatalf("mode table = %v, want the frontend MODES %v", backend, frontend) + } +} + +// TestTablesAreWellFormed guards the published shape itself: ids are unique and +// non-empty and every entry carries a default label, so a client can always +// render something for a known value. +func TestTablesAreWellFormed(t *testing.T) { + t.Parallel() + for _, table := range []struct { + name string + items []enums.Item + }{ + {name: "platforms", items: enums.Platforms()}, + {name: "moods", items: enums.Moods()}, + {name: "modes", items: enums.Modes()}, + } { + if len(table.items) == 0 { + t.Fatalf("%s is empty", table.name) + } + seen := make(map[string]bool, len(table.items)) + for _, it := range table.items { + if it.ID == "" || it.Label == "" { + t.Errorf("%s entry %+v must have both id and label", table.name, it) + } + if seen[it.ID] { + t.Errorf("%s id %q is duplicated", table.name, it.ID) + } + seen[it.ID] = true + } + } +} + +// TestTablesAreCopies asserts the accessors hand out independent slices, so a +// caller sorting its copy cannot reorder the authority table for everyone else. +func TestTablesAreCopies(t *testing.T) { + t.Parallel() + first := enums.Platforms() + first[0] = enums.Item{ID: "mutated", Label: "mutated"} + second := enums.Platforms() + if second[0].ID != "web" { + t.Fatalf("Platforms() shared state: second call starts with %q", second[0].ID) + } +} diff --git a/internal/services/gateway/meta.go b/internal/services/gateway/meta.go new file mode 100644 index 0000000..181a411 --- /dev/null +++ b/internal/services/gateway/meta.go @@ -0,0 +1,42 @@ +package gateway + +import ( + "net/http" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/contract/enums" +) + +// This file owns the read-only client metadata surface: GET /api/v1/meta/enums, +// which publishes the authoritative platform/mood/mode tables (Android +// collaboration B4, design/07 §6). Clients (Web, Android, an IM bridge) read the +// tables instead of hardcoding their own id → label mapping, so a source badge or +// a mood label cannot drift per client. The tables themselves live in +// internal/contract/enums; this handler only serves them, so there is one source +// of truth rather than a copy per consumer. + +// metaEnums is the GET /api/v1/meta/enums payload. `unknown` carries the +// machine-readable fallback policy (enums.UnknownFallback) so a client reads the +// tables and the rule for an unlisted value from the same response. +type metaEnums struct { + Platforms []enums.Item `json:"platforms"` + Moods []enums.Item `json:"moods"` + Modes []enums.Item `json:"modes"` + Unknown string `json:"unknown"` +} + +// handleListEnums serves the shared client enum tables. It is a pure projection +// of the Go-side authority (no store, no error path) and contains nothing +// sensitive, so it is gated like GET /api/v1/sessions: any authenticated role may +// read it. +// +// Adding a value to a table is a non-breaking change for existing clients, which +// must display an unlisted value raw (see enums.UnknownFallback) rather than +// dropping it or erroring. +func (s *Server) handleListEnums(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, metaEnums{ + Platforms: enums.Platforms(), + Moods: enums.Moods(), + Modes: enums.Modes(), + Unknown: enums.UnknownFallback, + }) +} diff --git a/internal/services/gateway/meta_test.go b/internal/services/gateway/meta_test.go new file mode 100644 index 0000000..9e76f31 --- /dev/null +++ b/internal/services/gateway/meta_test.go @@ -0,0 +1,138 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" +) + +// These tests cover the shared client enum endpoint (Android collaboration B4, +// design/07 §6): the wire shape of GET /api/v1/meta/enums, the values it +// publishes, and the fact that it is readable by any authenticated role. They +// assert the JSON contract with literals rather than by comparing against +// internal/contract/enums, so the client-facing shape is pinned independently of +// the Go table (the table's own drift tests live in that package). + +// enumEntry is the wire shape of one published enum item. +type enumEntry struct { + ID string `json:"id"` + Label string `json:"label"` +} + +// metaEnumsWire decodes GET /api/v1/meta/enums and asserts the envelope and the +// per-item shape, returning the ids of each table for value assertions. +func metaEnumsWire(t *testing.T, ts *httptest.Server, tok string) (platforms, moods, modes []string, unknown string) { + t.Helper() + resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/meta/enums", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + env := decodeBody(t, body) + if len(env) != 1 { + t.Fatalf("envelope keys = %v, want only data", env) + } + var data struct { + Platforms []enumEntry `json:"platforms"` + Moods []enumEntry `json:"moods"` + Modes []enumEntry `json:"modes"` + Unknown string `json:"unknown"` + } + if err := json.Unmarshal(env["data"], &data); err != nil { + t.Fatalf("decode data: %v (body=%s)", err, body) + } + for _, table := range []struct { + name string + entries []enumEntry + }{{"platforms", data.Platforms}, {"moods", data.Moods}, {"modes", data.Modes}} { + if len(table.entries) == 0 { + t.Fatalf("%s is empty (body=%s)", table.name, body) + } + for _, e := range table.entries { + if e.ID == "" || e.Label == "" { + t.Errorf("%s entry %+v must carry both id and label", table.name, e) + } + } + } + return entryIDs(data.Platforms), entryIDs(data.Moods), entryIDs(data.Modes), data.Unknown +} + +func entryIDs(items []enumEntry) []string { + out := make([]string, 0, len(items)) + for _, it := range items { + out = append(out, it.ID) + } + return out +} + +// TestMetaEnumsShape pins the published contract: the three tables name the real +// platform/mood/mode values and the response carries the unknown-value fallback +// policy. +func TestMetaEnumsShape(t *testing.T) { + t.Parallel() + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + platforms, moods, modes, unknown := metaEnumsWire(t, ts, tok) + + wantPlatforms := []string{"web", "kook", "qq-obv11", "weixin-ilink", "telegram", "webhook"} + if !sameStringSet(platforms, wantPlatforms) { + t.Errorf("platforms = %v, want %v", platforms, wantPlatforms) + } + wantMoods := []string{"calm", "happy", "excited", "sad", "angry"} + if !sameStringSet(moods, wantMoods) { + t.Errorf("moods = %v, want %v", moods, wantMoods) + } + wantModes := []string{"chat", "code", "home", "work", "docs", "voice", "vision", "agents", "settings"} + if !sameStringSet(modes, wantModes) { + t.Errorf("modes = %v, want %v", modes, wantModes) + } + if unknown != "display-raw" { + t.Errorf("unknown = %q, want display-raw", unknown) + } +} + +// TestMetaEnumsAnyAuthenticatedRole asserts the endpoint is a read-only, +// non-sensitive table: every role may read it, and anonymous callers get 401 +// rather than the data. +func TestMetaEnumsAnyAuthenticatedRole(t *testing.T) { + t.Parallel() + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) + url := ts.URL + "/api/v1/meta/enums" + + for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer} { + tok := signAny(t, jwt, "acct", role) + resp, body := doReq(t, http.MethodGet, url, nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("role %s status = %d, want 200 (body=%s)", role, resp.StatusCode, body) + } + } + + resp, body := doReq(t, http.MethodGet, url, nil, "") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("anonymous status = %d, want 401 (body=%s)", resp.StatusCode, body) + } + wantErrorShape(t, body) +} + +// sameStringSet reports whether got and want hold the same values, ignoring order. +func sameStringSet(got, want []string) bool { + if len(got) != len(want) { + return false + } + seen := make(map[string]int, len(got)) + for _, v := range got { + seen[v]++ + } + for _, v := range want { + seen[v]-- + } + for _, n := range seen { + if n != 0 { + return false + } + } + return true +} diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go index 69edd54..a09847c 100644 --- a/internal/services/gateway/routes.go +++ b/internal/services/gateway/routes.go @@ -58,6 +58,12 @@ func (s *Server) registerUserRoutes( mux.Handle("POST /api/v1/chat", writeAuth(http.HandlerFunc(s.handleChat))) mux.Handle("GET /api/v1/sessions", anyAuth(http.HandlerFunc(s.handleListSessions))) mux.Handle("GET /api/v1/personas", anyAuth(http.HandlerFunc(s.handleListPersonas))) + // Shared client enum tables (Android collaboration B4, design/07 §6): the + // authority every client maps ids to labels from, so a source badge or a mood + // label cannot drift per client. A read-only projection of + // internal/contract/enums with nothing sensitive in it, so any authenticated + // role may read it (same gate as /sessions above). + mux.Handle("GET /api/v1/meta/enums", anyAuth(http.HandlerFunc(s.handleListEnums))) mux.Handle("POST /api/v1/audio/transcribe", ownerAuth(http.HandlerFunc(s.handleTranscribe))) mux.Handle("POST /api/v1/audio/speech", ownerAuth(http.HandlerFunc(s.handleSpeech)))