(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 个变异自检(含一条自认的等价变异)。
498 lines
22 KiB
Go
498 lines
22 KiB
Go
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
|
||
}
|