package gateway import ( "context" "errors" "log/slog" "net/http" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" ) // errPersonaBindingUnsupported 由 newSubSession 在调用方要求一个 // 绑定角色的会话、但注入的 history 存储无法绑定角色时返回。 // handler 把它映射为 501,而不是假装绑定已生效。 var errPersonaBindingUnsupported = errors.New("gateway: history store does not support session persona binding") // 本文件拥有网关的回合环境解析(DevDocs/design/22 // §2/§4.5):由哪个角色应答,表达为该回合运行的 // loop.SessionEnv 环境。 // // 优先级(design/22 §2,自上而下): // 1. 请求级覆盖 —— 本批次不实现(批次 1 暴露的是 // 会话级绑定;按请求覆盖是加性且不持久化的); // 2. 会话级绑定 —— chat_sessions.persona_id,在此解析; // 3. 渠道级映射 —— 批次 2(channels.json / channel_sessions); // 4. 全局默认 —— 表达为 PersonaID == "",由 loop 解析到其 // 已配置的默认角色(Options.PersonaID)。这正是让每个 // 既有会话的行为与以往完全一致的原因。 // // env 的其他字段不受影响:对这些面向 owner 的 Web/语音路径, // ActorEnv 与 MemWritable 仍来自 loop.OwnerEnv()。这就是身份红线 // —— 谁可以做什么由 channel 的 actor 元数据决定(此处是一个 JWT // owner),绝不是由所扮演的角色决定。人格选择只改变 // 提示词、记忆命名空间、情绪机与思考预算。 // sessionPersonaReader 是 HistoryStore 可选的更窄视图,用于读取 // 会话的绑定角色。*history.Store 实现了它;未实现的存储(测试 // 替身,或早于 design/22 的存储)只是得到 "" —— 全局默认 —— 因此 // 网关继续工作。 type sessionPersonaReader interface { SessionPersona(ctx context.Context, sessionID string) (string, error) } // sessionPersonaWriter 是 HistoryStore 可选的更窄视图,用于写入 // 会话的角色绑定。会话角色在创建时决定(design/22 §8-1), // 因此不再有任何请求路径重绑 —— 创建走 subSessionCreator —— // 而网关也不再对该接口做类型断言。它留在读取器 // 与创建器旁边,使存储的角色绑定表面集中记录在一处: // *history.Store 仍实现 SetSessionPersona(design/22 §4.1),而会用它的 // 管理/owner 写路径是独立端点,不是 PATCH。 type sessionPersonaWriter interface { SetSessionPersona(ctx context.Context, sessionID, personaID string) error } // subSessionCreator 是 HistoryStore 可选的更窄视图,用于创建一个 // 已绑定角色的会话(POST /sessions {personaId},design/22 §4.5)。 // *history.Store 实现了它;没有它的存储只能创建未绑定的 // 会话,因此显式要求角色的请求以 501 拒绝。 type subSessionCreator interface { NewSubSessionFor(ctx context.Context, ownerID, personaID string) (string, error) } // turnEnvResolver 为一个回合构建 loop 会话环境。它持有 // 解析所需的两个只读依赖,因此 HTTP/WS handler // 与 hub 都经同一条代码路径解析角色(design/22 §4.5:一个 // 解析器,而不是每个入口各硬编码一份 loop.OwnerEnv())。 type turnEnvResolver struct { history HistoryStore personas PersonaStore log *slog.Logger } // owner 返回 sessionID 一个面向 owner 回合的每回合环境: // 全访问 owner env(身份不变)加上该会话绑定的角色。 // 空白角色意味着「继承全局默认」;loop 执行那最后一次 // 回退,因此网关永远不必知道默认角色的 id。 func (t turnEnvResolver) owner(ctx context.Context, sessionID string) loop.SessionEnv { env := loop.OwnerEnv() env.PersonaID = t.sessionPersona(ctx, sessionID) return env } // sessionPersona 返回绑定到 sessionID 的角色,会话继承全局 // 默认时返回 ""。未知会话、没有读取器的存储、或 // 读错误都退化为 ""(默认),而不是让该回合失败: // 聊天绝不能被一次元数据查找阻塞,而该回退正是 // 多角色之前的行为。 func (t turnEnvResolver) sessionPersona(ctx context.Context, sessionID string) string { if t.history == nil || sessionID == "" { return "" } reader, ok := t.history.(sessionPersonaReader) if !ok { return "" } personaID, err := reader.SessionPersona(ctx, sessionID) if err != nil { if t.log != nil && !errors.Is(err, history.ErrSessionNotFound) { t.log.Debug("session persona read", "err", err, "session", sessionID) } return "" } return personaID } // defaultPersonaID 返回库中默认角色的人格 id,未接线人格 // 存储或没有角色被标记为默认时返回 ""。它是 // owner.yaml:defaultPersona 的网关侧视图,用于在没有会话时 // 需要角色 id 的场合(管理记忆搜索回退,design/22 §4.5)。 func (s *Server) defaultPersonaID() string { if s.personas == nil { return "" } for _, c := range s.personas.List() { if c.Default && c.PersonaID != "" { return c.PersonaID } } return "" } // requireKnownPersona 拒绝注册表不认识的 personaId,返回 // 400 并返回 false。空 id 一律允许(它意味着「继承 // 全局默认」),而没有 persona 存储的网关无法校验,因此 // 那里接受该绑定,之后只是解析为未知角色。 func (s *Server) requireKnownPersona(w http.ResponseWriter, personaID string) bool { if personaID == "" || s.personas == nil { return true } if _, ok := s.personas.Persona(personaID); !ok { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "unknown personaId %q", personaID)) return false } return true } // newSubSession 创建一个绑定到 personaID 的子会话,存储提供 // 角色感知创建器时使用它。没有它的存储只能创建 // 未绑定会话,因此显式的角色请求被拒绝(501),而不是 // 静默丢弃该绑定。 func (s *Server) newSubSession(ctx context.Context, ownerID, personaID string) (string, error) { creator, ok := s.history.(subSessionCreator) if !ok { if personaID != "" { return "", errPersonaBindingUnsupported } return s.history.NewSubSession(ctx, ownerID) } return creator.NewSubSessionFor(ctx, ownerID, personaID) }