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 仍写不出这五个块
This commit is contained in:
1 parent
58cba0b8d2
commit
7afef8d037
11 files changed
+778
-142
No files matched your search
@@ -0,0 +1,137 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// 本文件拥有主人档案的读写(裁决 10.4.5),从 admin_write_personas_users.go 拆出
|
||||
// (AGENTS §10 文件 ≤500 行)。
|
||||
|
||||
// --- 主人档案(裁决 10.4.5)--------------------------------------------------
|
||||
//
|
||||
// 档案是 design/11 §0 的身份红线:渠道层用它的 accounts 白名单比对 actor 元数据
|
||||
// 认出主人,所以读写都只给 owner。落盘 configs/users/owner.yaml(0600 原子写)
|
||||
// 后热替换活档案 —— 面板的「保存」因此是真保存,不再是只改本地 state 的假成功。
|
||||
|
||||
// ownerProfileResponse 是主人档案的线上形式,字段与 user.OwnerProfile 的 YAML
|
||||
// 一一对应,面板可原样往返回填。
|
||||
type ownerProfileResponse struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
DefaultPersona string `json:"defaultPersona,omitempty"`
|
||||
IdentityFile string `json:"identityFile,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Accounts []ownerAccountRequest `json:"accounts"`
|
||||
}
|
||||
|
||||
// ownerAccountRequest 是一条渠道账号白名单项(平台 + 该平台的账号 id)。
|
||||
type ownerAccountRequest struct {
|
||||
Platform string `json:"platform"`
|
||||
AccountID string `json:"accountId"`
|
||||
}
|
||||
|
||||
// ownerProfileFrom 把档案映射为线上形式,accounts 一律是空数组而不是 null。
|
||||
func ownerProfileFrom(p *user.OwnerProfile) ownerProfileResponse {
|
||||
out := ownerProfileResponse{
|
||||
ID: p.ID, DisplayName: p.DisplayName, DefaultPersona: p.DefaultPersona,
|
||||
IdentityFile: p.IdentityFile, Notes: p.Notes,
|
||||
Accounts: make([]ownerAccountRequest, 0, len(p.Accounts)),
|
||||
}
|
||||
for _, a := range p.Accounts {
|
||||
out.Accounts = append(out.Accounts, ownerAccountRequest{Platform: a.Platform, AccountID: a.AccountID})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toOwnerProfile 把线上档案映射回 user.OwnerProfile,并去掉各字段首尾空白
|
||||
// (面板里手打的平台名/账号 id 常带空格,带进白名单就永远匹配不上)。
|
||||
func (r ownerProfileResponse) toOwnerProfile() *user.OwnerProfile {
|
||||
p := &user.OwnerProfile{
|
||||
ID: strings.TrimSpace(r.ID), DisplayName: strings.TrimSpace(r.DisplayName),
|
||||
DefaultPersona: strings.TrimSpace(r.DefaultPersona), IdentityFile: strings.TrimSpace(r.IdentityFile),
|
||||
Notes: strings.TrimSpace(r.Notes),
|
||||
Accounts: make([]user.OwnerAccount, 0, len(r.Accounts)),
|
||||
}
|
||||
for _, a := range r.Accounts {
|
||||
p.Accounts = append(p.Accounts, user.OwnerAccount{
|
||||
Platform: strings.TrimSpace(a.Platform), AccountID: strings.TrimSpace(a.AccountID),
|
||||
})
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// adminGetOwnerProfile 返回落盘的主人档案。文件不存在(全新安装,还没人配过
|
||||
// 主人)时返回一份空白档案而不是 404 —— 面板据此渲染一张空表单让主人首次填写。
|
||||
func (s *Server) adminGetOwnerProfile(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := user.LoadOwnerProfile(ownerProfilePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
writeJSON(w, http.StatusOK, ownerProfileFrom(&user.OwnerProfile{}))
|
||||
return
|
||||
}
|
||||
// 坏档案(IO 失败或必填字段缺失)如实报告,不返回一份看似正常的空档案:
|
||||
// 那会让「白名单是空的」和「档案读不出来」看起来一模一样。
|
||||
s.log.Error("owner profile read", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_unreadable", "failed to read owner profile: %v", err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ownerProfileFrom(p))
|
||||
}
|
||||
|
||||
// adminPutOwnerProfile 校验并落盘主人档案(0600 原子写),随后热替换活档案,使
|
||||
// 新白名单**立即**参与渠道层的 actor 判定,不必重启(design/11 §9「可编辑、
|
||||
// 热重载」)。校验失败或落盘失败都以明确的错误作答,绝不假装保存成功。
|
||||
func (s *Server) adminPutOwnerProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var req ownerProfileResponse
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
p := req.toOwnerProfile()
|
||||
if err := p.Validate(); err != nil {
|
||||
s.auditClaims(r, "owner.profile.update", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid owner profile: %v", err))
|
||||
return
|
||||
}
|
||||
if err := user.SaveOwnerProfile(ownerProfilePath, p); err != nil {
|
||||
s.auditClaims(r, "owner.profile.update", "error")
|
||||
s.log.Error("owner profile persist", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_persist_failed", "failed to persist owner profile"))
|
||||
return
|
||||
}
|
||||
s.hotSwapOwnerProfile(p)
|
||||
s.auditClaims(r, "owner.profile.update", "ok")
|
||||
writeJSON(w, http.StatusOK, ownerProfileFrom(p))
|
||||
}
|
||||
|
||||
// liveOwnerProfile 是 OwnerRegistrar 的**可选**扩展:组合根额外实现它时(其
|
||||
// ownerRegistrar 持有启动时加载的活 *user.OwnerProfile,也就是渠道 bridge 用来
|
||||
// 解析 actor 的同一个指针),admin 写路径就能原地热替换那份档案。
|
||||
type liveOwnerProfile interface {
|
||||
// LiveOwnerProfile 返回活的档案;启动时未能加载档案时为 nil。
|
||||
LiveOwnerProfile() *user.OwnerProfile
|
||||
}
|
||||
|
||||
// hotSwapOwnerProfile 把刚落盘的档案原地装进活档案。拿不到活档案时只记一条
|
||||
// WARN 说明「已落盘、需重启生效」,绝不假装热重载成功。
|
||||
func (s *Server) hotSwapOwnerProfile(p *user.OwnerProfile) {
|
||||
holder, ok := s.ownerRegistrar.(liveOwnerProfile)
|
||||
if !ok {
|
||||
s.log.Warn("owner profile saved; live profile not exposed by composition root, restart to apply")
|
||||
return
|
||||
}
|
||||
live := holder.LiveOwnerProfile()
|
||||
if live == nil {
|
||||
s.log.Warn("owner profile saved; no live profile was loaded at boot, restart to apply")
|
||||
return
|
||||
}
|
||||
live.Replace(p)
|
||||
s.log.Info("owner profile hot-reloaded", "accounts", len(p.Accounts))
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
)
|
||||
|
||||
// 本文件锁定人格写路径的**非破坏性更新**语义:面板 PUT 是整块替换,而请求体只
|
||||
// 带它认识的字段,因此「请求体里不出现的角色级块」必须保留已存值。曾经只有
|
||||
// `thinking` 做了这件事,`awakening`(裁决 14.4.3 刚接入的档位)、`visual`、
|
||||
// `triggerWords`、`emotion`(初始态/衰减/转移表)与 `affection`(好感度曲线)都会被
|
||||
// 一次面板保存静默抹掉 —— 配置从档案里消失(design/01 §2.1 禁止静默无效的配置),
|
||||
// 随仓库发布的 configs/personas/cyrene.yaml 正是受害者(它五个块都写了)。
|
||||
|
||||
// personaWithReadOnlyBlocks 造一个「手工写过 YAML」的角色:这一整类只读块都配齐。
|
||||
func personaWithReadOnlyBlocks() *loader.Persona {
|
||||
return &loader.Persona{
|
||||
ID: "xilian", Name: "昔涟", CharacterRef: "xilian",
|
||||
Identity: loader.Identity{Role: "r", SystemPrompt: "你是昔涟。"},
|
||||
Awakening: &loader.AwakeningParams{
|
||||
DefaultState: loader.AwakeningStateQuiet,
|
||||
Overrides: &loader.AwakeningOverrides{MinConfidence: 0.7, ForceState: loader.AwakeningStateAwake},
|
||||
},
|
||||
Visual: loader.PersonaVisual{
|
||||
Avatar: "/images/Cyrene_Avatar/x.png",
|
||||
ChatBackground: loader.ChatBackground{Landscape: "l.png", Portrait: "p.png"},
|
||||
},
|
||||
TriggerWords: []string{"昔涟", "Cyrene"},
|
||||
Emotion: loader.EmotionParams{
|
||||
InitialState: emotion.Happy,
|
||||
DecayRate: 0.05,
|
||||
Transitions: map[emotion.EmotionState][]emotion.EmotionState{emotion.Happy: {emotion.Calm}},
|
||||
},
|
||||
Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10},
|
||||
}
|
||||
}
|
||||
|
||||
// registerStoredPersona 把该角色连同一个同名角色登记进注册表(写路径要求两者都在)。
|
||||
func registerStoredPersona(t *testing.T, p *loader.Persona) *personareg.Registry {
|
||||
t.Helper()
|
||||
|
||||
reg := personareg.New()
|
||||
if err := reg.RegisterPersona(p); err != nil {
|
||||
t.Fatalf("RegisterPersona: %v", err)
|
||||
}
|
||||
if err := reg.RegisterCharacter(&personareg.Character{ID: p.ID, Name: p.Name, PersonaID: p.ID}); err != nil {
|
||||
t.Fatalf("RegisterCharacter: %v", err)
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
// TestPersonaUpdate_PreservesReadOnlyBlocks 是那次数据丢失的回归锁:面板 PUT 一次
|
||||
// (请求体只带 name/role/systemPrompt),五个只读块必须**仍在** —— 在内存注册表里、
|
||||
// 也在落盘 YAML 里(能重新载入出同一份配置)。
|
||||
func TestPersonaUpdate_PreservesReadOnlyBlocks(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
reg := registerStoredPersona(t, personaWithReadOnlyBlocks())
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Personas: reg})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/personas/xilian", map[string]any{
|
||||
"name": "昔涟", "role": "r", "systemPrompt": "你是昔涟。",
|
||||
}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// 内存注册表:一个块都不能少(更新会据此重建情绪机与好感度曲线)。
|
||||
p, ok := reg.Persona("xilian")
|
||||
if !ok {
|
||||
t.Fatal("persona disappeared from the registry")
|
||||
}
|
||||
assertReadOnlyBlocks(t, "registry", p)
|
||||
|
||||
// 落盘 YAML:面板保存的是真档案,重新载入必须得到同样的块。
|
||||
reloaded, err := loader.Load(filepath.Join(personaDir, "xilian.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("reload the saved persona: %v", err)
|
||||
}
|
||||
assertReadOnlyBlocks(t, "saved yaml", reloaded)
|
||||
}
|
||||
|
||||
// TestPersonaUpdate_KeepsMemoryNamespaceFromRequest 守住那次修复的边界:
|
||||
// `emotion.memoryNamespace` **不是**只读块(面板确实编辑它),因此保留其它情绪参数
|
||||
// 不得把请求体里的新命名空间一并丢掉。
|
||||
func TestPersonaUpdate_KeepsMemoryNamespaceFromRequest(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
stored := personaWithReadOnlyBlocks()
|
||||
stored.Emotion.MemoryNamespace = "persona.old"
|
||||
reg := registerStoredPersona(t, stored)
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Personas: reg})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/personas/xilian", map[string]any{
|
||||
"name": "昔涟", "role": "r", "systemPrompt": "你是昔涟。", "memoryNamespace": "persona.new",
|
||||
}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
p, _ := reg.Persona("xilian")
|
||||
if p.Emotion.MemoryNamespace != "persona.new" {
|
||||
t.Fatalf("memoryNamespace = %q, want the request's value (面板确实编辑它)", p.Emotion.MemoryNamespace)
|
||||
}
|
||||
if p.Emotion.InitialState != emotion.Happy || p.Emotion.DecayRate != 0.05 {
|
||||
t.Fatalf("emotion params = %+v, want the stored initialState/decayRate", p.Emotion)
|
||||
}
|
||||
}
|
||||
|
||||
// assertReadOnlyBlocks 断言这一整类只读块逐项还在(含嵌套值)。
|
||||
func assertReadOnlyBlocks(t *testing.T, where string, p *loader.Persona) {
|
||||
t.Helper()
|
||||
|
||||
if p.Awakening == nil {
|
||||
t.Fatalf("%s: awakening block was wiped by the update (档位数据丢失)", where)
|
||||
}
|
||||
if p.Awakening.DefaultState != loader.AwakeningStateQuiet {
|
||||
t.Errorf("%s: awakening.default_state = %q, want quiet", where, p.Awakening.DefaultState)
|
||||
}
|
||||
if ov := p.Awakening.Overrides; ov == nil || ov.MinConfidence != 0.7 || ov.ForceState != loader.AwakeningStateAwake {
|
||||
t.Errorf("%s: awakening.overrides = %+v, want 0.7/awake", where, p.Awakening.Overrides)
|
||||
}
|
||||
if p.Visual.Avatar != "/images/Cyrene_Avatar/x.png" ||
|
||||
p.Visual.ChatBackground.Landscape != "l.png" || p.Visual.ChatBackground.Portrait != "p.png" {
|
||||
t.Errorf("%s: visual = %+v, want the stored assets (头像/背景数据丢失)", where, p.Visual)
|
||||
}
|
||||
if len(p.TriggerWords) != 2 || p.TriggerWords[0] != "昔涟" || p.TriggerWords[1] != "Cyrene" {
|
||||
t.Errorf("%s: triggerWords = %v, want [昔涟 Cyrene] (角色提醒词数据丢失)", where, p.TriggerWords)
|
||||
}
|
||||
if p.Emotion.InitialState != emotion.Happy || p.Emotion.DecayRate != 0.05 || len(p.Emotion.Transitions) != 1 {
|
||||
t.Errorf("%s: emotion = %+v, want the stored initialState/decayRate/transitions (情绪机参数被重置)", where, p.Emotion)
|
||||
}
|
||||
if p.Affection.Delta != 10 || p.Affection.Max != 700 || p.Affection.Initial != 50 {
|
||||
t.Errorf("%s: affection = %+v, want 50/0/700/10 (好感度曲线被重置)", where, p.Affection)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyPersonaReadOnlyBlocks_PreserveAndIsolate 直接锁两个编辑助手:非 nil 的
|
||||
// 已存角色一律保留;没有已存角色(创建路径不会走到这里,但助手必须不 panic)得到
|
||||
// 零值。保留的切片还要**复制**,否则改一处会把注册表里那份也改掉。
|
||||
func TestApplyPersonaReadOnlyBlocks_PreserveAndIsolate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stored := personaWithReadOnlyBlocks()
|
||||
if got := applyAwakeningEdit(stored); got != stored.Awakening {
|
||||
t.Fatalf("applyAwakeningEdit(stored) = %+v, want the stored block", got)
|
||||
}
|
||||
|
||||
// 请求体里的命名空间在保留其它情绪参数时不被覆盖。
|
||||
p := &loader.Persona{Emotion: loader.EmotionParams{MemoryNamespace: "persona.new"}}
|
||||
applyPersonaReadOnlyBlocks(p, stored)
|
||||
if p.Emotion.MemoryNamespace != "persona.new" {
|
||||
t.Fatalf("memoryNamespace = %q, want the request's value", p.Emotion.MemoryNamespace)
|
||||
}
|
||||
if p.Visual.Avatar != stored.Visual.Avatar || p.Emotion.InitialState != emotion.Happy ||
|
||||
p.Affection.Delta != 10 || len(p.TriggerWords) != 2 {
|
||||
t.Fatalf("read-only blocks were not preserved: %+v", p)
|
||||
}
|
||||
p.TriggerWords[0] = "改了"
|
||||
if stored.TriggerWords[0] != "昔涟" {
|
||||
t.Fatal("applyPersonaReadOnlyBlocks aliased the registry's slice")
|
||||
}
|
||||
|
||||
// 创建路径(没有已存人格):助手必须什么都不做,而不是 panic。
|
||||
created := &loader.Persona{Emotion: loader.EmotionParams{MemoryNamespace: "persona.cyrene"}}
|
||||
applyPersonaReadOnlyBlocks(created, nil)
|
||||
if created.Awakening != nil || created.Visual != (loader.PersonaVisual{}) || created.Affection != (loader.AffectionParams{}) {
|
||||
t.Fatalf("a nil stored persona must leave the request's persona alone: %+v", created)
|
||||
}
|
||||
if applyAwakeningEdit(nil) != nil {
|
||||
t.Fatal("applyAwakeningEdit(nil) must yield nil")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -15,7 +14,6 @@ import (
|
||||
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"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// 人格到身份与 API key 的管理写操作(仅 owner,带审计)。人格
|
||||
@@ -152,11 +150,17 @@ func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) {
|
||||
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, existingPersona(reg, id))
|
||||
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)
|
||||
@@ -238,128 +242,6 @@ func (s *Server) adminRevokeApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true})
|
||||
}
|
||||
|
||||
// --- 主人档案(裁决 10.4.5)--------------------------------------------------
|
||||
//
|
||||
// 档案是 design/11 §0 的身份红线:渠道层用它的 accounts 白名单比对 actor 元数据
|
||||
// 认出主人,所以读写都只给 owner。落盘 configs/users/owner.yaml(0600 原子写)
|
||||
// 后热替换活档案 —— 面板的「保存」因此是真保存,不再是只改本地 state 的假成功。
|
||||
|
||||
// ownerProfileResponse 是主人档案的线上形式,字段与 user.OwnerProfile 的 YAML
|
||||
// 一一对应,面板可原样往返回填。
|
||||
type ownerProfileResponse struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
DefaultPersona string `json:"defaultPersona,omitempty"`
|
||||
IdentityFile string `json:"identityFile,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Accounts []ownerAccountRequest `json:"accounts"`
|
||||
}
|
||||
|
||||
// ownerAccountRequest 是一条渠道账号白名单项(平台 + 该平台的账号 id)。
|
||||
type ownerAccountRequest struct {
|
||||
Platform string `json:"platform"`
|
||||
AccountID string `json:"accountId"`
|
||||
}
|
||||
|
||||
// ownerProfileFrom 把档案映射为线上形式,accounts 一律是空数组而不是 null。
|
||||
func ownerProfileFrom(p *user.OwnerProfile) ownerProfileResponse {
|
||||
out := ownerProfileResponse{
|
||||
ID: p.ID, DisplayName: p.DisplayName, DefaultPersona: p.DefaultPersona,
|
||||
IdentityFile: p.IdentityFile, Notes: p.Notes,
|
||||
Accounts: make([]ownerAccountRequest, 0, len(p.Accounts)),
|
||||
}
|
||||
for _, a := range p.Accounts {
|
||||
out.Accounts = append(out.Accounts, ownerAccountRequest{Platform: a.Platform, AccountID: a.AccountID})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toOwnerProfile 把线上档案映射回 user.OwnerProfile,并去掉各字段首尾空白
|
||||
// (面板里手打的平台名/账号 id 常带空格,带进白名单就永远匹配不上)。
|
||||
func (r ownerProfileResponse) toOwnerProfile() *user.OwnerProfile {
|
||||
p := &user.OwnerProfile{
|
||||
ID: strings.TrimSpace(r.ID), DisplayName: strings.TrimSpace(r.DisplayName),
|
||||
DefaultPersona: strings.TrimSpace(r.DefaultPersona), IdentityFile: strings.TrimSpace(r.IdentityFile),
|
||||
Notes: strings.TrimSpace(r.Notes),
|
||||
Accounts: make([]user.OwnerAccount, 0, len(r.Accounts)),
|
||||
}
|
||||
for _, a := range r.Accounts {
|
||||
p.Accounts = append(p.Accounts, user.OwnerAccount{
|
||||
Platform: strings.TrimSpace(a.Platform), AccountID: strings.TrimSpace(a.AccountID),
|
||||
})
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// adminGetOwnerProfile 返回落盘的主人档案。文件不存在(全新安装,还没人配过
|
||||
// 主人)时返回一份空白档案而不是 404 —— 面板据此渲染一张空表单让主人首次填写。
|
||||
func (s *Server) adminGetOwnerProfile(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := user.LoadOwnerProfile(ownerProfilePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
writeJSON(w, http.StatusOK, ownerProfileFrom(&user.OwnerProfile{}))
|
||||
return
|
||||
}
|
||||
// 坏档案(IO 失败或必填字段缺失)如实报告,不返回一份看似正常的空档案:
|
||||
// 那会让「白名单是空的」和「档案读不出来」看起来一模一样。
|
||||
s.log.Error("owner profile read", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_unreadable", "failed to read owner profile: %v", err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ownerProfileFrom(p))
|
||||
}
|
||||
|
||||
// adminPutOwnerProfile 校验并落盘主人档案(0600 原子写),随后热替换活档案,使
|
||||
// 新白名单**立即**参与渠道层的 actor 判定,不必重启(design/11 §9「可编辑、
|
||||
// 热重载」)。校验失败或落盘失败都以明确的错误作答,绝不假装保存成功。
|
||||
func (s *Server) adminPutOwnerProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var req ownerProfileResponse
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
p := req.toOwnerProfile()
|
||||
if err := p.Validate(); err != nil {
|
||||
s.auditClaims(r, "owner.profile.update", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid owner profile: %v", err))
|
||||
return
|
||||
}
|
||||
if err := user.SaveOwnerProfile(ownerProfilePath, p); err != nil {
|
||||
s.auditClaims(r, "owner.profile.update", "error")
|
||||
s.log.Error("owner profile persist", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_persist_failed", "failed to persist owner profile"))
|
||||
return
|
||||
}
|
||||
s.hotSwapOwnerProfile(p)
|
||||
s.auditClaims(r, "owner.profile.update", "ok")
|
||||
writeJSON(w, http.StatusOK, ownerProfileFrom(p))
|
||||
}
|
||||
|
||||
// liveOwnerProfile 是 OwnerRegistrar 的**可选**扩展:组合根额外实现它时(其
|
||||
// ownerRegistrar 持有启动时加载的活 *user.OwnerProfile,也就是渠道 bridge 用来
|
||||
// 解析 actor 的同一个指针),admin 写路径就能原地热替换那份档案。
|
||||
type liveOwnerProfile interface {
|
||||
// LiveOwnerProfile 返回活的档案;启动时未能加载档案时为 nil。
|
||||
LiveOwnerProfile() *user.OwnerProfile
|
||||
}
|
||||
|
||||
// hotSwapOwnerProfile 把刚落盘的档案原地装进活档案。拿不到活档案时只记一条
|
||||
// WARN 说明「已落盘、需重启生效」,绝不假装热重载成功。
|
||||
func (s *Server) hotSwapOwnerProfile(p *user.OwnerProfile) {
|
||||
holder, ok := s.ownerRegistrar.(liveOwnerProfile)
|
||||
if !ok {
|
||||
s.log.Warn("owner profile saved; live profile not exposed by composition root, restart to apply")
|
||||
return
|
||||
}
|
||||
live := holder.LiveOwnerProfile()
|
||||
if live == nil {
|
||||
s.log.Warn("owner profile saved; no live profile was loaded at boot, restart to apply")
|
||||
return
|
||||
}
|
||||
live.Replace(p)
|
||||
s.log.Info("owner profile hot-reloaded", "accounts", len(p.Accounts))
|
||||
}
|
||||
|
||||
// personaFromRequest 从 upsert 请求构建 loader.Persona。characterRef 省略时
|
||||
// 回落到 id(它只是资源引用,有人格 id 就够);identity.systemPrompt 则
|
||||
// **不回落到任何派生串** —— 缺失由调用方显式报 400(裁决 08.4.2),这样写进
|
||||
@@ -420,6 +302,41 @@ func applyThinkingEdit(req *personaThinkingRequest, existing *loader.Persona) lo
|
||||
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...)
|
||||
|
||||
Reference in new issue
Block a user