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"` 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)) } 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)) } 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)) } 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 }