Files
Cyrene_v2/internal/services/gateway/rest.go
T
AskaEth 89e13cf53d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,loop): 忙时并行回合基础管线(I1′ 有条件放宽)
- turnId 由编排器生成、loop Run 复用(硬前提:否则 turn_begin 与流帧各铸一个,客户端两级分桶立即失配)
- StateSink 增带 turnId 的变体(未接线时退化为现签名,不丢状态帧)
- 上行 chat.parallel(WS chat 帧 + REST 三入口同形;省略 = 逐字不变)
- 单会话额度默认 2 / 硬上限 3 / <2 关闭;额度满或未声明 ⇒ 落回既有排队语义
- I1′:checkpoint 归属、历史配对、忙态计数三处从会话级单例升为路级
- session_idle 收紧为「所有在途路收尾且 pending 空」(仅并行时生效;单路逐字不变)
- 预留接口(形状定死、无实现无接线):tool/legfile.go 的 FileClaimer / ErrFileBusy / WithLegID
- 顺手修一个真 bug:并行路 ctx 派生自提交请求作用域,REST 同步入口一写响应就把它连坐杀死(改 context.WithoutCancel 派生)
- 23 个新用例(含 -race -count=3);变异 6 条(turnId 复用 / idle 时点 / 并行升级 / checkpoint 键 / 编排器交 id / pending_ack reason)
- 契约变更点 10 条待监工升 v0.65(本人未改 Docs/**)
2026-10-05 09:18:50 +08:00

452 lines
19 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/multimodal/vision"
)
// 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)。
// Image 受配置上限(CHAT_IMAGE_MAX_BYTES,默认与
// POST /api/v1/upload 的 5 MiB 同口径,且不超过 vision 解码层硬顶)
// 换算出的字符上限限制,
// 超限一律 400 image_too_large,不被静默吃下。
// DeviceID 是客户端产出的发送浏览器/设备标识
// (design/16 §4.5),例如 "web:chrome:a1b2c3";它可选且按原样持久化。
// Mode 是客户端发送该回合时的行为模式(design/01 §3、design/16
// §5.1);它可选,并按消息持久化以供气泡渲染,省略时
// 回退到 "chat"。
// ClientMsgID 是客户端自带的幂等键(Android 协同 §G4):它可选,同一会话内
// 同一个非空值只承认第一条 —— 重复到达返回既有消息 id(`{duplicate:true,
// messageId}`)而不再落库、不再起回合,客户端据此把本地乐观气泡对齐到服务端
// 真相(替代「靠文本相等」的脆弱匹配)。省略它时行为与加入该字段之前逐字相同。
// Parallel 是「把本条作为**独立一路**处理」的显式声明(design/15 §2.5):它可选,
// 只有忙时且并发额度未满才照准(见 orchestrator.Submit 的并行分支)。省略它时
// 行为与加入该字段之前逐字相同 —— 服务端**不做**任何启发式自动升级。
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"`
ClientMsgID string `json:"clientMsgId,omitempty"`
// Documents 是本回合附带的文档引用(design/24 §6.1):最多 maxChatDocuments
// 条,每条只给**服务端存储名**(绝不接受任意 URL)。解析与校验见
// chat_documents.go。
Documents []chatDocumentRef `json:"documents,omitempty"`
Parallel bool `json:"parallel,omitempty"`
Stream bool `json:"stream"`
}
// chatResponse 是同步聊天回复的载荷。
type chatResponse struct {
Reply string `json:"reply"`
Steps int `json:"steps"`
StopReason string `json:"stopReason"`
}
// maxChatImageBytes 是 chat.image 允许的**解码后图像字节**上限的**默认值**,与
// POST /api/v1/upload 的 maxUploadBytes(5 MiB,见 upload.go)同口径:
// 图片有上传与内联两条入口,两边必须给出同一个「一张图能有多大」的答案,
// 否则上传的限制可被 base64 内联绕开 —— 客户端发来多大就落库多大,
// 每页历史(GET /sessions/{id}/messages)与每次 WS history 回放都要
// 把整段 base64 再传一遍(协同文档 G3)。
//
// 值直接取 vision 的权威常量(解码层 internal/runtime/multimodal/vision 按同一
// 常量硬顶):两处各写一份同值常量时,配置一放宽就会漂移成「网关照放、vision 拒」。
// 它只是默认值:生效上限由配置 CHAT_IMAGE_MAX_BYTES 经 chatImageLimitBytes
// 解析,且**只能收紧、不能放宽**。
const maxChatImageBytes = vision.MaxInlineImageBytes
// chatImagePrefixAllowance 是 data: URI 前缀(如 "data:image/png;base64,")
// 所占的字符余量,取 vision 的同一常量(唯一真源),不含 base64 载荷本身。
const chatImagePrefixAllowance = vision.InlineImagePrefixAllowance
// chatImageCharsFor 把「解码后图像字节上限」换算为 image 字符串的字符上限:
// base64 每 3 字节编码为 4 字符(膨胀 4/3),因此恰好 n 字节的整幅图编码后是
// ceil(n/3)*4 = (n+2)/3*4 字符(这就是 (n+2)/3*4 的由来:余 1、2 字节时末组仍需
// 补满 4 字符),再加 data: 前缀余量 —— 恰好 n 字节的合法图片绝不会被误拒。
// 默认口径(就是 vision 硬顶)直接返回 vision.MaxInlineImageChars,配置出更小的
// 上限时按同一条膨胀系数现算 —— 网关口径与解码层口径因此不可能漂移。
//
// 校验按**字符串长度**做,是因为入口层只有这一步是零分配的:vision 层要先
// base64 解码整个字符串才拿到真实字节数(vision.decodeBase64 →
// base64.StdEncoding.DecodeString),也就是先分配再报错。在入口层按长度拦下,
// 超大字符串根本走不到解码那一步。
func chatImageCharsFor(bytesLimit int) int {
if bytesLimit == maxChatImageBytes {
return vision.MaxInlineImageChars
}
return ((bytesLimit+2)/3)*4 + chatImagePrefixAllowance
}
// chatImageLimitBytes 解析本次生效的**解码后字节**上限:配置给了正数就用它,
// 但**钳到 vision 的硬顶为止**(配置只能收紧、不能放宽);否则(未配置的零值、
// 0、负数)回落到默认 maxChatImageBytes —— 语义是「≤0 = 用默认」而**不是**
// 「禁用上限」,默认值等于 vision 硬顶,故默认行为与引入该配置之前逐字节一致。
// 钳位是因为解码层按 vision.MaxInlineImageBytes 硬顶、读不到网关配置:这里若放行
// 更大的值,base64/URL 路径就会出现「网关照放、vision 拒(ErrImageTooLarge)」,
// 即「调大配置」在真实消费路径上失效(最大只到 5 MiB);要真正支持更大的内联图,
// 必须同时把上限透传进 vision 解码层(更大改动,不在本次范围)。
func chatImageLimitBytes(configured int) int {
if configured <= 0 {
return maxChatImageBytes
}
if configured > vision.MaxInlineImageBytes {
return vision.MaxInlineImageBytes
}
return configured
}
// chatImageTooLargeCode 是 image 超限时返回给客户端的机器可读错误码,
// 与其它 400 文案区分开:客户端只看 code 就能把「图太大,请改走上传」
// 与「请求体格式错」分开处理。
const chatImageTooLargeCode = "image_too_large"
// checkChatImageSize 校验 image 字符串长度是否在 limit 之内,超限时返回
// 400 + image_too_large 的显式错误(带上限与本次长度,并指明改用
// POST /api/v1/upload 取 URL 再发)。limit 作为参数传入,使该判定能被
// 用小上限直接单测(边界与比较运算符因此可被钉住),REST 入口传的是
// chatImageCharsFor(生效的字节上限)。
//
// 文案里的体积由 limit 反推(base64 膨胀 4/3 → 解码后约 limit*3/4 字节),
// 因此换上限时文案自动跟随,不会出现「写着 5 MiB、实际是别的数」。
func checkChatImageSize(image string, limit int) error {
if len(image) <= limit {
return nil
}
return apperr.Wrap(apperr.ErrBadRequest, chatImageTooLargeCode,
"图片过大:image 上限约 %.1f MiB(base64 字符串上限 %d 字符,本次 %d 字符);请先 POST /api/v1/upload 上传,再用返回的 url 作为 image 发送",
float64(limit)*3/4/(1<<20), limit, len(image))
}
// 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
}
// image 上限在**任何**解码/落库之前检查,且对同步与 stream 两条分支
// 都生效(stream 只是把该轮转发给 ws.chat,转发前必须先拒),
// 因此超限的图片既不进模型、也不落库、也不会被历史回放重传。
// 上限来自配置对默认值的解析(CHAT_IMAGE_MAX_BYTES,≤0 = 用默认 5 MiB,
// 且钳到 vision 解码层硬顶为止 —— 配置只能收紧),换算成字符上限只有
// chatImageCharsFor 一处。
// 日志只带长度与上限,绝不带图片内容。
limit := chatImageCharsFor(chatImageLimitBytes(s.cfg.Server.ChatImageMaxBytes))
if err := checkChatImageSize(req.Image, limit); err != nil {
s.log.Warn("chat image rejected", "session", req.SessionID, "chars", len(req.Image), "limit", limit)
writeError(w, err)
return
}
// 文档引用只认服务端存储名且必须真实存在,非法 / 不存在一律当场拒(design/24 §4.4)。
docs, err := s.resolveChatDocuments(req.Documents)
if err != nil {
s.documentsLog().Warn("chat documents rejected", "session", req.SessionID, "count", len(req.Documents))
writeError(w, err)
return
}
if strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "" && len(req.Documents) == 0 {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text、image 或 documents 至少需要一项"))
return
}
if req.Stream {
// 协同 G2:不再「先回 200,再看有没有落地」。与 WS 的 chat 帧同一条
// 直达编排器的路径,由 handleChatStream 按判定回明确的状态码。
s.handleChatStream(w, r, req)
return
}
// 剩下的交给派发:把回合交给编排器(或未接线时的旧同步路径)并写响应。
s.submitChatTurn(w, r, req, docs)
}
// 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]
}
// 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))
}