package gateway import ( "encoding/json" "net/http" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // tokenTTL is how long an issued JWT remains valid. const tokenTTL = 24 * time.Hour // PersonaSummary is the wire shape of a persona in REST/admin list responses. type PersonaSummary struct { ID string `json:"id"` Name string `json:"name"` Character string `json:"character"` IsDefault bool `json:"isDefault"` Mode string `json:"mode"` MemoryNS string `json:"memoryNamespace"` Avatar string `json:"avatar,omitempty"` // ChatBackground holds per-orientation wallpaper; a role's own, never another's. ChatBackground ChatBackgroundSummary `json:"chatBackground,omitempty"` } // ChatBackgroundSummary mirrors the persona chatBackground on the wire. type ChatBackgroundSummary struct { Landscape string `json:"landscape,omitempty"` Portrait string `json:"portrait,omitempty"` } // MemorySummary is the wire shape of a memory entry in the admin list response. type MemorySummary struct { ID string `json:"id"` Scope string `json:"scope"` Text string `json:"text"` } // SessionSummary is the wire shape of a channel session in list responses. type SessionSummary struct { Platform string `json:"platform"` ChannelID string `json:"channelId"` SessionID string `json:"sessionId"` ActorRole string `json:"actorRole"` Owner bool `json:"owner"` IsGroup bool `json:"isGroup"` MemWrite bool `json:"memWritable"` } // loginRequest is the POST /api/v1/auth/login body. type loginRequest struct { Username string `json:"username"` Password string `json:"password"` } // loginResponse is the login success payload. RefreshToken is the opaque // long-lived credential (POST /auth/refresh) added by B2; it is omitted when no // refresh store is wired so the pre-B2 contract is unchanged. type loginResponse struct { Token string `json:"token"` ExpiresAt int64 `json:"expiresAt"` Role string `json:"role"` RefreshToken string `json:"refreshToken,omitempty"` } // chatRequest is the POST /api/v1/chat body. When Stream is true the gateway // triggers streaming over the WebSocket channel and returns immediately. Image is // an optional base64 data: URI or http(s) URL attached for the vision path; the // loop routes it through the vision/OCR tool or the main model (design/06 §0). // DeviceID is the client-produced identifier of the sending browser/device // (design/16 §4.5), e.g. "web:chrome:a1b2c3"; it is optional and persisted as-is. // Mode is the behavior mode the client sent the turn in (design/01 §3, design/16 // §5.1); it is optional and persisted per message for bubble rendering, falling // back to "chat" when omitted. type chatRequest struct { SessionID string `json:"sessionId"` Text string `json:"text"` Image string `json:"image,omitempty"` DeviceID string `json:"deviceId,omitempty"` Mode string `json:"mode,omitempty"` Stream bool `json:"stream"` } // chatResponse is the synchronous chat reply payload. type chatResponse struct { Reply string `json:"reply"` Steps int `json:"steps"` StopReason string `json:"stopReason"` } // handleHealth reports liveness. It is unauthenticated because it is a probe. func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) } // handleLogin exchanges the admin username/password for a signed JWT. func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } cfg := s.cfg.Auth if req.Username != cfg.AdminUser || req.Password != cfg.AdminPass { writeError(w, apperr.Wrap(apperr.ErrUnauthorized, "unauthorized", "invalid username or password")) return } token, err := s.auth.Sign(req.Username, auth.RoleOwner, tokenTTL) if err != nil { s.log.Error("login sign", "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "login_failed", "could not sign token")) return } resp := loginResponse{ Token: token, ExpiresAt: time.Now().Add(tokenTTL).Unix(), Role: string(auth.RoleOwner), } // Issue the long-lived refresh credential alongside the access token. A store // failure fails the login rather than silently handing back a login without // the advertised refresh token; the plaintext is never logged. if s.refresh != nil { refreshToken, _, rerr := s.refresh.Issue(r.Context(), req.Username, auth.RoleOwner, s.refreshTTL()) if rerr != nil { s.log.Error("login issue refresh token", "err", rerr) writeError(w, apperr.Wrap(apperr.ErrInternal, "login_failed", "could not issue refresh token")) return } resp.RefreshToken = refreshToken } writeJSON(w, http.StatusOK, resp) } // handleChat runs one synchronous agent turn (or triggers streaming). func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { var req chatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } if strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "" { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text or image is required")) return } if req.Stream { s.publishChatStream(r.Context(), req.SessionID, req.Text, req.Image, req.DeviceID, req.Mode) writeJSON(w, http.StatusOK, map[string]any{"stream": true}) return } if s.runner == nil { writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_unavailable", "chat service is not configured")) return } if req.SessionID == "" { req.SessionID = "web" } // The Web main session is the owner's private conversation (JWT = owner), so // it binds the full-access env: memory read+write, unchanged behavior. msgs := userChatMessages(req.Text, req.Image) if s.hub.orchestrator != nil { sr, res := s.hub.orchestrator.SubmitSync(withMode(withDeviceID(r.Context(), req.DeviceID), req.Mode), req.SessionID, msgs, loop.OwnerEnv()) if !sr.Accepted { if sr.PendingFull { writeError(w, apperr.Wrap(apperr.ErrConflict, "chat_busy", "会话正忙,待处理消息已满")) } else { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text or image is required")) } return } // A busy session returns a non-blocking ack (design/15 §2.1) instead of // blocking the request on a turn that may run for a while. if sr.Pending { writeJSON(w, http.StatusAccepted, map[string]any{"queued": true, "queueDepth": sr.QueueDepth}) return } if sr.Err != nil { s.log.Error("chat run", "err", sr.Err, "session", req.SessionID) writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed")) return } writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason}) return } // Legacy synchronous path when no orchestrator is wired (a hub built without // one keeps the exact pre-design/15 reply contract). res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), loop.OwnerEnv()), req.SessionID, msgs) if err != nil { s.log.Error("chat run", "err", err, "session", req.SessionID) writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed")) return } writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason}) } // handleListSessions returns the persistent chat-history sessions (main + sub) when a // history store is wired, otherwise falls back to the channel-session view. The fallback // keeps the pre-design/16 contract: a server built without history still lists the known // channel sessions (or [] when no store is wired). func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { if s.history != nil { s.handleHistorySessions(w, r) return } out := make([]SessionSummary, 0) if s.sessions != nil { list, err := s.sessions.List(r.Context()) if err != nil { s.log.Error("list sessions", "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "sessions_failed", "failed to list sessions")) return } for _, sess := range list { out = append(out, summaryOfSession(sess)) } } writeJSON(w, http.StatusOK, out) } // handleListPersonas returns the personas (or [] when no store is wired). func (s *Server) handleListPersonas(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.collectPersonas()) } // collectPersonas returns a non-nil persona slice from the configured store. func (s *Server) collectPersonas() []PersonaSummary { out := make([]PersonaSummary, 0) if s.personas == nil { return out } for _, c := range s.personas.List() { id := c.PersonaID if id == "" { id = c.ID } sum := PersonaSummary{ ID: id, Name: c.Name, Character: c.Name, IsDefault: c.Default, Mode: firstMode(c.Modes), } // Read this persona's own visual assets (avatar / chat background). Each // role carries its own; a missing persona or unset visual yields empty, // never another role's — the frontend falls back to a neutral default. if p, ok := s.personas.Persona(id); ok && p != nil { if p.Visual.Avatar != "" { sum.Avatar = p.Visual.Avatar } if p.Visual.ChatBackground.Landscape != "" || p.Visual.ChatBackground.Portrait != "" { sum.ChatBackground = ChatBackgroundSummary{ Landscape: p.Visual.ChatBackground.Landscape, Portrait: p.Visual.ChatBackground.Portrait, } } } out = append(out, sum) } return out } // firstMode returns the first declared behavior mode, or "" when none are set. func firstMode(modes []string) string { if len(modes) == 0 { return "" } return modes[0] } // userChatMessages builds the user turn for a chat / stream request. When image is // present the turn becomes an image message (with optional text); otherwise a // plain text message. An empty text with an image yields an image-only turn. func userChatMessages(text, image string) []model.Message { if strings.TrimSpace(image) != "" { return []model.Message{model.ImageMessage(model.RoleUser, text, image)} } return []model.Message{model.TextMessage(model.RoleUser, text)} } // summaryOfSession maps a session.Session to its wire shape. func summaryOfSession(sess session.Session) SessionSummary { return SessionSummary{ Platform: sess.Platform, ChannelID: sess.ChannelID, SessionID: sess.SessionID, ActorRole: string(sess.ActorRole), Owner: sess.Owner, IsGroup: sess.IsGroup, MemWrite: sess.MemWritable, } } // writeJSON encodes v as the {data: v} envelope with the given status. func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]any{"data": v}) } // writeError encodes err as the {error:{code,message}} envelope with the status // resolved by apperr.HTTPStatus. func writeError(w http.ResponseWriter, err error) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(apperr.HTTPStatus(err)) _ = json.NewEncoder(w).Encode(apperr.ToJSON(err)) }