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"` // SenderName is the human-readable sender display name of a third-party // message (group/stranger, design/16 §4.6). It is blank for the owner's // private chat on the main session, so empty means "labeled as the owner". SenderName string `json:"senderName,omitempty"` // AdapterID is the adapter (platform + account) registration name of the IM // source that produced the message (e.g. "kook:3bef..."). It is blank for // Web-originated messages. AdapterID string `json:"adapterId,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) } // handleChannelPlatformLog returns up to limit messages for an IM platform across its // isolated (group/stranger) sessions, newest first, for the admin adapter log page // (design/16 §4.6 §4). It excludes the fixed main web session, so only the platform's // non-owner message stream is surfaced; the owner-private mirror stays out of this view. 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 returns up to limit messages for the admin adapter log page, // newest first, across one of three granularities (design/16 §4.6 §4): a single // adapter config (?adapter=kook:3bef...), a whole platform (?platform=kook), or the // full IM adapter overview (neither, so adapter_id != ''). before/limit reuse the // standard cursor pagination. The owner-private mirror on the main web session is // NOT excluded here because it carries the adapter_id; each granularity filters on // adapter_id / platform directly. 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 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, SenderName: m.SenderName, AdapterID: m.AdapterID, CreatedAt: m.CreatedAt, } } // historyDefaultLimit is the page size used when the client omits the limit query param. const historyDefaultLimit = 50