Files
Cyrene_v2/internal/services/gateway/admin_write_personas_users.go
T
AskaEth 7afef8d037
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(loop,gateway,persona): awakening 逐 turn 按角色生效 + 修「面板保存角色抹掉 5 个块」的数据丢失(裁决 14.4.3)
- per-role 注入补齐:Options.PersonaAwakening(personaID) → Loop.personaAwakening → l.awakeningFor(ctx)
  → capacity.Input.Awakening(toolSchemas 是全仓唯一构造该 Input 处)
  · 按 turn 解析(不绑 Loop 构造期,避免并发会话数据竞争),与既有 personaFor/knobsFor 同构
  · 未知角色/空角色/档案无该块 ⇒ 零值 ⇒ 回落 Config.Awakening(不声明者行为与今天构造性一致)
- **数据丢失修复**:persona PUT 是整块替换,此前只保留 thinking ⇒ 保存一次会同时抹掉
  awakening / visual(头像·背景) / triggerWords(群聊提醒词) / emotion(情绪机参数) / affection(好感度曲线)。
  新增 applyAwakeningEdit + applyPersonaReadOnlyBlocks 逐一保留(cyrene.yaml 五块全写,属必现故障)
- 文件守 500 行:主人档案段原样搬移到 admin_owner_profile.go(零逻辑变更)
- 复核:build/vet 绿 + 全树 `go test ./... -count=1` 0 FAIL;端到端=真 YAML→loader→buildCapacity→真 loop
  流式请求→断言工具面(tasky 声明 force_state 唤醒 → 含 web-search;cyrene 不声明 → 与无解析器对照逐字相同);
  **8 条变异反证**先红后还原(含逐块删除各自的保留逻辑)
- 残留缺口(登记):wire_runtime.go:254 那一行本身无用例覆盖;loader 非 strict(未知 YAML 键静默忽略,
  需主人裁决是否开 KnownFields);POST /admin/personas 仍写不出这五个块
2026-10-04 13:07:52 +08:00

398 lines
16 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"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
)
// 人格到身份与 API key 的管理写操作(仅 owner,带审计)。人格
// 写操作注册进人格注册表(真实的运行时效果),并持久化
// 一个 YAML 文件使其在重启后存活;API key 在
// auth.APIKey 存储中铸造/吊销。
const personaDir = "configs/personas"
// personaUpsertRequest 是 POST/PUT /api/v1/admin/personas 的请求体。
type personaUpsertRequest struct {
ID string `json:"id"`
Name string `json:"name"`
CharacterRef string `json:"characterRef"`
Role string `json:"role"`
SystemPrompt string `json:"systemPrompt"`
Personality string `json:"personality"`
SpeechStyle string `json:"speechStyle"`
Catchphrase string `json:"catchphrase"`
Background string `json:"background"`
Values []string `json:"values"`
Modes []string `json:"modes"`
Mode string `json:"mode"`
MemoryNS string `json:"memoryNamespace"`
// Thinking 是可选的推理/呈现块(design/01 §2.1)。它
// 原样持久化进人格 YAML;更新时省略它会保留
// 已存块(见 adminUpdatePersona)。
Thinking *personaThinkingRequest `json:"thinking,omitempty"`
}
// personaThinkingRequest 是人格 `thinking` 段的线上形式。
// 其指针/命名与 YAML 一致,使面板能原样往返。
type personaThinkingRequest struct {
BudgetTokens int `json:"budgetTokens,omitempty"`
Effort string `json:"effort,omitempty"`
ShowToUser *bool `json:"showToUser,omitempty"`
Mode string `json:"mode,omitempty"`
}
// adminCreatePersona 在注册表中注册一个新人格(真实的运行时
// 效果),并持久化进人格 YAML 库使其在重启后存活。
func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) {
var req personaUpsertRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.ID) == "" || strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Role) == "" {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id, name and role are required"))
return
}
// identity.systemPrompt 必填(裁决 08.4.2):这里不做回落、不派生默认值 ——
// 否则一条没有身份的「人格」会被静默降级成拼凑出的说明串。载入期
// (loader.Load)执行同一规则,因此写进来的档案必然也能被重新载入。
if strings.TrimSpace(req.SystemPrompt) == "" {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "systemPrompt is required (identity.systemPrompt must not be empty)"))
return
}
reg, ok := s.personas.(*personareg.Registry)
if !ok {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_unavailable", "persona registry is not configured"))
return
}
if _, exists := reg.Persona(req.ID); exists {
s.auditClaims(r, "persona.create", "error")
writeError(w, &apperr.AppError{Code: "duplicate", Message: "人格已存在,请使用编辑", HTTPCode: http.StatusConflict})
return
}
p := personaFromRequest(req)
if err := p.ValidateThinking(); err != nil {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid thinking config: %v", err))
return
}
if err := savePersonaYAML(p); err != nil {
s.auditClaims(r, "persona.create", "error")
s.log.Error("persona persist", "id", req.ID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_persist_failed", "failed to persist persona"))
return
}
if err := reg.RegisterPersona(p); err != nil {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_register_failed", "failed to register persona"))
return
}
if err := reg.RegisterCharacter(characterFromRequest(req)); err != nil {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_register_failed", "failed to register character"))
return
}
s.auditClaims(r, "persona.create", "ok")
writeJSON(w, http.StatusCreated, s.personaSummaryOf(p, characterFromRequest(req)))
}
// adminUpdatePersona 编辑既有人格,持久化 YAML 并
// 替换内存中的人格/角色。
func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req personaUpsertRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Role) == "" {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name and role are required"))
return
}
// identity.systemPrompt 必填(裁决 08.4.2),与创建路径同一把门:缺失/空白
// 显式报错,不回落、不静默保留旧值(「没改」要由客户端显式传回原值)。
if strings.TrimSpace(req.SystemPrompt) == "" {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "systemPrompt is required (identity.systemPrompt must not be empty)"))
return
}
req.ID = id
reg, ok := s.personas.(*personareg.Registry)
if !ok {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_unavailable", "persona registry is not configured"))
return
}
if _, exists := reg.Persona(id); !exists {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "persona %q not found", id))
return
}
p := personaFromRequest(req)
if err := p.ValidateThinking(); err != nil {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid thinking config: %v", err))
return
}
// 面板的写路径是**整块替换**,而请求体只带它认识的字段。请求体里不出现的
// 角色级块一律保留已存值,否则「面板改一次角色」就会抹掉手工写进 YAML 的
// 配置(数据丢失):
existing := existingPersona(reg, id)
// 不认识可选 `thinking` 块的客户端(旧面板、脚本)会省略它;
// 应保留已存的那个,而不是静默清掉手工编辑的
// 配置。发送该对象则替换它,全字段为默认值
// 时清除它。
p.Thinking = applyThinkingEdit(req.Thinking, existing)
p.Awakening = applyAwakeningEdit(existing)
applyPersonaReadOnlyBlocks(p, existing)
if err := savePersonaYAML(p); err != nil {
s.auditClaims(r, "persona.update", "error")
s.log.Error("persona persist", "id", id, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_persist_failed", "failed to persist persona"))
return
}
if err := reg.UpdatePersona(p); err != nil {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_update_failed", "failed to update persona"))
return
}
if err := reg.UpdateCharacter(characterFromRequest(req)); err != nil {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_update_failed", "failed to update character"))
return
}
s.auditClaims(r, "persona.update", "ok")
writeJSON(w, http.StatusOK, s.personaSummaryOf(p, characterFromRequest(req)))
}
// adminCreateUser 诚实作答:既没有持久化用户表,也没有
// 可供支撑的用户认证登录(部署形态是来自 .env 的单个管理
// 账号加运行时 API key;design/11 是单用户优先),因此
// 创建用户会是一行没人能登录的记录。它报告缺失的
// 存储,而不是编造一个用户。
func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) {
s.auditClaims(r, "user.create", "error")
writeError(w, &apperr.AppError{
Code: "not_implemented",
Message: "新增用户/角色未实现:无用户表,登录仅用 .env 单 admin(ADMIN_USERNAME/PASSWORD),角色为 owner>admin>viewer 常量;多用户存储与登录接入需先设计(design/11 单用户为主)",
HTTPCode: http.StatusNotImplemented,
})
}
// adminIssueApiKey 铸造一个真实 API key 并返回一次明文(唯一可见的一次)。
// 响应中的 id 是管理列表所展示的脱敏形式,之后可用它吊销该 key。
//
// 按裁决 15.4.3,key **不做角色分级**:本端点不再读请求体里的 role(也不
// 接受 role 语义),表里也没有角色维度 —— 任何有效 key 鉴权后都是超级管理员
// (owner)。响应仍回传实际授予的角色(恒为 owner),让调用方一眼看到
// 「签出去的就是超管 key」,而不是以为自己在签一把只读 key。
func (s *Server) adminIssueApiKey(w http.ResponseWriter, r *http.Request) {
keys, ok := s.users.(*auth.APIKey)
if !ok {
s.auditClaims(r, "apikey.issue", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置"))
return
}
key, err := keys.Generate()
if err != nil {
s.auditClaims(r, "apikey.issue", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "apikey_failed", "failed to issue api key"))
return
}
s.auditClaims(r, "apikey.issue", "ok")
writeJSON(w, http.StatusCreated, map[string]any{
"id": auth.MaskKey(key),
"key": key,
"role": string(auth.RoleOwner),
})
}
// adminRevokeApiKey 按脱敏 id(管理列表返回的标识)吊销一个
// key。没有 key 携带该掩码时报告 not-found。
func (s *Server) adminRevokeApiKey(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
keys, ok := s.users.(*auth.APIKey)
if !ok {
s.auditClaims(r, "apikey.revoke", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置"))
return
}
if !keys.RevokeByMask(id) {
s.auditClaims(r, "apikey.revoke", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "api key not found"))
return
}
s.auditClaims(r, "apikey.revoke", "ok")
writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true})
}
// personaFromRequest 从 upsert 请求构建 loader.Persona。characterRef 省略时
// 回落到 id(它只是资源引用,有人格 id 就够);identity.systemPrompt 则
// **不回落到任何派生串** —— 缺失由调用方显式报 400(裁决 08.4.2),这样写进
// 磁盘的人格必然带着真实身份,也必然能被 loader.Load 重新载入。
func personaFromRequest(req personaUpsertRequest) *loader.Persona {
characterRef := strings.TrimSpace(req.CharacterRef)
if characterRef == "" {
characterRef = req.ID
}
return &loader.Persona{
ID: req.ID,
Name: req.Name,
CharacterRef: characterRef,
Identity: loader.Identity{Role: req.Role, SystemPrompt: strings.TrimSpace(req.SystemPrompt)},
Personality: req.Personality,
SpeechStyle: req.SpeechStyle,
Catchphrase: req.Catchphrase,
Background: req.Background,
Values: req.Values,
Emotion: loader.EmotionParams{MemoryNamespace: strings.TrimSpace(req.MemoryNS)},
Thinking: thinkingFromRequest(req.Thinking),
}
}
// thinkingFromRequest 把线上 `thinking` 块映射到人格字段;
// 块缺失得到零值,yaml.Marshal 会完全省略它。
func thinkingFromRequest(req *personaThinkingRequest) loader.ThinkingParams {
if req == nil {
return loader.ThinkingParams{}
}
return loader.ThinkingParams{
BudgetTokens: req.BudgetTokens,
Effort: strings.TrimSpace(req.Effort),
ShowToUser: req.ShowToUser,
Mode: strings.TrimSpace(req.Mode),
}
}
// existingPersona 返回 id 对应的已存人格,不存在时为 nil。
func existingPersona(reg *personareg.Registry, id string) *loader.Persona {
if p, ok := reg.Persona(id); ok {
return p
}
return nil
}
// applyThinkingEdit 返回更新应持久化的 `thinking` 块:请求自带
// 块时用请求的,否则用已存人格的块 ——
// 因此不认识该可选字段的客户端绝不可能清掉它。发送
// 该对象的更新会直接替换整块(全默认字段则清除它)。
func applyThinkingEdit(req *personaThinkingRequest, existing *loader.Persona) loader.ThinkingParams {
if req != nil {
return thinkingFromRequest(req)
}
if existing != nil {
return existing.Thinking
}
return loader.ThinkingParams{}
}
// applyAwakeningEdit 返回更新应持久化的 `awakening` 块(design/14 §3,裁决
// 14.4.3):请求体不携带它就保留已存档位。面板目前**没有**该块的编辑控件
// (档位只能写进 `configs/personas/*.yaml`),因此像 applyThinkingEdit 的
// 「省略即保留」那样,更新路径必须留住它 —— 否则面板保存一次角色,刚手工写好
// 的唤醒档位就被抹成 nil,配置被静默丢失(design/01 §2.1 禁止静默无效的配置)。
// 面板将来支持编辑它时,在这里加请求体分支(自带块则替换、缺省仍保留),
// 调用点不动。
func applyAwakeningEdit(existing *loader.Persona) *loader.AwakeningParams {
if existing == nil {
return nil
}
return existing.Awakening
}
// applyPersonaReadOnlyBlocks 保留请求体**根本不携带**的角色级块:视觉资产
// (visual:头像/聊天背景)、角色提醒词(triggerWords,design/18 §2 触发②)、情绪机
// 参数(emotion 的 initialState/decayRate/transitions)与好感度曲线(affection)。
// 它们都只能由人格 YAML 手工声明(面板不提供编辑控件,请求体里也没有对应字段),
// 而写路径是**整块替换** —— 不显式保留,一次「改个角色名」的保存就会把它们从
// 档案与注册表里一并抹掉(数据丢失,design/01 §2.1)。
//
// emotion.memoryNamespace 刻意不在此列:面板确实编辑它(`memoryNamespace`),
// 由请求体决定。
func applyPersonaReadOnlyBlocks(p, existing *loader.Persona) {
if p == nil || existing == nil {
return
}
p.Visual = existing.Visual
p.TriggerWords = append([]string(nil), existing.TriggerWords...)
p.Emotion.InitialState = existing.Emotion.InitialState
p.Emotion.DecayRate = existing.Emotion.DecayRate
p.Emotion.Transitions = existing.Emotion.Transitions
p.Affection = existing.Affection
}
// characterFromRequest 构建绑定到该人格的 registry.Character。
func characterFromRequest(req personaUpsertRequest) *personareg.Character {
modes := append([]string(nil), req.Modes...)
if len(modes) == 0 && req.Mode != "" {
modes = []string{req.Mode}
}
return &personareg.Character{ID: req.ID, Name: req.Name, PersonaID: req.ID, Modes: modes}
}
// personaSummaryOf 把人格+角色映射为其线上摘要。它与
// 管理列表返回的形状相同 —— 角色级 mood 加上 `thinking` 块 ——
// 因此写响应与随后的读对人格的描述完全一致
// (客户端可信任任一方用于回填)。
func (s *Server) personaSummaryOf(p *loader.Persona, c *personareg.Character) PersonaSummary {
return PersonaSummary{
ID: p.ID,
Name: c.Name,
Character: c.Name,
IsDefault: c.Default,
Mode: firstMode(c.Modes),
MemoryNS: p.MemoryNamespace(),
Mood: s.moodOf(p.ID),
Thinking: thinkingSummaryOf(p.Thinking, true),
}
}
// savePersonaYAML 把 p 以 <id>.yaml 持久化进人格库,使其在
// 重启后存活(wire.go 加载该目录)。id 会被净化,使用户
// 输入无法穿越出库目录。
func savePersonaYAML(p *loader.Persona) error {
data, err := yaml.Marshal(p)
if err != nil {
return fmt.Errorf("persona: marshal %q: %w", p.ID, err)
}
if err := os.MkdirAll(personaDir, 0o755); err != nil {
return fmt.Errorf("persona: mkdir: %w", err)
}
path := filepath.Join(personaDir, sanitizeID(p.ID)+".yaml")
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("persona: write %s: %w", path, err)
}
return nil
}
// sanitizeID 只保留 URL/路径安全字符,使人格 id 不能
// 逃出人格目录。
func sanitizeID(id string) string {
var b strings.Builder
for _, r := range id {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
return b.String()
}