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。
153 lines
6.6 KiB
Go
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)
|
|
}
|