Files
Cyrene_v2/internal/services/gateway/history.go
T
AskaEth 1420e8aaa6
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): D3 审查读侧/revert 端点 + 语音用户可见面(L45)
(a) 记忆入库前审查的两条 admin 端点(主人判「有必要就做」):
  GET /api/v1/admin/memories/reviews?limit= (留痕读侧,不含记忆正文)
  POST /api/v1/admin/memories/{id}/revert (回滚软合并,落审计 memory.review.revert)
  两条 writeAuth(裁定 DECISIONS #10:留痕属私有面,不给 viewer)
(b) 语音用户可见面:DTO voice(含服务端派生 url)+ GET /api/v1/media/{name}
  (200/206/304/416/HEAD、Accept-Ranges、非法名/穿越统一 404 media_not_found)
  + documents.Store.SaveAudio(内容寻址、$DATA_DIR 0600)+ history voice 列读写与引用计数差动

缺口(如实):message_persisted/握手回放仍不带 voice(文件面外,需给 history.Message 加字段);
AMR 需一行 VoiceTranscoder 注入(DECISIONS #11 已排)。4 个变异自检(含一条自认的等价变异)。
2026-10-06 14:12:27 +08:00

498 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// historySessionSummary 是 GET /api/v1/sessions 响应中一个持久化会话
// (main 或 sub)的线上结构。
type historySessionSummary struct {
ID string `json:"id"`
Type string `json:"type"` // 取值:"main" | "sub"
Title string `json:"title,omitempty"`
CreatedAt int64 `json:"createdAt"`
// Pinned 把会话浮到列表顶部(D5)。它只是排序元数据,
// 且始终存在,使客户端无需第二次调用就能渲染/排序。
Pinned bool `json:"pinned"`
// LastMessage 是会话最新消息的纯展示预览,使客户端
// 无需逐会话请求就能渲染对话行(例如 Android 会话列表)。
// 没有消息的会话会省略它。内容
// 已由存储截断。
LastMessage *historyLastMessage `json:"lastMessage,omitempty"`
// UnreadCount 是比会话读游标(D4)更新的消息数,
// 在同一次列表查询中推导。它始终返回(无未读时为 0),
// 使客户端无需第二次调用就能渲染/清除角标。该
// 游标是会话作用域且单归属:在任一端已读会为
// 该账号的每一端清除角标。
UnreadCount int `json:"unreadCount"`
// PersonaID 是该会话所属的角色(design/22 §2/§4.5)。它是
// 「对话中由哪个角色应答」的唯一事实来源,客户端
// 用它为活动会话渲染角色的名字/头像。
// 为空时省略,而空意味着「继承全局默认角色」,因此
// 忽略该字段的旧客户端仍展示默认角色的视觉。
PersonaID string `json:"personaId,omitempty"`
}
// historyLastMessage 是会话摘要中最后消息预览的线上结构。
type historyLastMessage struct {
Role string `json:"role"` // 取值:"user" | "assistant"
Content string `json:"content"`
CreatedAt int64 `json:"createdAt"`
}
// historyMessage 是 GET /api/v1/sessions/{id}/messages 响应中一条持久化聊天
// 消息的线上结构。Mode 被携带,使前端无需猜测就渲染
// 气泡(design/16 §5.1);thinking 会被存储并返回给
// owner/admin(消息路由仅 owner/admin,因此绝不触达 viewer)。
type historyMessage struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
Role string `json:"role"`
Content string `json:"content"`
Mode string `json:"mode,omitempty"`
Mood string `json:"mood,omitempty"`
Image string `json:"image,omitempty"`
// Documents 是这条消息携带的文档引用与抽取统计(design/24 §2.4、§6.1),
// 形状为 [{name,mime,size,chars,truncated,status}, ...]:一个回合最多 5 条,
// 顺序即客户端发送顺序。它们**只带引用与统计,绝不带抽取正文** —— 正文若
// 从这里回传,每翻一页历史都要背着它(G3 的教训)。没有附件(或附件列表为
// 空)的消息**省略该字段**,与引入文档之前逐字相同。
//
// ⚠️ 契约变更(S3.5):此前这里是单对象 `document`。单数形状从未进
// Docs/api-client.md(v0.59 待发、客户端未接),因此不留兼容期,直接换成
// 数组 —— 客户端按 `documents`(可为空/缺失)读取。
Documents []history.DocumentRef `json:"documents,omitempty"`
Thinking string `json:"thinking,omitempty"`
Platform string `json:"platform,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
// SenderName 是第三方消息(群聊/陌生人,design/16 §4.6)
// 给人看的发送者显示名。对主会话中主人的私聊它为
// 空,因此空意味着「标记为主人」。
SenderName string `json:"senderName,omitempty"`
// AdapterID 是产出该消息的 IM 来源适配器(平台 + 账号)
// 注册名(例如 "kook:3bef...")。对 Web 来源的消息
// 为空。
AdapterID string `json:"adapterId,omitempty"`
// PersonaID 是说出这条消息的角色(design/22 §7-5)。一个会话可以
// 在保留上下文的同时切换角色,因此作者角色按消息
// 存储并返回,客户端用该角色的名字/头像渲染
// 气泡。为空时省略,而空意味着「全局默认角色」——
// 多角色之前的行读起来与以往完全一致。
PersonaID string `json:"personaId,omitempty"`
// ClientMsgID 是客户端自带的幂等键(Android 协同 §G4),原样回填,
// 因此客户端能把本地乐观气泡与服务端真相对齐(而不再靠「文本相等」
// 这类脆弱匹配),也能在重连后从读接口收敛。它为空时省略,而空意味着
// 「这条消息没有幂等键」(老客户端、IM 镜像、历史行)。
ClientMsgID string `json:"clientMsgId,omitempty"`
// TurnID 是这条消息所属的那一**路**回合的权威标识(design/15 §2.5.9):
// 编排器生成,与该路全部流帧 / `turn_begin` 帧壳上的 turnId **同值**。
// 同一路的 user 行与 assistant 行因此带同一个值,客户端据此
// - 精确配对「回复 ↔ 提问」;
// - 在**重连 / 回放**时从历史行恢复回复侧的分组小标(真并行下同一会话有两个
// 交错的回答,只按顺序渲染会配错)。
//
// 它为空时省略,而空意味着「这一行不属于任何已知的回合」:加入该列之前的历史
// 行、以及未经编排器的写入路径(IM 镜像等)都是这种情形,读起来与以往逐字
// 相同。排队 / 被转向吃进的消息在**落库那一刻**也还没有自己的路(那时它由哪
// 一路回答尚未确定),因此它们的那一帧 `message_persisted` 不带 turnId ——
// 客户端从 `turn_begin`(帧壳 turnId + data.messageIds)得知归属,行本身随后
// 由它实际被处理的那一路补写。
TurnID string `json:"turnId,omitempty"`
// ToolCalls 是这条消息所属那**一轮**的工具回看面(契约 v0.76 ①;切片 CODE-TC):
// 形状 `[{name,status,durationMs,argsSummary,resultSummary}, ...]`,内容逐字来自
// **已落盘的轮次链路图**(`flow_graphs`),因此是出图时**已脱敏**的那一份。
//
// 🔴 缺席语义:该键**只在这条消息真的回看到工具调用时才出现**。图取不到
// (超保留期 / 该轮未落盘 / 隔离渠道会话从不入库 / 该路标识查不到图)、以及这一路
// 确实没调过工具,都是**键缺席** —— **绝不**回 `[]` 冒充「没调过工具」。客户端
// 因此只能做正向断言:「有这个键 ⇒ 这一轮调用过工具」。
//
// 它**只由读接口填充**(`handleListMessages`):其余共用本结构的出口
// (`message_persisted` / `message_edited` / 握手回放 / 渠道日志)逐字不变。
ToolCalls []historyToolCall `json:"toolCalls,omitempty"`
// Voice 是这条消息的**语音面**(契约 v0.67 §4.4;形状与缺席语义见 history_voice.go):
// 非语音消息**整键缺席**(指针 nil,不是 `{}` 也不是 `null`)⇒ **🎤 判据 = 本键存在**。
Voice *historyVoice `json:"voice,omitempty"`
CreatedAt int64 `json:"createdAt"`
}
// handleHistorySessions 先返回主会话,再返回每个子会话(最旧
// 优先)。它是前端的聊天历史会话列表(design/16 §2.2)。
func (s *Server) handleHistorySessions(w http.ResponseWriter, r *http.Request) {
meta, err := s.history.ListSessions(r.Context())
if err != nil {
s.log.Error("history list sessions", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "sessions_failed", "failed to list sessions"))
return
}
out := make([]historySessionSummary, 0, len(meta))
for _, m := range meta {
out = append(out, historySessionSummary{
ID: m.ID,
Type: m.Type,
Title: m.Title,
CreatedAt: m.CreatedAt,
Pinned: m.Pinned,
PersonaID: m.PersonaID,
LastMessage: toHistoryLastMessage(m.LastMessage),
UnreadCount: m.UnreadCount,
})
}
writeJSON(w, http.StatusOK, out)
}
// toHistoryLastMessage 把已存的最后消息预览映射为其线上结构,
// 对没有消息的会话返回 nil(因此该字段从 JSON 中省略)。
func toHistoryLastMessage(m *history.MessageSummary) *historyLastMessage {
if m == nil {
return nil
}
return &historyLastMessage{Role: m.Role, Content: m.Content, CreatedAt: m.CreatedAt}
}
// createSubSessionRequest 是 POST /api/v1/sessions 的请求体。请求体本身
// 可选(只想要普通会话的客户端不发送任何内容),personaId 是
// 新会话可选绑定的角色(design/22 §4.5)。它是设计的
// 优先级中「为新会话播种角色」的那一半:此处显式给出的角色
// 成为会话级绑定(唯一事实来源),而省略它则
// 保持 persona_id 为空串,使会话继承全局默认。
type createSubSessionRequest struct {
PersonaID string `json:"personaId"`
}
// handleCreateSubSession 创建一个新的子会话并返回其 sessionId。它是
// 用户侧写操作,因此与 /api/v1/chat 一样受 owner/admin 门控。可选的
// {"personaId":"..."} 请求体把新会话绑定到该角色;未知 id 会
// 以 400 拒绝,而不是创建一个会静默以
// 默认角色应答的会话。
func (s *Server) handleCreateSubSession(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
ownerID := ""
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
ownerID = claims.Sub
}
var req createSubSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
personaID := strings.TrimSpace(req.PersonaID)
if !s.requireKnownPersona(w, personaID) {
return
}
id, err := s.newSubSession(r.Context(), ownerID, personaID)
if err != nil {
if errors.Is(err, errPersonaBindingUnsupported) {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话角色绑定需要支持 personaId 的 HistoryStore", HTTPCode: http.StatusNotImplemented})
return
}
s.log.Error("history create sub session", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to create session"))
return
}
resp := map[string]any{"sessionId": id}
if personaID != "" {
resp["personaId"] = personaID
}
writeJSON(w, http.StatusCreated, resp)
}
// handleListMessages 用游标分页返回某会话的一页消息。
// 它是热分页历史端点(design/16 §2.2、§5.1)。支持两个互斥的
// 游标:
// - before(毫秒 createdAt):返回严格早于 before 的消息,最新优先
// —— 默认方向,用于向后翻历史;
// - after(毫秒 createdAt):返回严格新于 after 的消息,最旧优先
// —— 前向增量追赶,使已重连的客户端能拉取
// 从其已有的最新 createdAt 起错过的全部内容,而不受
// 向后翻的单页上限限制。
//
// limit 两个方向都默认 50(上限 200)。platform 在任一方向按
// 来源平台过滤该页。可选的 q 把该页切换为
// 会话作用域的内容搜索(最新优先,仍遵守 before/limit);q 与
// after 互斥,空 q 被忽略,因此普通分页
// 不变。同时给出 before 与 after(二者都 > 0)返回 400,
// after 非数字/为负或 limit/before 非法时亦然。
//
// **会话不存在 / 已删除 ⇒ `404 session_not_found`**(契约 v0.63,行为变更);
// 存在但确实没有消息仍是 `200 {"data":[]}`(见 history_session_guard.go)。
func (s *Server) handleListMessages(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
}
limitStr := r.URL.Query().Get("limit")
limit := historyDefaultLimit
if limitStr != "" {
v, err := strconv.Atoi(limitStr)
if err != nil || v < 1 {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
return
}
limit = v
}
var before int64
if b := r.URL.Query().Get("before"); b != "" {
v, err := strconv.ParseInt(b, 10, 64)
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid before"))
return
}
before = v
}
after, ok := parseAfterCursor(r)
if !ok {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid after"))
return
}
if before > 0 && after > 0 {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "before and after are mutually exclusive"))
return
}
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
}
// 会话不存在 / 已删除 ⇒ 404 session_not_found(契约 v0.63):客户端据此把
// 「当前会话已不在」与「这是一条空会话」分开处理。**存在但没有消息**仍走
// 下面的空数组(见 history_session_guard.go)。
if s.rejectMissingSession(w, r, id) {
return
}
var msgs []history.Message
var err error
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)
default:
msgs, err = s.history.List(r.Context(), id, before, limit, platform)
}
if err != nil {
s.log.Error("history list messages", "err", err, "session", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "messages_failed", "failed to list messages"))
return
}
out := make([]historyMessage, 0, len(msgs))
for _, m := range msgs {
out = append(out, toHistoryMessage(m))
}
// 工具回看面(契约 v0.76 ①;切片 CODE-TC):只在本端点补,且**一次批量查图**
// (按页内路标识集合,禁 N+1)。图取不到 ⇒ 字段整体缺席(attachToolCalls 只在
// 真的回看到工具调用时才写),绝不回空数组。
s.attachToolCalls(r.Context(), id, out)
// 语音面(契约 v0.67 §4.4):同样一次批量读列,列里没有语音事实 ⇒ 整体缺席。
s.attachVoice(r.Context(), out)
writeJSON(w, http.StatusOK, out)
}
// parseAfterCursor 读取非负的 "after" 查询参数(毫秒 createdAt
// 游标)。参数缺失/为空得到 (0, true) —— 无前向边界。非数字或
// 负值得到 (0, false),使调用方返回 400。
func parseAfterCursor(r *http.Request) (int64, bool) {
raw := r.URL.Query().Get("after")
if raw == "" {
return 0, true
}
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil || v < 0 {
return 0, false
}
return v, true
}
// handleChannelPlatformLog 为某个 IM 平台跨其隔离(群聊/陌生人)
// 会话返回至多 limit 条消息,最新优先,供管理适配器日志页
// (design/16 §4.6 §4)。它排除固定的主 Web 会话,因此只浮现该平台的
// 非主人消息流;owner 私有镜像不进入该视图。
func (s *Server) handleChannelPlatformLog(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
platform := r.PathValue("platform")
if platform == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required"))
return
}
limitStr := r.URL.Query().Get("limit")
limit := historyDefaultLimit
if limitStr != "" {
v, err := strconv.Atoi(limitStr)
if err != nil || v < 1 {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
return
}
limit = v
}
var before int64
if b := r.URL.Query().Get("before"); b != "" {
v, err := strconv.ParseInt(b, 10, 64)
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid before"))
return
}
before = v
}
msgs, err := s.history.ListPlatformLog(r.Context(), platform, before, limit)
if err != nil {
s.log.Error("history platform log", "err", err, "platform", platform)
writeError(w, apperr.Wrap(apperr.ErrInternal, "platform_log_failed", "failed to list platform log"))
return
}
out := make([]historyMessage, 0, len(msgs))
for _, m := range msgs {
out = append(out, toHistoryMessage(m))
}
// 语音面(契约 v0.67 §4.4):渠道日志页也是读取面,同样补(一次批量)。
s.attachVoice(r.Context(), out)
writeJSON(w, http.StatusOK, out)
}
// handleChannelMessages 为管理适配器日志页返回至多 limit 条消息,
// 最新优先,覆盖三种粒度之一(design/16 §4.6 §4):单个
// 适配器配置(?adapter=kook:3bef...)、整个平台(?platform=kook),或
// 全部 IM 适配器总览(二者都不给,即 adapter_id 非空)。before/limit 复用
// 标准游标分页。主 Web 会话上的 owner 私有镜像在此
// 不被排除,因为它携带 adapter_id;每种粒度直接按
// adapter_id / platform 过滤。
func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
limitStr := r.URL.Query().Get("limit")
limit := historyDefaultLimit
if limitStr != "" {
v, err := strconv.Atoi(limitStr)
if err != nil || v < 1 {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
return
}
limit = v
}
var before int64
if b := r.URL.Query().Get("before"); b != "" {
v, err := strconv.ParseInt(b, 10, 64)
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid before"))
return
}
before = v
}
adapter := r.URL.Query().Get("adapter")
platform := r.URL.Query().Get("platform")
var msgs []history.Message
var err error
switch {
case adapter != "":
msgs, err = s.history.ListAdapterLog(r.Context(), adapter, before, limit)
case platform != "":
msgs, err = s.history.ListPlatformLog(r.Context(), platform, before, limit)
default:
msgs, err = s.history.ListAllLog(r.Context(), before, limit)
}
if err != nil {
s.log.Error("history channel messages", "err", err, "adapter", adapter, "platform", platform)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_messages_failed", "failed to list channel messages"))
return
}
out := make([]historyMessage, 0, len(msgs))
for _, m := range msgs {
out = append(out, toHistoryMessage(m))
}
// 语音面(契约 v0.67 §4.4):IM 适配器日志页也是读取面之一,同样补(一次批量)。
s.attachVoice(r.Context(), out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleDeleteSession(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
}
if err := s.history.DeleteSession(r.Context(), id); err != nil {
if errors.Is(err, history.ErrImmutableMain) {
writeError(w, apperr.Wrap(apperr.ErrForbidden, "forbidden", "the main session cannot be deleted"))
return
}
s.log.Error("history delete session", "err", err, "session", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to delete session"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
}
// toHistoryMessage 把已存的 history.Message 映射为其线上结构。
func toHistoryMessage(m history.Message) historyMessage {
return historyMessage{
ID: m.ID,
SessionID: m.SessionID,
Role: m.Role,
Content: m.Content,
Mode: m.Mode,
Mood: m.Mood,
Image: m.Image,
Documents: decodeHistoryDocuments(m.Document),
Thinking: m.Thinking,
Platform: m.Platform,
DeviceID: m.DeviceID,
SenderName: m.SenderName,
AdapterID: m.AdapterID,
PersonaID: m.PersonaID,
// ClientMsgID 一并回填:它正是客户端拿来做乐观气泡对齐的键
// (协同 G4),读接口(GET /sessions/{id}/messages)与
// `message_persisted` 帧因此给出同一个答案。
ClientMsgID: m.ClientMsgID,
// TurnID 同理一并回填:读接口、WS 握手回放、`message_persisted` 与
// `message_edited` 四条面共用本映射,因此它们对「这条消息属于哪一路」
// 永远给出同一个答案(design/15 §2.5.9)。
TurnID: m.TurnID,
CreatedAt: m.CreatedAt,
}
}
// historyDefaultLimit 是客户端省略 limit 查询参数时使用的页大小。
const historyDefaultLimit = 50
// decodeHistoryDocuments 解码已存消息的 document 列(引用数组,兼容 S2/S3 早期
// 的单对象旧形状)。空列表示这条消息没有附件,返回 nil 以省略该字段。列被写坏时
// (本服务端只会写合法 JSON,因此那只能是外部改动或未来格式变更)**绝不静默
// 当成没有附件** —— 「一方以为附件传上去了,另一方一无所知」正是 F2 要消灭的
// 误解;这时回一个标着 status=unreadable 的最小引用,让客户端看得见这条引用
// 读不出来。
func decodeHistoryDocuments(raw string) []history.DocumentRef {
refs, err := history.DecodeDocuments(raw)
if err != nil {
return []history.DocumentRef{{Status: history.DocumentStatusUnreadable}}
}
return refs
}