- 原状:网关与 vision 各有一份同值常量,且网关上限可由 CHAT_IMAGE_MAX_BYTES 调大 ⇒ 调大后 **网关照放、vision 拒绝**(ErrImageTooLarge),base64/URL 路径上「调大配置」失效 - 修法:网关改为**引用 vision 的常量**(`vision.MaxInlineImageBytes` / `InlineImagePrefixAllowance` / `MaxInlineImageChars`,import 方向与既有的 voice 同层、不违规);`chatImageLimitBytes` 语义= 「configured 生效但**钳在 vision 硬顶**」(只收紧不放宽);默认口径直接用 vision 常量(不再自算) - 复核:gateway 包测试绿(9 条 ChatImage,含默认逐字节同值、配 1 MiB 生效、配 8 MiB 被钳到硬顶且 同一串在 vision 侧也 ErrImageTooLarge ⇒ 两侧同一答案);**变异两点**(去掉钳位分支 → 断言红; 再清空解析循环 → 实测复现「配 2× 硬顶时网关 200 放行」)均已还原 - 登记:真要支持 > 5 MiB 需给 vision 可注入上限(并把 /upload 的 5 MiB 一起改);`.env.example`/`config.go` 注释/`api-reference` 仍写「上限=CHAT_IMAGE_MAX_BYTES」未提钳位,待同步
500 lines
21 KiB
Go
500 lines
21 KiB
Go
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"
|
||
"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}`)而不再落库、不再起回合,客户端据此把本地乐观气泡对齐到服务端
|
||
// 真相(替代「靠文本相等」的脆弱匹配)。省略它时行为与加入该字段之前逐字相同。
|
||
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"`
|
||
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
|
||
}
|
||
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 {
|
||
// 协同 G2:不再「先回 200,再看有没有落地」。与 WS 的 chat 帧同一条
|
||
// 直达编排器的路径,由 handleChatStream 按判定回明确的状态码。
|
||
s.handleChatStream(w, r, req)
|
||
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)
|
||
// 客户端的 deviceID、行为模式与可选幂等键(协同 G4)都经 ctx 串进编排器。
|
||
ctx := withClientMsgHint(withMode(withDeviceID(r.Context(), req.DeviceID), req.Mode), clientMsgHint{ClientMsgID: req.ClientMsgID})
|
||
if s.hub.orchestrator != nil {
|
||
sr, res := s.hub.orchestrator.SubmitSync(ctx, req.SessionID, msgs, env)
|
||
// 重复到达(同一个 clientMsgId):明确告知既有消息 id,而不是再跑一遍
|
||
// 回合、再落一条用户消息(协同 G4)。
|
||
if sr.Duplicate {
|
||
writeJSON(w, http.StatusOK, map[string]any{"duplicate": true, "messageId": sr.MessageID, "sessionId": req.SessionID})
|
||
return
|
||
}
|
||
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))
|
||
}
|