feat(history): 消息搜索/编辑/删除 + 会话置顶(D2/D3/D5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- history/message_ops.go(新): Search(会话内, q/after互斥, LIKE参数化+%/_转义+大小写不敏感, 严格限定会话doc) / UpdateMessage(返回更新后Message) / DeleteMessage / messageByID / escapeLike
- history/session_pin.go(新): SetPinned; 迁移0009 chat_sessions加pinned INTEGER DEFAULT 0; store.go SessionMeta.Pinned + createSessionsTable加列
- session_preview.go: 查询加s.pinned + ORDER BY pinned DESC,(type=main) DESC,created_at ASC
- gateway history_write.go(新,保持history.go≤500): handlePatchSession(pinned+title) / handlePatchMessage / handleDeleteMessage; server.go加PATCH/DELETE /api/v1/messages/{id} + /sessions/{id}置顶
- history.go: handleListMessages读q分流Search; 契约PATCH title/置顶
- D2会话内搜索(不做跨会话,隔离会话不外泄红线); D3不做重发端点(客户端编辑后再发); D5用pinned bool
- 测试(message_ops/history_messages/migrate_pinned), build/vet/test绿+3平台交叉编译; Docs/api-client v0.30
- 未做: 编辑/删除WS实时广播(message_edited/deleted), 下批
This commit is contained in:
AskaEth committed 2026-09-08 23:59:13 +08:00
1 parent c7b2cd8ae0
commit 844b2a74c1
15 files changed
+1091 -73

No files matched your search

+12 -6
View File
@@ -26,11 +26,13 @@
| 1b | `/api/v1/auth/logout` | POST | refreshToken 或 JWT | ✅ | **登出**(B2):body `{refreshToken}` 撤**单个** grant(幂等);带 `Authorization: Bearer <jwt>` 且无 body → 撤**该账号全部** grant(全设备登出,返回 `{revokedCount}`)。refresh 相关操作成功才审计 | | 1b | `/api/v1/auth/logout` | POST | refreshToken 或 JWT | ✅ | **登出**(B2):body `{refreshToken}` 撤**单个** grant(幂等);带 `Authorization: Bearer <jwt>` 且无 body → 撤**该账号全部** grant(全设备登出,返回 `{revokedCount}`)。refresh 相关操作成功才审计 |
| 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` | | 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` |
| 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,deviceId?,mode?,stream?}` → `{data:{reply}}`;`stream:true` 时经 WS 触发流式。`image` 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06)——**真视觉需配 `LLM_VISION_MODEL`/凭据,无凭据时降级为主模型直接看图或视觉·OCR 工具文本描述**;`deviceId` 为**客户端生成的具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3`,可选),落库到消息、便于主人知道从哪个端发来;`mode` 为**行为模式**(design/01 §3 / design/16 §5.1,如 `chat`/`code`/`home`/`docs`/…,可选),随该轮次落库——IM 恒 `chat`、缺省回落 `chat`,供前端按模式渲染气泡(仅 `chat` 才解析 `<p>` 拆气泡)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}` | | 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,deviceId?,mode?,stream?}` → `{data:{reply}}`;`stream:true` 时经 WS 触发流式。`image` 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06)——**真视觉需配 `LLM_VISION_MODEL`/凭据,无凭据时降级为主模型直接看图或视觉·OCR 工具文本描述**;`deviceId` 为**客户端生成的具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3`,可选),落库到消息、便于主人知道从哪个端发来;`mode` 为**行为模式**(design/01 §3 / design/16 §5.1,如 `chat`/`code`/`home`/`docs`/…,可选),随该轮次落库——IM 恒 `chat`、缺省回落 `chat`,供前端按模式渲染气泡(仅 `chat` 才解析 `<p>` 拆气泡)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}` |
| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,lastMessage?}]}`(主会话 `id="web"` 恒在首位,次级 `web:<uuid>`;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略);未注入时退化回渠道会话列表 | | 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,pinned,lastMessage?}]}`(**排序(D5):`pinned=true` 优先,其次主会话 `id="web"` 恒在前,再按 `createdAt` 升序**;次级 `web:<uuid>`;`pinned` 恒返回 bool;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略);未注入时退化回渠道会话列表 |
| 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId}}`(`sessionId="web:<uuid>"`);未注入 HistoryStore 回 `501 not_implemented` | | 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId}}`(`sessionId="web:<uuid>"`);未注入 HistoryStore 回 `501 not_implemented` |
| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501` | | 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501` |
| 6a | `/api/v1/messages/{id}` | PATCH | JWT(owner/admin) | ✅ | **编辑单条消息**(D3):body `{content}`(非空、≤4000 rune,空/纯空白/超长回 `400`)→ `{data:{message}}`(更新后的完整消息 DTO,含 `id/sessionId/role/content/.../createdAt`;**只改 `content`**,role/时间戳/平台保持不变);消息不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**重发不单独设端点**——客户端用「编辑后的内容再 `POST /chat`」即可(避免重复轮次语义) |
| 6b | `/api/v1/messages/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除单条消息**(D3)→ `{data:{deleted:<id>}}`;不存在回 `404 not_found`;未注入 HistoryStore 回 `501` |
| 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:<id>}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` | | 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):body `{title}`(trim、≤80 rune,空/超长回 `400`)→ `{data:{id,title}}`;不存在回 `404`;主会话(`id="web"`)可改名但不可删;未注入 HistoryStore 回 `501`。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 | | 8 | `/api/v1/sessions/{id}` | PATCH | JWT(owner/admin) | ✅ | **会话元数据更新**(A4 改名 + D5 置顶):body `{title?,pinned?}`(**至少一个字段**,都缺回 `400`)。`title` trim、≤80 rune,空/超长回 `400`;`pinned` 为 bool(置顶/取消,主会话与次级会话均可)。响应只回显**本次变更**的字段:`{data:{id,title?}}` / `{data:{id,pinned?}}` / 两者都改则都有(**title-only 保持 A4 原 `{id,title}` 形状**);不存在回 `404`;主会话(`id="web"`)可改名/置顶但不可删;未注入 HistoryStore 回 `501`。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 |
| 9 | `/api/v1/personas` | GET | JWT | ✅ | `{data:[persona...]}` | | 9 | `/api/v1/personas` | GET | JWT | ✅ | `{data:[persona...]}` |
| 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"`,非空文本省略),客户端据此提示「没识别到声音」) | | 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` 响应头**,客户端据此提示「没生成声音」) | | 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` 响应头**,客户端据此提示「没生成声音」) |
@@ -192,10 +194,13 @@
3. **带端标识**(可选但推荐):每条消息/请求可带 `deviceId`(见 §4.3),让她知道你从哪个端来。 3. **带端标识**(可选但推荐):每条消息/请求可带 `deviceId`(见 §4.3),让她知道你从哪个端来。
### 4.2 会话与消息用法(聊天) ### 4.2 会话与消息用法(聊天)
- **列出会话**:`GET /api/v1/sessions` → `[{id,type:"main"|"sub",title?,createdAt}]`。**主会话 `id="web"` 恒在首位**(她与你的主线,最大化继承上下文);次级 `web:<uuid>` 由 `POST /api/v1/sessions` 新建(临时小事/专业问题,独立上下文回溯)。 - **列出会话**:`GET /api/v1/sessions` → `[{id,type:"main"|"sub",title?,createdAt,pinned}]`。**排序:置顶会话(`pinned=true`)最前,其次主会话 `id="web"`**(她与你的主线,最大化继承上下文),再按创建时间升序;次级 `web:<uuid>` 由 `POST /api/v1/sessions` 新建(临时小事/专业问题,独立上下文回溯)。
- **拉取激活会话历史**:`GET /api/v1/sessions/{id}/messages?limit=50&before=<ms>&platform=`。 - **改名 / 置顶**:`PATCH /api/v1/sessions/{id}` body `{title?}`(≤80 rune)或 `{pinned?:bool}`(至少一个字段)→ `{data:{id,title?,pinned?}}`(只回显变更字段)。
- **拉取激活会话历史**:`GET /api/v1/sessions/{id}/messages?limit=50&before=<ms>&platform=&q=`。
- **热分页**:`limit` 默认 50(上限 200),`before` = 当前最旧一条的 `createdAt`(毫秒),向上翻页。`platform` 可选(IM 适配器传本平台只拉本渠道;Web/通用客户端不传 = 全量共享主会话)。 - **热分页**:`limit` 默认 50(上限 200),`before` = 当前最旧一条的 `createdAt`(毫秒),向上翻页。`platform` 可选(IM 适配器传本平台只拉本渠道;Web/通用客户端不传 = 全量共享主会话)。
- **搜索**:`q` 非空 = **会话内**内容搜索(倒序,仍支持 `before`/`limit` 分页);空 `q` = 普通分页;`q` 与 `after` 互斥。**不跨会话**。
- 返回**按时间倒序**,需 `reverse()` 成旧→新用于渲染。 - 返回**按时间倒序**,需 `reverse()` 成旧→新用于渲染。
- **编辑 / 删除单条消息**:`PATCH /api/v1/messages/{id}` `{content}`(≤4000 rune)→ `{data:{message}}`;`DELETE /api/v1/messages/{id}` → `{data:{deleted:<id>}}`。不存在回 `404`。**重发**用「编辑后内容再 `POST /chat`」即可(无独立重发端点)。
- **发消息**:`POST /api/v1/chat` `{text, deviceId?, image?, stream:true}`。`stream:true` 走 WS 流式;非流式返回完整 `reply`(会话正忙时 `202 queued`、排队满 `409 chat_busy`,见 §3.2)。 - **发消息**:`POST /api/v1/chat` `{text, deviceId?, image?, stream:true}`。`stream:true` 走 WS 流式;非流式返回完整 `reply`(会话正忙时 `202 queued`、排队满 `409 chat_busy`,见 §3.2)。
- **实时增量**:连接 WS 后,消息落库会广播 `message_persisted`(§3.3)——**用其中的 `id` 去重**,`sessionId` 匹配激活会话才渲染;新开/重连连接首帧即 `history` 回放(主会话最近 50 条)。 - **实时增量**:连接 WS 后,消息落库会广播 `message_persisted`(§3.3)——**用其中的 `id` 去重**,`sessionId` 匹配激活会话才渲染;新开/重连连接首帧即 `history` 回放(主会话最近 50 条)。
@@ -206,7 +211,7 @@
### 4.4 常见错误码 / 边界 ### 4.4 常见错误码 / 边界
- `401` 未认证/令牌失效 → 重新登录。`403` 权限不足(viewer 读管理写/主会话删除)。 - `401` 未认证/令牌失效 → 重新登录。`403` 权限不足(viewer 读管理写/主会话删除)。
- `404` 端点未实现;`501 not_implemented` 后端明确未提供(如知识库删除、新增用户)。 - `404` 资源不存在(消息/会话 id 未找到)或端点未实现;`501 not_implemented` 后端明确未提供(如知识库删除、新增用户)。
- `400` 请求体/参数非法(`limit`/`before` 越界、空 content)。`409 chat_busy` 排队满。 - `400` 请求体/参数非法(`limit`/`before` 越界、空 content)。`409 chat_busy` 排队满。
- **红线**:身份判定永远来自后端 channel 层 actor 元数据 + 白名单,**客户端不要用提示词/文本判身份**;`thinking`(思维链)在公开消息/回放中**已剥离**,客户端不应依赖它。 - **红线**:身份判定永远来自后端 channel 层 actor 元数据 + 白名单,**客户端不要用提示词/文本判身份**;`thinking`(思维链)在公开消息/回放中**已剥离**,客户端不应依赖它。
@@ -216,6 +221,7 @@
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。 - 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
## 5. 变更记录 ## 5. 变更记录
- `v0.30`(**Android 协同批次 ③a:D2 消息搜索 / D3 消息编辑删除 / D5 会话置顶**):① **D2**:`GET /api/v1/sessions/{id}/messages` 新增可选 **`q`**——**会话内**内容搜索(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 转义为字面量),仍倒序 + `before`/`limit` 游标;`q` 与 `after` 互斥(同传 `400`),空 `q` 走普通分页;**严格限定单会话,不新增跨会话搜索**(避免群聊/陌生人隔离会话外泄,红线)。`HistoryStore` 新增 `Search`。② **D3**:新增 `PATCH /api/v1/messages/{id}`(body `{content}`,非空 ≤4000 rune → `{data:{message}}`,只改 content)与 `DELETE /api/v1/messages/{id}`(→ `{data:{deleted:id}}`);不存在回 `404 not_found`;`HistoryStore` 新增 `UpdateMessage`/`DeleteMessage` + `ErrMessageNotFound`。**不新增重发端点**(客户端用编辑后的 content 再发)。③ **D5**:`chat_sessions` 新增 `pinned INTEGER NOT NULL DEFAULT 0`(迁移 **0009**,down 可逆);`GET /sessions` 排序改为 **`pinned DESC` → 主会话优先 → `createdAt ASC`**,会话 DTO 新增恒返回的 **`pinned`** bool;`PATCH /sessions/{id}` 扩展为 body `{title?,pinned?}`(至少一个字段,只回显变更字段,title-only 保持 A4 形状);`HistoryStore` 新增 `SetPinned`。全部为 owner/admin 写、读消息 owner/admin;未注入 HistoryStore 回 `501`。
- `v0.29`(**/admin/llm 读-改-写合并**):`POST /api/v1/admin/llm` 由「全量替换」改为**部分编辑合并**——请求体字段用指针区分「未给」与「显式零值」;`maxTokens` / `apiKey` / `provider` / `baseUrl` / `model` / `fallback` / `multimodal` **省略即保留原值**(不再把省略的 `maxTokens` 重置为 0),新组仍要求 `baseUrl`+`model`、非法 provider / 负 maxTokens 仍 `400`。面板 LLM 页新增每组 `maxTokens` 展示/编辑 + 新增/编辑表单;面板插件页新增「已装插件列表」+「安装 MCP 插件」表单(kind/name/transport/endpoint|command+args/apiKey/env),api.ts 请求体带 `config`(修掉旧 body 400)。`runtimeconfig/models.go` 新增 `Find`。 - `v0.29`(**/admin/llm 读-改-写合并**):`POST /api/v1/admin/llm` 由「全量替换」改为**部分编辑合并**——请求体字段用指针区分「未给」与「显式零值」;`maxTokens` / `apiKey` / `provider` / `baseUrl` / `model` / `fallback` / `multimodal` **省略即保留原值**(不再把省略的 `maxTokens` 重置为 0),新组仍要求 `baseUrl`+`model`、非法 provider / 负 maxTokens 仍 `400`。面板 LLM 页新增每组 `maxTokens` 展示/编辑 + 新增/编辑表单;面板插件页新增「已装插件列表」+「安装 MCP 插件」表单(kind/name/transport/endpoint|command+args/apiKey/env),api.ts 请求体带 `config`(修掉旧 body 400)。`runtimeconfig/models.go` 新增 `Find`。
- `v0.28`(**MCP 插件热安装/卸载**):`POST /api/v1/admin/plugins/install`(**由 501 改为真实实现**,owner-only + 审计)body `{kind:"mcp",name,config:{transport,endpoint?|command?,args?,apiKey?,env?}}` → `200 {data:{installed,name,kind,transport,toolCount}}`——真实连接 MCP server、发现并**热注册工具**进 capability registry(下一轮模型请求即注入),配置落盘 `plugins.json`(gitignore、0600);连接失败回 `502` 且**不落盘**;同名重复 = 刷新。`POST /api/v1/admin/plugins/uninstall` 扩展:MCP 插件走 `Unregister`(移除工具 + close + 删配置),普通能力保持原 `registry.Remove`。新增 `GET /api/v1/admin/plugins/installed`(owner/admin 读)列出已装插件(`apiKey`/敏感 `env` 掩码 + `live`/`toolCount`)。`mcp.MCPConfig` 新增 `APIKey`(http/sse 以 `Authorization: Bearer` 发送)与 `Env`(stdio 子进程环境);`plugins.json` 于启动时 `Restore` 重新安装。**契约版本 `v0.28`**。 - `v0.28`(**MCP 插件热安装/卸载**):`POST /api/v1/admin/plugins/install`(**由 501 改为真实实现**,owner-only + 审计)body `{kind:"mcp",name,config:{transport,endpoint?|command?,args?,apiKey?,env?}}` → `200 {data:{installed,name,kind,transport,toolCount}}`——真实连接 MCP server、发现并**热注册工具**进 capability registry(下一轮模型请求即注入),配置落盘 `plugins.json`(gitignore、0600);连接失败回 `502` 且**不落盘**;同名重复 = 刷新。`POST /api/v1/admin/plugins/uninstall` 扩展:MCP 插件走 `Unregister`(移除工具 + close + 删配置),普通能力保持原 `registry.Remove`。新增 `GET /api/v1/admin/plugins/installed`(owner/admin 读)列出已装插件(`apiKey`/敏感 `env` 掩码 + `live`/`toolCount`)。`mcp.MCPConfig` 新增 `APIKey`(http/sse 以 `Authorization: Bearer` 发送)与 `Env`(stdio 子进程环境);`plugins.json` 于启动时 `Restore` 重新安装。**契约版本 `v0.28`**。
- `v0.27`(**LLM token 预算可配置化**):`models.json` 模型组新增可选 **`maxTokens`**(`int`,缺省/`0` = 未设),作为该组主/备链的 token 预算;`GET /api/v1/admin/llm` 的 `AdminLlmProvider` 与 `POST /api/v1/admin/llm` body/回显同步该字段(`omitempty`,未设为 0 时不出现)。`.env` 新增 **`LLM_FALLBACK_MAX_TOKENS`**(`int`,默认 `8192`,`0` = 关闭兜底),只作用于**确有回退模型**时链末端的回退步。**优先级**:显式 `Request.MaxTokens`(审查/判定类小预算)> 生效组 `maxTokens` > `LLM_FALLBACK_MAX_TOKENS`(仅链末端、且组未设时)> 适配器默认(anthropic 8192)。单步链(无 fallback)不套用 env 兜底。`maxTokens` 为负回 `400 bad_request`。**契约版本 `v0.27`**。 - `v0.27`(**LLM token 预算可配置化**):`models.json` 模型组新增可选 **`maxTokens`**(`int`,缺省/`0` = 未设),作为该组主/备链的 token 预算;`GET /api/v1/admin/llm` 的 `AdminLlmProvider` 与 `POST /api/v1/admin/llm` body/回显同步该字段(`omitempty`,未设为 0 时不出现)。`.env` 新增 **`LLM_FALLBACK_MAX_TOKENS`**(`int`,默认 `8192`,`0` = 关闭兜底),只作用于**确有回退模型**时链末端的回退步。**优先级**:显式 `Request.MaxTokens`(审查/判定类小预算)> 生效组 `maxTokens` > `LLM_FALLBACK_MAX_TOKENS`(仅链末端、且组未设时)> 适配器默认(anthropic 8192)。单步链(无 fallback)不套用 env 兜底。`maxTokens` 为负回 `400 bad_request`。**契约版本 `v0.27`**。
+58
View File
@@ -0,0 +1,58 @@
package db
import (
"context"
"database/sql"
"testing"
)
// columnExists reports whether table has a column named column.
func columnExists(t *testing.T, ctx context.Context, conn *sql.DB, table, column string) bool {
t.Helper()
var n int
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM pragma_table_info(?) WHERE name = ?", table, column).Scan(&n); err != nil {
t.Fatalf("pragma_table_info(%s): %v", table, err)
}
return n == 1
}
// TestMigrate0009PinnedColumnIdempotent verifies the pinned migration (Android
// collaboration batch ③a / D5) is reversible and re-appliable: up adds the column,
// a second up is a no-op, down drops it, and a re-up restores it.
func TestMigrate0009PinnedColumnIdempotent(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := openMemory(t)
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
if !columnExists(t, ctx, conn, "chat_sessions", "pinned") {
t.Fatal("chat_sessions.pinned should exist after MigrateUp")
}
// Up twice: the recorded migration is skipped, so the column stays and no error.
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp second call: %v", err)
}
if !columnExists(t, ctx, conn, "chat_sessions", "pinned") {
t.Fatal("chat_sessions.pinned should still exist after idempotent re-run")
}
// Down one step rolls back only 0009, dropping the column.
if err := MigrateDown(ctx, conn, 1, testLogger()); err != nil {
t.Fatalf("MigrateDown(1): %v", err)
}
if columnExists(t, ctx, conn, "chat_sessions", "pinned") {
t.Fatal("chat_sessions.pinned should be dropped by the 0009 down")
}
// Re-applying restores it end to end.
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("re-MigrateUp: %v", err)
}
if !columnExists(t, ctx, conn, "chat_sessions", "pinned") {
t.Fatal("chat_sessions.pinned should exist again after re-MigrateUp")
}
}
@@ -0,0 +1,6 @@
-- 0009_chat_session_pinned down: drop the pinned ordering column added by the up
-- migration. SQLite DROP COLUMN (3.35+) is already relied on by 0006/0007, and the
-- column is not referenced by any index, so a plain drop is safe and lossless (the
-- pin is order metadata, not data the session needs to remain valid).
ALTER TABLE chat_sessions DROP COLUMN pinned;
@@ -0,0 +1,12 @@
-- 0009_chat_session_pinned: pin a persistent chat session to the top of the session
-- list (DevDocs/design/16 §2.2, Android 协同批次 ③a / D5). `pinned` is display/order
-- metadata only: it never affects identity or permissions (project red line), and any
-- session — the fixed main session or a sub session — may be pinned.
--
-- The 0003 chat_sessions table already exists at this point, so ALTER ADD COLUMN is
-- the correct, non-destructive step. The canonical schema (including this column) is
-- also re-issued idempotently by internal/runtime/history.NewStore so a fresh
-- in-memory store used by tests stays self-contained; the migration is the
-- authoritative path for an existing database.
ALTER TABLE chat_sessions ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;
+113
View File
@@ -0,0 +1,113 @@
package history
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
// This file owns the single-message operations behind the Android collaboration batch
// ③a (D2 search, D3 edit/delete). Both are strictly session-scoped or id-scoped: a
// message is only ever reached through the session it belongs to (Search) or its own
// opaque id (UpdateMessage/DeleteMessage), so an isolated group/stranger session can
// never surface in another session's results (project red line).
// Search returns up to limit messages for sessionID whose content contains query,
// newest first. The match is a case-insensitive ASCII LIKE (SQLite's default for
// LIKE) with the query's %/_/\ escaped so they match literally; before is the same
// millisecond cursor as List (strictly older than before) and limit is clamped to
// [1, maxPageLimit]. rowid breaks created_at ties so same-millisecond messages keep a
// stable order. An empty query returns an empty (non-nil) slice — the caller uses
// List for plain paging instead. Search never crosses the session boundary.
func (s *Store) Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit)
if query == "" {
return make([]Message, 0), nil
}
sqlText := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
FROM chat_messages WHERE session_id = ? AND content LIKE ? ESCAPE '\'`
args := []any{sessionID, "%" + escapeLike(query) + "%"}
if before > 0 {
sqlText += ` AND created_at < ?`
args = append(args, before)
}
sqlText += ` ORDER BY created_at DESC, rowid DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, fmt.Errorf("history: search %s: %w", sessionID, err)
}
defer func() { _ = rows.Close() }()
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan search %s: %w", sessionID, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate search %s: %w", sessionID, err)
}
return msgs, nil
}
// UpdateMessage replaces the content of the message with id and returns the updated
// message (like AppendFull, so the caller can return/broadcast it without a second
// read). Only content changes; role, platform, timestamps and the rest stay as stored.
// An unknown id returns ErrMessageNotFound.
func (s *Store) UpdateMessage(ctx context.Context, id, content string) (Message, error) {
res, err := s.db.ExecContext(ctx, `UPDATE chat_messages SET content = ? WHERE id = ?`, content, id)
if err != nil {
return Message{}, fmt.Errorf("history: update message %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return Message{}, fmt.Errorf("history: update message %s rows: %w", id, err)
}
if n == 0 {
return Message{}, fmt.Errorf("history: update message %s: %w", id, ErrMessageNotFound)
}
return s.messageByID(ctx, id)
}
// DeleteMessage removes a single message. An unknown id returns ErrMessageNotFound.
func (s *Store) DeleteMessage(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM chat_messages WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("history: delete message %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("history: delete message %s rows: %w", id, err)
}
if n == 0 {
return fmt.Errorf("history: delete message %s: %w", id, ErrMessageNotFound)
}
return nil
}
// messageByID reads one message by id, returning ErrMessageNotFound when the row is
// absent.
func (s *Store) messageByID(ctx context.Context, id string) (Message, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
FROM chat_messages WHERE id = ?`, id)
m, err := scanMessage(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Message{}, fmt.Errorf("history: read message %s: %w", id, ErrMessageNotFound)
}
return Message{}, fmt.Errorf("history: read message %s: %w", id, err)
}
return m, nil
}
// escapeLike escapes the LIKE wildcards in a user query so %, _ and \ match literally
// (the caller pairs it with ESCAPE '\'). It is applied before the %...% wrapping.
func escapeLike(query string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(query)
}
@@ -0,0 +1,249 @@
package history
import (
"context"
"errors"
"testing"
)
// seedMessages appends msgs and returns them with their store-assigned ids.
func seedMessages(t *testing.T, st *Store, msgs ...Message) []Message {
t.Helper()
out := make([]Message, 0, len(msgs))
for _, m := range msgs {
if err := st.Append(context.Background(), m); err != nil {
t.Fatalf("append %q: %v", m.Content, err)
}
out = append(out, m)
}
return out
}
func TestSearchSessionScopedNewestFirstAndPaged(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
seedMessages(t, st,
Message{SessionID: MainSessionID, Role: RoleUser, Content: "hit one", CreatedAt: 1000},
Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "miss", CreatedAt: 1500},
Message{SessionID: MainSessionID, Role: RoleUser, Content: "hit two", CreatedAt: 2000},
Message{SessionID: MainSessionID, Role: RoleUser, Content: "hit three", CreatedAt: 3000},
Message{SessionID: MainSessionID, Role: RoleUser, Content: "hit four", CreatedAt: 4000},
)
// Newest-first page of two hits; the non-matching message is skipped.
page, err := st.Search(ctx, MainSessionID, "hit", 0, 2)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(page) != 2 || page[0].CreatedAt != 4000 || page[1].CreatedAt != 3000 {
t.Fatalf("page = %+v, want [4000 3000]", page)
}
// before cursor walks to the older hits.
older, err := st.Search(ctx, MainSessionID, "hit", 3000, 2)
if err != nil {
t.Fatalf("search older: %v", err)
}
if len(older) != 2 || older[0].CreatedAt != 2000 || older[1].CreatedAt != 1000 {
t.Fatalf("older = %+v, want [2000 1000]", older)
}
// An empty query yields an empty (non-nil) slice, not the whole session.
empty, err := st.Search(ctx, MainSessionID, "", 0, 10)
if err != nil {
t.Fatalf("search empty: %v", err)
}
if empty == nil || len(empty) != 0 {
t.Fatalf("empty query = %#v, want empty non-nil", empty)
}
}
func TestSearchCaseInsensitiveAndLiteralWildcards(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
seedMessages(t, st,
Message{SessionID: MainSessionID, Role: RoleUser, Content: "Hello World", CreatedAt: 1000},
Message{SessionID: MainSessionID, Role: RoleUser, Content: "100% done", CreatedAt: 2000},
Message{SessionID: MainSessionID, Role: RoleUser, Content: "under_score", CreatedAt: 3000},
)
// ASCII case-insensitive LIKE.
got, err := st.Search(ctx, MainSessionID, "hello", 0, 10)
if err != nil {
t.Fatalf("search hello: %v", err)
}
if len(got) != 1 || got[0].Content != "Hello World" {
t.Fatalf("hello = %+v, want [Hello World]", got)
}
// A literal % must not act as a wildcard (else it would match every row).
pct, err := st.Search(ctx, MainSessionID, "100%", 0, 10)
if err != nil {
t.Fatalf("search 100%%: %v", err)
}
if len(pct) != 1 || pct[0].Content != "100% done" {
t.Fatalf("100%% = %+v, want [100%% done]", pct)
}
// A literal _ must match only the underscore, not any single character.
us, err := st.Search(ctx, MainSessionID, "under_", 0, 10)
if err != nil {
t.Fatalf("search under_: %v", err)
}
if len(us) != 1 || us[0].Content != "under_score" {
t.Fatalf("under_ = %+v, want [under_score]", us)
}
}
// TestSearchNeverCrossesSessionBoundary is the red-line guard: a search is scoped to
// one session, so an isolated group/stranger session's content can never surface in
// the main session's results (or vice versa).
func TestSearchNeverCrossesSessionBoundary(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
sub, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub: %v", err)
}
seedMessages(t, st,
Message{SessionID: MainSessionID, Role: RoleUser, Content: "secret-token main", CreatedAt: 1000},
Message{SessionID: sub, Role: RoleUser, Content: "secret-token sub", CreatedAt: 2000},
Message{SessionID: "sess-group", Role: RoleUser, Content: "secret-token group", CreatedAt: 3000, SenderName: "第三方"},
)
main, err := st.Search(ctx, MainSessionID, "secret-token", 0, 10)
if err != nil {
t.Fatalf("search main: %v", err)
}
if len(main) != 1 || main[0].SessionID != MainSessionID {
t.Fatalf("main search = %+v, want only the main-session hit", main)
}
subHits, err := st.Search(ctx, sub, "secret-token", 0, 10)
if err != nil {
t.Fatalf("search sub: %v", err)
}
if len(subHits) != 1 || subHits[0].SessionID != sub {
t.Fatalf("sub search = %+v, want only the sub hit", subHits)
}
}
func TestUpdateMessageContentOnly(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
seedMessages(t, st, Message{ID: "m1", SessionID: MainSessionID, Role: RoleUser, Content: "before", CreatedAt: 1000})
got, err := st.UpdateMessage(ctx, "m1", "after")
if err != nil {
t.Fatalf("update: %v", err)
}
if got.ID != "m1" || got.Content != "after" || got.Role != RoleUser || got.CreatedAt != 1000 || got.SessionID != MainSessionID {
t.Fatalf("updated = %+v, want content=after with role/ts/session preserved", got)
}
// The edit is persisted, not just echoed.
listed, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(listed) != 1 || listed[0].Content != "after" {
t.Fatalf("listed = %+v, want content=after", listed)
}
// An unknown id returns the sentinel.
if _, err := st.UpdateMessage(ctx, "missing", "x"); !errors.Is(err, ErrMessageNotFound) {
t.Fatalf("update missing err = %v, want ErrMessageNotFound", err)
}
}
func TestDeleteMessage(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
seedMessages(t, st,
Message{ID: "m1", SessionID: MainSessionID, Role: RoleUser, Content: "keep", CreatedAt: 1000},
Message{ID: "m2", SessionID: MainSessionID, Role: RoleAssistant, Content: "drop", CreatedAt: 2000},
)
if err := st.DeleteMessage(ctx, "m2"); err != nil {
t.Fatalf("delete: %v", err)
}
listed, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(listed) != 1 || listed[0].ID != "m1" {
t.Fatalf("listed = %+v, want only m1", listed)
}
if err := st.DeleteMessage(ctx, "m2"); !errors.Is(err, ErrMessageNotFound) {
t.Fatalf("delete again err = %v, want ErrMessageNotFound", err)
}
}
func TestSetPinnedOrdersSessions(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
first, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub 1: %v", err)
}
second, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub 2: %v", err)
}
// Baseline: main first, subs oldest-first.
assertSessionOrder(t, st, []string{MainSessionID, first, second})
// Pinning the newest sub floats it above the main session.
if err := st.SetPinned(ctx, second, true); err != nil {
t.Fatalf("pin: %v", err)
}
assertSessionOrder(t, st, []string{second, MainSessionID, first})
if metas := listMetas(t, st); !metas[0].Pinned {
t.Fatalf("pinned session meta = %+v, want Pinned=true", metas[0])
}
// Unpinning restores the baseline order.
if err := st.SetPinned(ctx, second, false); err != nil {
t.Fatalf("unpin: %v", err)
}
assertSessionOrder(t, st, []string{MainSessionID, first, second})
// An unknown session id is a not-found error.
if err := st.SetPinned(ctx, "web:missing", true); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("pin missing err = %v, want ErrSessionNotFound", err)
}
}
// listMetas returns ListSessions output for order/pin assertions.
func listMetas(t *testing.T, st *Store) []SessionMeta {
t.Helper()
metas, err := st.ListSessions(context.Background())
if err != nil {
t.Fatalf("list sessions: %v", err)
}
return metas
}
// assertSessionOrder fails unless ListSessions returns exactly ids in order.
func assertSessionOrder(t *testing.T, st *Store, ids []string) {
t.Helper()
metas := listMetas(t, st)
got := make([]string, 0, len(metas))
for _, m := range metas {
got = append(got, m.ID)
}
if len(got) != len(ids) {
t.Fatalf("session order = %v, want %v", got, ids)
}
for i := range ids {
if got[i] != ids[i] {
t.Fatalf("session order = %v, want %v", got, ids)
}
}
}
+33
View File
@@ -0,0 +1,33 @@
package history
import (
"context"
"fmt"
)
// SetPinned pins or unpins a persistent session. Pinning is display/order metadata
// only (like the title): both the fixed main session and sub sessions may be pinned,
// and identity/permissions never derive from it. An unknown id returns
// ErrSessionNotFound.
func (s *Store) SetPinned(ctx context.Context, sessionID string, pinned bool) error {
res, err := s.db.ExecContext(ctx, `UPDATE chat_sessions SET pinned = ? WHERE id = ?`, pinnedValue(pinned), sessionID)
if err != nil {
return fmt.Errorf("history: set pinned %s: %w", sessionID, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("history: set pinned %s rows: %w", sessionID, err)
}
if n == 0 {
return fmt.Errorf("history: set pinned %s: %w", sessionID, ErrSessionNotFound)
}
return nil
}
// pinnedValue maps the boolean to SQLite's 0/1 integer representation.
func pinnedValue(pinned bool) int {
if pinned {
return 1
}
return 0
}
+11 -8
View File
@@ -29,8 +29,9 @@ type MessageSummary struct {
// LEFT JOIN's correlated subquery resolves each session's newest row via the // LEFT JOIN's correlated subquery resolves each session's newest row via the
// (session_id, created_at DESC) index (rowid DESC breaks a same-millisecond tie by // (session_id, created_at DESC) index (rowid DESC breaks a same-millisecond tie by
// insertion order), so a session with no messages still appears with NULL preview // insertion order), so a session with no messages still appears with NULL preview
// columns. Ordering keeps the main session first, then subs oldest-first. // columns. Ordering floats pinned sessions first, then keeps the main session before
const listSessionsQuery = `SELECT s.id, s.type, s.title, s.created_at, // unpinned subs, then subs oldest-first.
const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at,
m.role, m.content, m.created_at m.role, m.content, m.created_at
FROM chat_sessions s FROM chat_sessions s
LEFT JOIN chat_messages m ON m.rowid = ( LEFT JOIN chat_messages m ON m.rowid = (
@@ -39,12 +40,12 @@ const listSessionsQuery = `SELECT s.id, s.type, s.title, s.created_at,
ORDER BY created_at DESC, rowid DESC ORDER BY created_at DESC, rowid DESC
LIMIT 1 LIMIT 1
) )
ORDER BY (s.type = ?) DESC, s.created_at ASC` ORDER BY s.pinned DESC, (s.type = ?) DESC, s.created_at ASC`
// ListSessions returns the main session first, then every sub session, oldest first, // ListSessions returns pinned sessions first, then the main session, then every sub
// each carrying a LastMessage preview of its newest message (nil when the session has // session, oldest first, each carrying a LastMessage preview of its newest message (nil
// none). Sessions and previews are read in a single query, so the query count does not // when the session has none). Sessions and previews are read in a single query, so the
// grow with the number of sessions. The returned slice is non-nil. // query count does not grow with the number of sessions. The returned slice is non-nil.
func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) { func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) {
rows, err := s.db.QueryContext(ctx, listSessionsQuery, SessionMain) rows, err := s.db.QueryContext(ctx, listSessionsQuery, SessionMain)
if err != nil { if err != nil {
@@ -73,14 +74,16 @@ func scanSessionMeta(sc rowScanner) (SessionMeta, error) {
var ( var (
meta SessionMeta meta SessionMeta
typeStr string typeStr string
pinned int
role sql.NullString role sql.NullString
content sql.NullString content sql.NullString
lastAt sql.NullInt64 lastAt sql.NullInt64
) )
if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &meta.CreatedAt, &role, &content, &lastAt); err != nil { if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &pinned, &meta.CreatedAt, &role, &content, &lastAt); err != nil {
return SessionMeta{}, err return SessionMeta{}, err
} }
meta.Type = typeStr meta.Type = typeStr
meta.Pinned = pinned != 0
if role.Valid && lastAt.Valid { if role.Valid && lastAt.Valid {
meta.LastMessage = &MessageSummary{ meta.LastMessage = &MessageSummary{
Role: role.String, Role: role.String,
+29 -1
View File
@@ -62,6 +62,10 @@ var (
// ErrImmutableMain is returned when a caller tries to delete the fixed main session. // ErrImmutableMain is returned when a caller tries to delete the fixed main session.
ErrImmutableMain = errors.New("history: the main session cannot be deleted") ErrImmutableMain = errors.New("history: the main session cannot be deleted")
// ErrMessageNotFound is returned by UpdateMessage/DeleteMessage when no
// chat_messages row carries the requested id.
ErrMessageNotFound = errors.New("history: message not found")
) )
// Message is one persisted chat message. CreatedAt is a Unix millisecond timestamp and // Message is one persisted chat message. CreatedAt is a Unix millisecond timestamp and
@@ -109,6 +113,9 @@ type SessionMeta struct {
Type string // "main" | "sub" Type string // "main" | "sub"
Title string Title string
CreatedAt int64 CreatedAt int64
// Pinned floats the session to the top of ListSessions. It is display/order
// metadata only; identity and permissions never derive from it.
Pinned bool
// LastMessage is a display-only preview of the session's newest message, filled // LastMessage is a display-only preview of the session's newest message, filled
// by ListSessions in the same query. It is nil for a session with no messages. // by ListSessions in the same query. It is nil for a session with no messages.
// A client uses it to render the conversation row without a per-session request; // A client uses it to render the conversation row without a per-session request;
@@ -161,13 +168,33 @@ type HistoryStore interface {
// DeleteSession removes a sub session and all its messages. The main session is // DeleteSession removes a sub session and all its messages. The main session is
// immutable and returns ErrImmutableMain. // immutable and returns ErrImmutableMain.
DeleteSession(ctx context.Context, sessionID string) error DeleteSession(ctx context.Context, sessionID string) error
// Search returns up to limit messages for sessionID whose content contains query
// (case-insensitive ASCII LIKE, with %/_/\ escaped), newest first. before is the
// same millisecond cursor as List; limit is clamped to [1, maxPageLimit]. An empty
// query returns an empty slice (the caller falls back to List for plain paging).
// Search is always scoped to one session, so an isolated group/stranger session can
// never surface in another session's results.
Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]Message, error)
// UpdateMessage replaces the content of the message with id and returns the
// updated message (like AppendFull, so the caller can broadcast it without a
// second read). Only content changes; role/timestamps/platform stay as stored.
// An unknown id returns ErrMessageNotFound.
UpdateMessage(ctx context.Context, id, content string) (Message, error)
// DeleteMessage removes a single message. An unknown id returns
// ErrMessageNotFound.
DeleteMessage(ctx context.Context, id string) error
// RenameSession sets a session's display title. Both the main session and sub // RenameSession sets a session's display title. Both the main session and sub
// sessions may be renamed (a title is display-only metadata); an unknown id // sessions may be renamed (a title is display-only metadata); an unknown id
// returns ErrSessionNotFound. // returns ErrSessionNotFound.
RenameSession(ctx context.Context, sessionID, title string) error RenameSession(ctx context.Context, sessionID, title string) error
// SetPinned pins or unpins a session. Pinning is display/order metadata only;
// both the main session and sub sessions may be pinned. An unknown id returns
// ErrSessionNotFound.
SetPinned(ctx context.Context, sessionID string, pinned bool) error
// NewSubSession creates a new sub session and returns its id (MainSessionID:uuid). // NewSubSession creates a new sub session and returns its id (MainSessionID:uuid).
NewSubSession(ctx context.Context, ownerID string) (string, error) NewSubSession(ctx context.Context, ownerID string) (string, error)
// ListSessions returns the main session first then every sub session, oldest first. // ListSessions returns pinned sessions first, then the main session, then every
// sub session oldest first.
ListSessions(ctx context.Context) ([]SessionMeta, error) ListSessions(ctx context.Context) ([]SessionMeta, error)
} }
@@ -551,6 +578,7 @@ CREATE TABLE IF NOT EXISTS chat_sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
type TEXT NOT NULL, type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
pinned INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
)` )`
@@ -279,9 +279,17 @@ func (c *captureHistory) ListAllLog(context.Context, int64, int) ([]history.Mess
return nil, nil return nil, nil
} }
func (c *captureHistory) DeleteSession(context.Context, string) error { return nil } func (c *captureHistory) DeleteSession(context.Context, string) error { return nil }
func (c *captureHistory) Search(context.Context, string, string, int64, int) ([]history.Message, error) {
return nil, nil
}
func (c *captureHistory) UpdateMessage(_ context.Context, id, content string) (history.Message, error) {
return history.Message{ID: id, Content: content}, nil
}
func (c *captureHistory) DeleteMessage(context.Context, string) error { return nil }
func (c *captureHistory) RenameSession(context.Context, string, string) error { func (c *captureHistory) RenameSession(context.Context, string, string) error {
return nil return nil
} }
func (c *captureHistory) SetPinned(context.Context, string, bool) error { return nil }
func (c *captureHistory) NewSubSession(context.Context, string) (string, error) { func (c *captureHistory) NewSubSession(context.Context, string) (string, error) {
return "", nil return "", nil
} }
+19 -57
View File
@@ -1,12 +1,10 @@
package gateway package gateway
import ( import (
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
@@ -20,6 +18,9 @@ type historySessionSummary struct {
Type string `json:"type"` // "main" | "sub" Type string `json:"type"` // "main" | "sub"
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
CreatedAt int64 `json:"createdAt"` CreatedAt int64 `json:"createdAt"`
// Pinned floats the session to the top of the list (D5). It is order metadata
// only and is always present so a client can render/sort without a second call.
Pinned bool `json:"pinned"`
// LastMessage is a display-only preview of the session's newest message, so the // LastMessage is a display-only preview of the session's newest message, so the
// client can render a conversation row (e.g. the Android session list) without a // client can render a conversation row (e.g. the Android session list) without a
// per-session request. It is omitted for a session with no messages. Content is // per-session request. It is omitted for a session with no messages. Content is
@@ -76,6 +77,7 @@ func (s *Server) handleHistorySessions(w http.ResponseWriter, r *http.Request) {
Type: m.Type, Type: m.Type,
Title: m.Title, Title: m.Title,
CreatedAt: m.CreatedAt, CreatedAt: m.CreatedAt,
Pinned: m.Pinned,
LastMessage: toHistoryLastMessage(m.LastMessage), LastMessage: toHistoryLastMessage(m.LastMessage),
}) })
} }
@@ -122,8 +124,11 @@ func (s *Server) handleCreateSubSession(w http.ResponseWriter, r *http.Request)
// one-page limit of a backward walk. // one-page limit of a backward walk.
// //
// limit defaults to 50 (clamped to 200) for both directions. platform filters the page // limit defaults to 50 (clamped to 200) for both directions. platform filters the page
// by source platform in either direction. Supplying both before and after (both > 0) // by source platform in either direction. An optional q switches the page to a
// answers 400, as does a non-numeric/negative after or an invalid limit/before. // session-scoped content search (newest first, still honoring before/limit); q is
// mutually exclusive with after, and an empty q is ignored so plain paging is
// unchanged. Supplying both before and after (both > 0) answers 400, as does a
// non-numeric/negative after or an invalid limit/before.
func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
if s.history == nil { if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented}) writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
@@ -163,12 +168,20 @@ func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
return return
} }
platform := r.URL.Query().Get("platform") platform := r.URL.Query().Get("platform")
query := strings.TrimSpace(r.URL.Query().Get("q"))
if query != "" && after > 0 {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "q and after are mutually exclusive"))
return
}
var msgs []history.Message var msgs []history.Message
var err error var err error
if after > 0 { switch {
case query != "":
msgs, err = s.history.Search(r.Context(), id, query, before, limit)
case after > 0:
msgs, err = s.history.ListAfter(r.Context(), id, after, limit, platform) msgs, err = s.history.ListAfter(r.Context(), id, after, limit, platform)
} else { default:
msgs, err = s.history.List(r.Context(), id, before, limit, platform) msgs, err = s.history.List(r.Context(), id, before, limit, platform)
} }
if err != nil { if err != nil {
@@ -322,57 +335,6 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"deleted": id}) writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
} }
// renameSessionRequest is the PATCH /api/v1/sessions/{id} body.
type renameSessionRequest struct {
Title string `json:"title"`
}
// maxSessionTitleRunes bounds a client-supplied session title so a display row never
// overflows. It is a display-only bound: identity and permissions never derive from
// the title (project red line).
const maxSessionTitleRunes = 80
// handleRenameSession renames a persistent session (main or sub). The title is
// display-only metadata, so unlike DELETE it is allowed on the fixed main session. An
// empty/whitespace title or one longer than maxSessionTitleRunes answers 400; an
// unknown session id answers 404. It is owner/admin-gated like the other history
// writes.
func (s *Server) handleRenameSession(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "session id is required"))
return
}
var req renameSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
title := strings.TrimSpace(req.Title)
if title == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title is required"))
return
}
if utf8.RuneCountInString(title) > maxSessionTitleRunes {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title is too long (max 80 characters)"))
return
}
if err := s.history.RenameSession(r.Context(), id, title); err != nil {
if errors.Is(err, history.ErrSessionNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "session %q not found", id))
return
}
s.log.Error("history rename session", "err", err, "session", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to rename session"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "title": title})
}
// toHistoryMessage maps a stored history.Message to its wire shape. // toHistoryMessage maps a stored history.Message to its wire shape.
func toHistoryMessage(m history.Message) historyMessage { func toHistoryMessage(m history.Message) historyMessage {
return historyMessage{ return historyMessage{
@@ -0,0 +1,335 @@
package gateway
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// seedFullMessage appends m through the real store and returns the stored message
// (with its generated id), so HTTP tests can address a specific message.
func seedFullMessage(t *testing.T, st *history.Store, m history.Message) history.Message {
t.Helper()
got, err := st.AppendFull(context.Background(), m)
if err != nil {
t.Fatalf("append %q: %v", m.Content, err)
}
return got
}
// TestHistorySearchMessagesHTTP covers D2: session-scoped ?q= search hits, paging,
// the empty-q fallback, input validation and isolation from another session.
func TestHistorySearchMessagesHTTP(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
ctx := context.Background()
for _, m := range []history.Message{
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "今天天气不错", CreatedAt: 1000},
{SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "嗯,适合出门", CreatedAt: 1500},
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "天气要变凉了", CreatedAt: 2000},
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "天气又热了", CreatedAt: 3000},
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "天气终于晴了", CreatedAt: 4000},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
// A matching message in another (sub) session must never surface in the main search.
sub, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub: %v", err)
}
if err := st.Append(ctx, history.Message{SessionID: sub, Role: history.RoleUser, Content: "天气 sub", CreatedAt: 5000}); err != nil {
t.Fatalf("seed sub: %v", err)
}
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Newest-first page of 2 matching hits.
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&limit=2", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("search status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var page []historyMessage
decodeDataSlice(t, body, &page)
if len(page) != 2 || page[0].CreatedAt != 4000 || page[1].CreatedAt != 3000 {
t.Fatalf("search page = %+v, want [4000 3000]", page)
}
if page[0].SessionID != history.MainSessionID {
t.Fatalf("search leaked session %q", page[0].SessionID)
}
// before cursor pages to the older hits.
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&before=3000&limit=2", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("search older status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var older []historyMessage
decodeDataSlice(t, body, &older)
if len(older) != 2 || older[0].CreatedAt != 2000 || older[1].CreatedAt != 1000 {
t.Fatalf("search older = %+v, want [2000 1000]", older)
}
// The sub session's search sees only its own hit.
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+sub+"/messages?q=天气", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("sub search status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var subHits []historyMessage
decodeDataSlice(t, body, &subHits)
if len(subHits) != 1 || subHits[0].SessionID != sub {
t.Fatalf("sub search = %+v, want only the sub hit", subHits)
}
// An empty q falls back to plain paging (all messages).
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=&limit=10", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("empty q status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var all []historyMessage
decodeDataSlice(t, body, &all)
if len(all) != 5 {
t.Fatalf("empty q = %d messages, want 5", len(all))
}
// q is mutually exclusive with after, and invalid limit still 400.
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&after=1000", nil, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("q+after status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&limit=abc", nil, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("bad limit status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
}
// TestHistoryPatchAndDeleteMessageHTTP covers D3: editing and deleting one message,
// the input bounds, 404 and RBAC.
func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
msg := seedFullMessage(t, st, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "原文", CreatedAt: 1000})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
base := ts.URL + "/api/v1/messages/" + msg.ID
// Edit the content; role/timestamp/session are preserved and echoed back.
resp, body := doReq(t, http.MethodPatch, base, map[string]any{"content": "改后的正文"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var updated historyMessage
if err := json.Unmarshal(decodeBody(t, body)["data"], &updated); err != nil {
t.Fatalf("decode patch data: %v (body=%s)", err, body)
}
if updated.ID != msg.ID || updated.Content != "改后的正文" || updated.Role != history.RoleUser || updated.CreatedAt != 1000 {
t.Fatalf("updated = %+v, want content edited with role/ts preserved", updated)
}
// The edit is persisted.
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var listed []historyMessage
decodeDataSlice(t, body, &listed)
if len(listed) != 1 || listed[0].Content != "改后的正文" {
t.Fatalf("listed = %+v, want the edited content", listed)
}
// Invalid edits: empty/whitespace, missing field, over-long, unknown id.
for _, tc := range []struct {
name string
body any
want int
}{
{"empty", map[string]any{"content": ""}, http.StatusBadRequest},
{"blank", map[string]any{"content": " "}, http.StatusBadRequest},
{"missing", map[string]any{}, http.StatusBadRequest},
{"too-long", map[string]any{"content": strings.Repeat("字", maxMessageContentRunes+1)}, http.StatusBadRequest},
} {
resp, body = doReq(t, http.MethodPatch, base, tc.body, tok)
if resp.StatusCode != tc.want {
t.Fatalf("patch %s status = %d, want %d (body=%s)", tc.name, resp.StatusCode, tc.want, body)
}
wantErrorShape(t, body)
}
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/messages/missing", map[string]any{"content": "x"}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("patch unknown status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
// Delete the message, then deleting again is 404.
resp, body = doReq(t, http.MethodDelete, base, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var deleted struct {
Deleted string `json:"deleted"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &deleted); err != nil {
t.Fatalf("decode delete data: %v (body=%s)", err, body)
}
if deleted.Deleted != msg.ID {
t.Fatalf("deleted = %q, want %q", deleted.Deleted, msg.ID)
}
resp, body = doReq(t, http.MethodDelete, base, nil, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("delete again status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
// A viewer cannot edit or delete (owner/admin write).
other := seedFullMessage(t, st, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "x", CreatedAt: 2000})
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
for _, method := range []string{http.MethodPatch, http.MethodDelete} {
resp, body = doReq(t, method, ts.URL+"/api/v1/messages/"+other.ID, map[string]any{"content": "nope"}, viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer %s status = %d, want 403 (body=%s)", method, resp.StatusCode, body)
}
}
}
// TestHistoryMessagesNotWiredHTTP asserts the D2/D3 endpoints answer a clear 501 when
// no HistoryStore is injected.
func TestHistoryMessagesNotWiredHTTP(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
for _, tc := range []struct {
method, url string
}{
{http.MethodGet, "/api/v1/sessions/web/messages?q=x"},
{http.MethodPatch, "/api/v1/messages/x"},
{http.MethodDelete, "/api/v1/messages/x"},
} {
resp, body := doReq(t, tc.method, ts.URL+tc.url, map[string]any{"content": "x"}, tok)
if resp.StatusCode != http.StatusNotImplemented {
t.Fatalf("%s %s status = %d, want 501 (body=%s)", tc.method, tc.url, resp.StatusCode, body)
}
if !strings.Contains(string(body), "not_implemented") {
t.Fatalf("%s %s body = %s, want not_implemented", tc.method, tc.url, body)
}
}
}
// TestHistoryPatchSessionPinnedHTTP covers D5: pin/unpin changes GET /sessions order,
// the response echoes only the changed field, and the A4 title PATCH still works.
func TestHistoryPatchSessionPinnedHTTP(t *testing.T) {
t.Parallel()
_, ts, jwt, _ := newHistoryEnv(t)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
newSub := func() string {
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
var created struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &created); err != nil {
t.Fatalf("decode create: %v (body=%s)", err, body)
}
return created.SessionID
}
first, second := newSub(), newSub()
sessionOrder := func() []string {
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var sessions []historySessionSummary
decodeDataSlice(t, body, &sessions)
ids := make([]string, 0, len(sessions))
for _, s := range sessions {
ids = append(ids, s.ID)
}
return ids
}
assertOrder := func(want []string) {
t.Helper()
got := sessionOrder()
if len(got) != len(want) {
t.Fatalf("order = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order = %v, want %v", got, want)
}
}
}
assertOrder([]string{history.MainSessionID, first, second})
// Pin the newest sub: it floats above the main session, and the response echoes
// only the pinned field.
resp, body := doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"pinned": true}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("pin status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var pinned struct {
ID string `json:"id"`
Pinned *bool `json:"pinned"`
Title string `json:"title"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &pinned); err != nil {
t.Fatalf("decode pin: %v (body=%s)", err, body)
}
if pinned.ID != second || pinned.Pinned == nil || !*pinned.Pinned || pinned.Title != "" {
t.Fatalf("pin data = %+v, want id+pinned:true only", pinned)
}
assertOrder([]string{second, history.MainSessionID, first})
// GET /sessions reports the pin flag.
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var sessions []historySessionSummary
decodeDataSlice(t, body, &sessions)
if !sessions[0].Pinned || sessions[0].ID != second {
t.Fatalf("sessions[0] = %+v, want pinned sub %s", sessions[0], second)
}
// A title PATCH still works and keeps its {id,title} response shape.
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"title": " 置顶会话 "}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("title patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var renamed struct {
ID string `json:"id"`
Title string `json:"title"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &renamed); err != nil {
t.Fatalf("decode title patch: %v (body=%s)", err, body)
}
if renamed.ID != second || renamed.Title != "置顶会话" {
t.Fatalf("title patch = %+v, want 置顶会话", renamed)
}
// Unpin restores the baseline order.
resp, _ = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"pinned": false}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("unpin status = %d, want 200", resp.StatusCode)
}
assertOrder([]string{history.MainSessionID, first, second})
// Neither field → 400; unknown id → 404; viewer → 403.
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("empty patch status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{"pinned": true}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown pin status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"pinned": true}, viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer pin status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
}
+173
View File
@@ -0,0 +1,173 @@
package gateway
import (
"encoding/json"
"errors"
"net/http"
"strings"
"unicode/utf8"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// This file owns the session/message write endpoints added for the Android
// collaboration batch ③a: PATCH /sessions/{id} (A4 rename + D5 pin), PATCH
// /messages/{id} (D3 edit) and DELETE /messages/{id} (D3 delete). All are owner/admin
// history writes; the reads and DTOs live in history.go.
// patchSessionRequest is the PATCH /api/v1/sessions/{id} body. Both fields are pointers
// so an absent field is distinguishable from an explicit zero value: title renames the
// session (A4), pinned sets/unets the pin (D5), and at least one must be present.
type patchSessionRequest struct {
Title *string `json:"title"`
Pinned *bool `json:"pinned"`
}
// maxSessionTitleRunes bounds a client-supplied session title so a display row never
// overflows. It is a display-only bound: identity and permissions never derive from
// the title (project red line).
const maxSessionTitleRunes = 80
// handlePatchSession updates a persistent session's display metadata: its title (A4)
// and/or its pinned flag (D5). Both are display/order-only, so unlike DELETE they are
// allowed on the fixed main session. A request carrying neither field answers 400, as
// do an empty/whitespace or over-long title; an unknown session id answers 404. It is
// owner/admin-gated like the other history writes. The response echoes only the fields
// that were changed, so a title-only PATCH keeps its pre-D5 {id,title} shape.
func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "session id is required"))
return
}
var req patchSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if req.Title == nil && req.Pinned == nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title or pinned is required"))
return
}
resp := map[string]any{"id": id}
if req.Title != nil {
title := strings.TrimSpace(*req.Title)
if title == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title is required"))
return
}
if utf8.RuneCountInString(title) > maxSessionTitleRunes {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title is too long (max 80 characters)"))
return
}
if err := s.history.RenameSession(r.Context(), id, title); err != nil {
s.writeSessionPatchError(w, err, id, "rename session")
return
}
resp["title"] = title
}
if req.Pinned != nil {
if err := s.history.SetPinned(r.Context(), id, *req.Pinned); err != nil {
s.writeSessionPatchError(w, err, id, "pin session")
return
}
resp["pinned"] = *req.Pinned
}
writeJSON(w, http.StatusOK, resp)
}
// writeSessionPatchError maps a RenameSession/SetPinned failure to its response:
// ErrSessionNotFound → 404, anything else → 500 (logged under op).
func (s *Server) writeSessionPatchError(w http.ResponseWriter, err error, id, op string) {
if errors.Is(err, history.ErrSessionNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "session %q not found", id))
return
}
s.log.Error(op, "err", err, "session", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to update session"))
}
// patchMessageRequest is the PATCH /api/v1/messages/{id} body. Content is a pointer so
// an absent field is rejected rather than treated as an empty edit.
type patchMessageRequest struct {
Content *string `json:"content"`
}
// maxMessageContentRunes bounds an edited message body. It is a storage/display bound
// on the edit path only; inbound chat messages are not subject to it.
const maxMessageContentRunes = 4000
// handlePatchMessage edits a single message's content (Android collaboration batch ③a,
// D3). Only content changes — role/timestamp/platform stay as stored — and the response
// returns the updated message so the client can upsert it by id without a re-read. An
// empty/whitespace or over-long content answers 400, an unknown id answers 404, and a
// missing HistoryStore answers 501. It is owner/admin-gated.
func (s *Server) handlePatchMessage(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "message id is required"))
return
}
var req patchMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if req.Content == nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "content is required"))
return
}
content := *req.Content
if strings.TrimSpace(content) == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "content is required"))
return
}
if utf8.RuneCountInString(content) > maxMessageContentRunes {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "content is too long (max 4000 characters)"))
return
}
msg, err := s.history.UpdateMessage(r.Context(), id, content)
if err != nil {
if errors.Is(err, history.ErrMessageNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "message %q not found", id))
return
}
s.log.Error("history update message", "err", err, "message", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "message_failed", "failed to update message"))
return
}
writeJSON(w, http.StatusOK, toHistoryMessage(msg))
}
// handleDeleteMessage removes a single message (D3). An unknown id answers 404, a
// missing HistoryStore 501. It is owner/admin-gated.
func (s *Server) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "message id is required"))
return
}
if err := s.history.DeleteMessage(r.Context(), id); err != nil {
if errors.Is(err, history.ErrMessageNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "message %q not found", id))
return
}
s.log.Error("history delete message", "err", err, "message", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "message_failed", "failed to delete message"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
}
+17 -1
View File
@@ -124,9 +124,21 @@ type HistoryStore interface {
ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]history.Message, error) ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]history.Message, error)
ListAllLog(ctx context.Context, before int64, limit int) ([]history.Message, error) ListAllLog(ctx context.Context, before int64, limit int) ([]history.Message, error)
DeleteSession(ctx context.Context, sessionID string) error DeleteSession(ctx context.Context, sessionID string) error
// Search returns session-scoped content matches (newest first) for the
// ?q= message-search path. An empty query yields an empty slice.
Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]history.Message, error)
// UpdateMessage edits a single message's content and returns the updated
// message. An unknown id returns history.ErrMessageNotFound.
UpdateMessage(ctx context.Context, id, content string) (history.Message, error)
// DeleteMessage removes a single message. An unknown id returns
// history.ErrMessageNotFound.
DeleteMessage(ctx context.Context, id string) error
// RenameSession sets a session's display title (main or sub). An unknown id // RenameSession sets a session's display title (main or sub). An unknown id
// returns history.ErrSessionNotFound. // returns history.ErrSessionNotFound.
RenameSession(ctx context.Context, sessionID, title string) error RenameSession(ctx context.Context, sessionID, title string) error
// SetPinned pins/unpins a session (main or sub). An unknown id returns
// history.ErrSessionNotFound.
SetPinned(ctx context.Context, sessionID string, pinned bool) error
NewSubSession(ctx context.Context, ownerID string) (string, error) NewSubSession(ctx context.Context, ownerID string) (string, error)
ListSessions(ctx context.Context) ([]history.SessionMeta, error) ListSessions(ctx context.Context) ([]history.SessionMeta, error)
} }
@@ -471,7 +483,11 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/sessions/{id}/messages", historyAuth(http.HandlerFunc(s.handleListMessages))) mux.Handle("GET /api/v1/sessions/{id}/messages", historyAuth(http.HandlerFunc(s.handleListMessages)))
mux.Handle("POST /api/v1/sessions", historyAuth(http.HandlerFunc(s.handleCreateSubSession))) mux.Handle("POST /api/v1/sessions", historyAuth(http.HandlerFunc(s.handleCreateSubSession)))
mux.Handle("DELETE /api/v1/sessions/{id}", historyAuth(http.HandlerFunc(s.handleDeleteSession))) mux.Handle("DELETE /api/v1/sessions/{id}", historyAuth(http.HandlerFunc(s.handleDeleteSession)))
mux.Handle("PATCH /api/v1/sessions/{id}", historyAuth(http.HandlerFunc(s.handleRenameSession))) mux.Handle("PATCH /api/v1/sessions/{id}", historyAuth(http.HandlerFunc(s.handlePatchSession)))
// Single-message edit/delete (Android collaboration batch ③a, D3). A message is
// addressed by its own opaque id; both are owner/admin-gated history writes.
mux.Handle("PATCH /api/v1/messages/{id}", historyAuth(http.HandlerFunc(s.handlePatchMessage)))
mux.Handle("DELETE /api/v1/messages/{id}", historyAuth(http.HandlerFunc(s.handleDeleteMessage)))
// Realtime. // Realtime.
mux.HandleFunc("/ws/chat", s.handleWS) mux.HandleFunc("/ws/chat", s.handleWS)
@@ -203,10 +203,26 @@ func (f *fakeHistory) DeleteSession(context.Context, string) error {
return nil return nil
} }
func (f *fakeHistory) Search(context.Context, string, string, int64, int) ([]history.Message, error) {
return nil, nil
}
func (f *fakeHistory) UpdateMessage(_ context.Context, id, content string) (history.Message, error) {
return history.Message{ID: id, Content: content}, nil
}
func (f *fakeHistory) DeleteMessage(context.Context, string) error {
return nil
}
func (f *fakeHistory) RenameSession(context.Context, string, string) error { func (f *fakeHistory) RenameSession(context.Context, string, string) error {
return nil return nil
} }
func (f *fakeHistory) SetPinned(context.Context, string, bool) error {
return nil
}
func (f *fakeHistory) NewSubSession(context.Context, string) (string, error) { func (f *fakeHistory) NewSubSession(context.Context, string) (string, error) {
return "", nil return "", nil
} }