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/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"` } // 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. type loginResponse struct { Token string `json:"token"` ExpiresAt int64 `json:"expiresAt"` Role string `json:"role"` } // chatRequest is the POST /api/v1/chat body. When Stream is true the gateway // triggers streaming over the WebSocket channel and returns immediately. type chatRequest struct { SessionID string `json:"sessionId"` Text string `json:"text"` 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 } writeJSON(w, http.StatusOK, loginResponse{ Token: token, ExpiresAt: time.Now().Add(tokenTTL).Unix(), Role: string(auth.RoleOwner), }) } // 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) == "" { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text is required")) return } if req.Stream { s.publishChatStream(r.Context(), req.SessionID, req.Text) 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" } res, err := s.runner.Run(r.Context(), req.SessionID, []model.Message{model.TextMessage(model.RoleUser, req.Text)}) 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 known sessions (or [] when no store is wired). func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { 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 { out = append(out, s.personas.List()...) } return out } // 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)) }