Files
Cyrene_v2/internal/services/gateway/rest.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

393 lines
14 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 (
"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))
}