Files
Cyrene_v2/internal/services/gateway/persona_env.go
T
AskaEth c7ecc3a249
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(session,web): 会话角色不可变 — PATCH 拒 personaId + 移除前端换角色入口
design/22 §8-1(主人 2026-10-03 拍板):会话角色在创建时确定、之后不可变,
要换角色必须新开会话。

后端:
- PATCH /api/v1/sessions/{id} 的 personaId 改为 json.RawMessage 判「键出现」,
  出现即 400(null/""/未知 id 一律拒),且先于会话存在性检查;错误消息点名 personaId
- 删除 Server.setSessionPersona 与 PATCH 的 501 分支;title/pinned 行为逐字不变
- sessionPersonaWriter 接口与 history.Store.SetSessionPersona 保留(存储能力)

前端:
- 移除会话行「切换角色」入口与面板「挂角色」下拉(含 setSessionPersona action、
  lib/sessionPersona.ts);保留「新建会话选角色」;面板「角色」列改为只读展示

契约:Docs/api-client.md -> v0.46(破坏性:依赖 PATCH 换角色的客户端须改为新建会话)

验证:go build/vet ./... 绿;gateway+history 包测试绿(-count=1);前端 npm run build
(tsc --noEmit + vite build)绿;9 个前端逻辑测试全绿。
变异验证:改回「静默忽略」/「写绑定」两种反向实现,新断言均 FAIL。
2026-10-03 21:24:51 +08:00

153 lines
6.6 KiB
Go

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 is returned by newSubSession when a caller asks for
// a role-bound session but the injected history store cannot bind roles. The
// handler maps it to 501 rather than pretending the binding landed.
var errPersonaBindingUnsupported = errors.New("gateway: history store does not support session persona binding")
// This file owns the gateway's turn-environment resolution (DevDocs/design/22
// §2/§4.5): which role answers, expressed as the loop.SessionEnv the turn runs
// under.
//
// Precedence (design/22 §2, top down):
// 1. request-level override — NOT implemented in this batch (batch 1 exposes the
// session-level binding; a per-request override is additive and unpersisted);
// 2. session-level binding — chat_sessions.persona_id, resolved here;
// 3. channel-level mapping — batch 2 (channels.json / channel_sessions);
// 4. global default — expressed as PersonaID == "", which the loop resolves to its
// configured default role (Options.PersonaID). That is what makes every
// pre-existing session behave exactly as before.
//
// The env's OTHER fields are untouched: ActorEnv and MemWritable still come from
// loop.OwnerEnv() for these owner-facing Web/voice paths. That is the identity red
// line — who may do what is decided by the channel actor metadata (here: a JWT
// owner), never by the role being played. Persona selection only changes the
// prompt, the memory namespace, the emotion machine and the thinking budget.
// sessionPersonaReader is the optional narrower view of HistoryStore used to read a
// session's bound role. *history.Store implements it; a store that does not (a test
// fake, or a store predating design/22) simply yields "" — the global default — so
// the gateway keeps working.
type sessionPersonaReader interface {
SessionPersona(ctx context.Context, sessionID string) (string, error)
}
// sessionPersonaWriter is the optional narrower view of HistoryStore for writing a
// session's role binding. A session's role is decided at creation (design/22 §8-1),
// so no request path rebinds one any more — creation goes through subSessionCreator —
// and the gateway no longer type-asserts this interface. It stays next to the reader
// and the creator so the store's role-binding surface is documented in one place:
// *history.Store still implements SetSessionPersona (design/22 §4.1) and the
// admin/owner write path that would use it is a separate endpoint, not a PATCH.
type sessionPersonaWriter interface {
SetSessionPersona(ctx context.Context, sessionID, personaID string) error
}
// subSessionCreator is the optional narrower view of HistoryStore used to create a
// session already bound to a role (POST /sessions {personaId}, design/22 §4.5).
// *history.Store implements it; a store without it can only create an unbound
// session, so a request that explicitly asks for a role is refused with 501.
type subSessionCreator interface {
NewSubSessionFor(ctx context.Context, ownerID, personaID string) (string, error)
}
// turnEnvResolver builds the loop session environment for one turn. It holds the
// two read-only dependencies the resolution needs, so both the HTTP/WS handlers
// and the hub resolve roles through the same code path (design/22 §4.5: one
// resolver, not a hardcoded loop.OwnerEnv() per entry point).
type turnEnvResolver struct {
history HistoryStore
personas PersonaStore
log *slog.Logger
}
// owner returns the per-turn environment for one owner-facing turn of sessionID:
// the full-access owner env (identity unchanged) plus the session's bound role.
// A blank role means "inherit the global default"; the loop performs that final
// fallback, so the gateway never has to know the default role's id.
func (t turnEnvResolver) owner(ctx context.Context, sessionID string) loop.SessionEnv {
env := loop.OwnerEnv()
env.PersonaID = t.sessionPersona(ctx, sessionID)
return env
}
// sessionPersona returns the role bound to sessionID, or "" when the session
// inherits the global default. An unknown session, a store without the reader, or
// a read error all degrade to "" (the default) rather than failing the turn: a
// chat must never be blocked by a metadata lookup, and the fallback is the
// pre-multi-character behavior.
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 returns the persona id of the library's default character, or
// "" when no persona store is wired or no character is marked default. It is the
// gateway-side view of owner.yaml:defaultPersona used where a role id is needed
// without a session (the admin memory search fallback, 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 rejects a personaId the registry does not know, answering
// 400 and returning false. An empty id is always allowed (it means "inherit the
// global default"), and a gateway without a persona store cannot validate, so the
// binding is accepted there and simply resolves as an unknown role later.
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 creates a sub session bound to personaID, using the role-aware
// creator when the store provides one. A store without it can only create an
// unbound session, so an explicit role request is refused (501) instead of
// silently dropping the binding.
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)
}