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/persona/loader" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // tokenTTL 是已签发 JWT 的有效时长。 const tokenTTL = 24 * time.Hour // PersonaSummary 是 REST/管理列表响应中一个人格的线上结构。 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 持有分方向的壁纸;是本角色自己的,绝不是别人的。 ChatBackground ChatBackgroundSummary `json:"chatBackground,omitempty"` // Mood 是该角色的当前情绪状态(design/01 §2:情绪跟随角色, // 绝不是会话)。"" 意味着该角色没有受跟踪的情绪 —— 诚实的 // 空值,绝不是编造的默认。客户端从它给 mood 角标播种,因此 // 刷新不再闪出空 mood。 Mood string `json:"mood"` // Thinking 携带人格可选的推理/呈现块 // (design/01 §2.1)。人格未配置时它是 nil —— 因而从 JSON 中缺席 —— // 与写侧「省略的块保留 // 已存块」。仅管理列表:这些是模型调用开关,不是用户 UI 状态 // (用户列表取 Mood,把开关留给面板)。 Thinking *PersonaThinkingSummary `json:"thinking,omitempty"` } // PersonaThinkingSummary 在线上镜像人格的 `thinking` 块 // (design/01 §2.1;provider 映射见 design/04 §3.2)。 type PersonaThinkingSummary struct { // BudgetTokens 是推理预算(Anthropic thinking.budget_tokens)。 BudgetTokens int `json:"budgetTokens"` // Effort 是 provider 特有的推理强度(reasoning_effort)。 // 模型层会按活动 provider 的白名单校验它,并在它被丢弃时 // 打 WARN —— 这一侧只报告已存的内容。 Effort string `json:"effort"` // ShowToUser 是三态的,且在线上保持如此:null = 未 // 配置(thinking 帧继续流出 —— 既有行为), // true/false = 显式选择。普通 bool 会把「未设置」压平到 // 「关闭」上,丢掉写侧用 *bool 保持的区分。 ShowToUser *bool `json:"showToUser"` // Mode 是显式的硬开关:default | on | off("" = default)。 Mode string `json:"mode"` } // ChatBackgroundSummary 在线上镜像人格的 chatBackground。 type ChatBackgroundSummary struct { Landscape string `json:"landscape,omitempty"` Portrait string `json:"portrait,omitempty"` } // MemorySummary 是管理列表响应中一条记忆条目的线上结构。 type MemorySummary struct { ID string `json:"id"` Scope string `json:"scope"` Text string `json:"text"` } // SessionSummary 是列表响应中一个渠道会话的线上结构。 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 是 POST /api/v1/auth/login 的请求体。 type loginRequest struct { Username string `json:"username"` Password string `json:"password"` } // loginResponse 是登录成功的载荷。RefreshToken 是 B2 新增的非透明 // 长期凭据(POST /auth/refresh);未接线 refresh 存储时被省略, // 因此 B2 之前的契约不变。 type loginResponse struct { Token string `json:"token"` ExpiresAt int64 `json:"expiresAt"` Role string `json:"role"` RefreshToken string `json:"refreshToken,omitempty"` } // chatRequest 是 POST /api/v1/chat 的请求体。Stream 为 true 时网关 // 经 WebSocket 通道触发流式并立即返回。Image 是 // 为视觉路径附带的可选 base64 data: URI 或 http(s) URL; // loop 把它经视觉/OCR 工具或主模型路由(design/06 §0)。 // DeviceID 是客户端产出的发送浏览器/设备标识 // (design/16 §4.5),例如 "web:chrome:a1b2c3";它可选且按原样持久化。 // Mode 是客户端发送该回合时的行为模式(design/01 §3、design/16 // §5.1);它可选,并按消息持久化以供气泡渲染,省略时 // 回退到 "chat"。 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 是同步聊天回复的载荷。 type chatResponse struct { Reply string `json:"reply"` Steps int `json:"steps"` StopReason string `json:"stopReason"` } // handleHealth 报告存活。它不认证,因为它是探针。 func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) } // handleLogin 用管理用户名/密码换取已签名的 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), } // 在签发访问令牌的同时签发长期 refresh 凭据。存储失败会让 // 登录失败,而不是静默交回一个没有所声称 refresh token 的 // 登录;明文绝不记日志。 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 运行一次同步 agent 回合(或触发流式)。 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" } // Web 主会话是主人的私密对话(JWT = owner),因此它绑定 // 全访问 env:记忆读+写,行为不变。角色 // 来自会话的绑定(design/22 §4.5)。 msgs := userChatMessages(req.Text, req.Image) env := s.envResolver.owner(r.Context(), req.SessionID) if s.hub.orchestrator != nil { sr, res := s.hub.orchestrator.SubmitSync(withMode(withDeviceID(r.Context(), req.DeviceID), req.Mode), req.SessionID, msgs, env) 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 } // 忙碌会话返回非阻塞 ack(design/15 §2.1),而不是 // 让请求阻塞在一个可能跑一阵子的回合上。 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 } // 未接线编排器时的旧同步路径(未接线的 hub // 保持完全与 design/15 之前相同的回复契约)。 res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), env), 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 在接线了 history 存储时返回持久化聊天历史会话 // (main + sub),否则回退到渠道会话视图。回退保持 // design/16 之前的契约:未接 history 的服务器仍列出已知的 // 渠道会话(未接线任何存储时为 [])。 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 返回人格(未接线存储时为 [])。它是 // 用户侧形状:角色级 mood 一并带上,仅管理的 thinking // 开关则不然。 func (s *Server) handleListPersonas(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.collectPersonas(false)) } // collectPersonas 从已配置的存储返回非 nil 的人格切片。 // withThinking 加上仅管理的 `thinking` 块;mood 总是报告, // 因为用户 UI 要渲染角标。 func (s *Server) collectPersonas(withThinking bool) []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), Mood: s.moodOf(id), } // 读取该人格自己的视觉资产(头像 / 聊天背景)。每个 // 角色携带自己的;人格缺失或视觉未设置得到空, // 绝不是别的角色的 —— 前端回退到中性默认。 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, } } sum.Thinking = thinkingSummaryOf(p.Thinking, withThinking) } out = append(out, sum) } return out } // moodOf 报告该角色的当前情绪状态。情绪归 // 人格存储所有(design/01 §2),经可选的 PersonaMoodStore // 能力读取;不跟踪情绪的存储,或没有情绪机的角色, // 报告 "" 而不是编造一个 mood。 func (s *Server) moodOf(id string) string { store, ok := s.personas.(PersonaMoodStore) if !ok { return "" } emotions := store.Emotions() if emotions == nil { return "" } machine, _, ok := emotions.Get(id) if !ok || machine == nil { return "" } return string(machine.Current()) } // thinkingSummaryOf 把已存 thinking 块映射到线上。人格未配置时 // 它返回 nil —— 该键因而缺席,使客户端能把「未配置」 // 与显式取值区分开(写侧「省略的块保留已存块」规则的 // 读侧镜像)。用户侧列表的 include 为 false, // 因为用户侧不携带这些开关。 func thinkingSummaryOf(t loader.ThinkingParams, include bool) *PersonaThinkingSummary { if !include || !thinkingConfigured(t) { return nil } return &PersonaThinkingSummary{ BudgetTokens: t.BudgetTokens, Effort: t.Effort, ShowToUser: t.ShowToUser, Mode: t.Mode, } } // thinkingConfigured 报告是否有任何 thinking 开关被设置。空块 // 必须读作「未配置」,而不是全零对象。 func thinkingConfigured(t loader.ThinkingParams) bool { return t.BudgetTokens != 0 || t.Effort != "" || t.ShowToUser != nil || t.Mode != "" } // firstMode 返回首个声明的行为模式,未设置时返回 ""。 func firstMode(modes []string) string { if len(modes) == 0 { return "" } return modes[0] } // userChatMessages 为 chat / stream 请求构建用户回合。image // 存在时该回合成为图片消息(可带文本);否则是 // 纯文本消息。空文本加图片得到仅图片的回合。 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 把 session.Session 映射为其线上结构。 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 把 v 编码为 {data: v} 信封并带上给定状态。 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 把 err 编码为 {error:{code,message}} 信封,状态由 // 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)) }