Files
Cyrene_v2/internal/services/gateway/history.go
T
Aska 37e98ec584
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: IM平台列 + deviceID标识 + 会话持久化完善
- agent-96: chat_messages 加 platform 列(迁移0004) + IM主人私聊落主会话web(带platform) + 前端按平台过滤契约 + message_persisted 带 platform(复用 BroadcastPersisted)
- agent-97: chat_messages 加 device_id 列(迁移0005) + history.Message.DeviceID + Web发消息带deviceId(localStorage getClientId) + IM persistToMain 带 deviceID + 前端气泡展示 SourceLabel + 修主会话基础2个bug(fn scanMessage语法/DDL缺列)
- design/16 §4.4(平台) + §4.5(deviceID) 已入档
- 实机: POST /chat 带 deviceId 读回 user/assistant 均带 deviceId+platform; 后端 build/vet/test 绿 + 前端 build 绿
- 红线: 身份靠 actor 元数据; 禁止 shell 读 .env(JWT冒烟由主会话跑)
2026-09-07 04:51:41 +08:00

171 lines
6.0 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 (
"errors"
"net/http"
"strconv"
"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 is the wire shape of one persistent session (main or sub) in the
// GET /api/v1/sessions response.
type historySessionSummary struct {
ID string `json:"id"`
Type string `json:"type"` // "main" | "sub"
Title string `json:"title,omitempty"`
CreatedAt int64 `json:"createdAt"`
}
// historyMessage is the wire shape of one persisted chat message in the
// GET /api/v1/sessions/{id}/messages response. Mode is carried so the frontend renders
// the bubble without guessing (design/16 §5.1); thinking is stored and returned to the
// owner/admin (the messages route is owner/admin-only, so it never reaches a 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"`
Thinking string `json:"thinking,omitempty"`
Platform string `json:"platform,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
CreatedAt int64 `json:"createdAt"`
}
// handleHistorySessions returns the main session first, then every sub session (oldest
// first). It is the chat-history session list for the frontend (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,
})
}
writeJSON(w, http.StatusOK, out)
}
// handleCreateSubSession creates a new sub session and returns its sessionId. It is a
// user-side write, so it is owner/admin-gated like /api/v1/chat.
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
}
id, err := s.history.NewSubSession(r.Context(), ownerID)
if err != nil {
s.log.Error("history create sub session", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to create session"))
return
}
writeJSON(w, http.StatusCreated, map[string]any{"sessionId": id})
}
// handleListMessages returns a page of messages for a session, newest first, with cursor
// pagination. It is the hot-paging history endpoint (design/16 §2.2, §5.1): limit defaults
// to 50 (clamped to 200), before is the oldest message's created_at from the previous page.
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
}
platform := r.URL.Query().Get("platform")
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)
}
// handleDeleteSession deletes a sub session and all its messages. The main session is
// immutable (design/16 §6) and returns 403.
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 maps a stored history.Message to its wire shape.
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,
Thinking: m.Thinking,
Platform: m.Platform,
DeviceID: m.DeviceID,
CreatedAt: m.CreatedAt,
}
}
// historyDefaultLimit is the page size used when the client omits the limit query param.
const historyDefaultLimit = 50