fix(loop,gateway,persona): awakening 逐 turn 按角色生效 + 修「面板保存角色抹掉 5 个块」的数据丢失(裁决 14.4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-10-04 13:07:52 +08:00
1 parent 58cba0b8d2
commit 7afef8d037
11 files changed
+778 -142

No files matched your search

+45 -13
View File
@@ -27,9 +27,14 @@ import (
// (CAPACITY_L3_CACHE_TTL, default 5m) is parsed here because the config loader
// only carries scalars.
//
// The persona-level awakening override (裁决 14.4.3) is filled here from the
// current main persona's YAML block (see defaultAwakening); a persona that
// declares no block contributes the zero value, i.e. no override.
// The persona-level awakening override (裁决 14.4.3) enters the decision layer in
// two places. This function fills the *global* fallback (Config.Awakening) from the
// current main persona's YAML block (see defaultAwakening): it is what a persona
// declaring no block inherits. The per-turn value is resolved by awakeningFor,
// which the loop calls for every turn's own role through
// loop.Options.PersonaAwakening — without it, a non-default character's
// `awakening` block would be parsed, validated and then silently ignored
// (forbidden by design/01 §2.1).
func (a *app) buildCapacity() *capacity.Decider {
if !a.cfg.Capacity.Enabled {
return nil
@@ -96,21 +101,48 @@ func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) {
}
// defaultAwakening 取当前主角色(`configs/users/owner.yaml` 的 `defaultPersona`,
// 未配置时回落 `cyrene`)档案里的 `awakening` 块,映射为 capacity 的人格档位覆盖
// (design/14 §3,裁决 14.4.3)。下列情形一律返回零值 = **无覆盖**,由决策层走
// 全局默认,绝不在此处凭空补档位:
// - 尚未装配人格注册表(降级启动 / 单测构造的 app);
// - 该角色档案里没写 `awakening` 块(缺席就是没有);
// - 档位名不是规范态名(loader 已在载入期拒绝非法值;这里按无覆盖处理并
// 告警——未知态若被塞进决策层,会落进「不受限工具集」分支)。
// 未配置时回落 `cyrene`)档案里的 `awakening` 块。它填的是决策层的**全局兜底**
// (capacity.Config.Awakening);每个 turn 真正生效的档位由 awakeningFor 按该
// turn 的角色解析(裁决 14.4.3 的后半)。两者共用同一处映射,因此「全局兜底」
// 与「按角色取值」的口径绝不会分叉。
//
// 档位是倾向/呈现,不是能力边界——它只影响本轮暴露的工具面与唤醒阈值。
// 未装配人格注册表(降级启动 / 单测构造的 app)时返回零值 —— 注册表缺席时连
// 默认角色都问不出来,此时不补任何档位。
func (a *app) defaultAwakening() capacity.Awakening {
if a.personas == nil {
return capacity.Awakening{}
}
p, ok := a.defaultPersona()
if !ok || p == nil || p.Awakening == nil {
return a.awakeningFor(a.defaultPersonaID())
}
// awakeningFor 取**该角色**档案里的 `awakening` 块,映射为决策层的人格档位覆盖
// (design/14 §3,裁决 14.4.3)。它是 loop.Options.PersonaAwakening 的实现:循环
// 每个 turn 按该 turn 的角色 id 调它,因此绑定非默认角色的会话用**自己**的档位
// 判定能力面,而不是只吃默认角色的全局兜底。
//
// 下列情形一律返回零值 = **无覆盖**,由决策层走全局默认(Config.Awakening +
// 全局阈值),绝不在此处凭空补档位:
// - 尚未装配人格注册表(降级启动 / 单测构造的 app);
// - 角色 id 为空或该角色不在注册表里(未知/已删除人格);
// - 该角色档案里没写 `awakening` 块(缺席就是没有)。
//
// 档位是倾向/呈现,不是能力边界——它只影响本轮暴露的工具面与唤醒阈值。
func (a *app) awakeningFor(personaID string) capacity.Awakening {
if a.personas == nil || personaID == "" {
return capacity.Awakening{}
}
p, ok := a.personas.Persona(personaID)
if !ok || p == nil {
return capacity.Awakening{}
}
return a.awakeningOf(p)
}
// awakeningOf 把一份人格档案的 `awakening` 块映射为决策层的档位覆盖。映射不了
// 的档位名按「无该档位」处理(awakeningStateOf 会告警),因此非法值绝不会被塞进
// 决策层(未知态会落进「不受限工具集」分支)。
func (a *app) awakeningOf(p *loader.Persona) capacity.Awakening {
if p == nil || p.Awakening == nil {
return capacity.Awakening{}
}
aw := capacity.Awakening{
+245
View File
@@ -0,0 +1,245 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"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/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件端到端锁定裁决 14.4.3 的后半:**每个 turn 用该 turn 所属角色的唤醒档位**。
// 只用默认角色的档位填 Config.Awakening(全局兜底)是不够的 —— 绑定非默认角色的
// 会话会「档位被解析、被校验、却静默不生效」(design/01 §2.1 禁止静默无效配置)。
// 因此这里的断言落在**模型实际看到的工具面**上:链路是 persona YAML → loader →
// 注册表 → app.awakeningFor → loop.Options.PersonaAwakening → capacity.Input.Awakening
// → Decide → 广告给模型的 schema。
// 加在两个角色身上的工具名取自 capacity 的内建档位集:terminal 在静息态就可见,
// web-search 只在唤醒/全能态出现,因此「被广告的工具集」直接暴露判定结果。
const (
quietToolName = "terminal"
awakeToolName = "web-search"
)
// roleProbeTool 是只为广告工具名存在的 sdk.Tool 替身:本文件断言的是
// 「哪些工具被广告给模型」,工具本身从不执行。
type roleProbeTool string
func (t roleProbeTool) Name() string { return string(t) }
func (t roleProbeTool) Description() string { return "role probe tool " + string(t) }
func (t roleProbeTool) Parameters() (map[string]any, error) {
return nil, nil
}
func (t roleProbeTool) Execute(context.Context, any) (any, error) { return nil, nil }
// roleProbeAdapter 每次都记下流式请求,使测试能读回该 turn 广告给模型的工具面。
type roleProbeAdapter struct{ streamReqs []model.Request }
func (a *roleProbeAdapter) Name() string { return "role-probe" }
func (a *roleProbeAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
return model.Response{Content: "好的"}, nil
}
func (a *roleProbeAdapter) ChatStream(_ context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
a.streamReqs = append(a.streamReqs, req)
if on != nil {
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "好的"})
}
return model.Response{Content: "好的"}, nil
}
// twoRoleApp 注册两个角色,两者都来自真实 YAML(走与生产相同的载入期解析与校验):
// `cyrene` 是默认角色且档案里**没有** `awakening` 块,`tasky` 带调用方给定的块。
// 默认角色不声明档位,正是「不声明者继承全局兜底(零覆盖)」的现场。
func twoRoleApp(t *testing.T, taskyAwakening string) *app {
t.Helper()
dir := t.TempDir()
reg := personareg.New()
for _, spec := range []struct {
name string
body string
isDefault bool
}{
{"cyrene", awakeningPersonaBody, true},
{"tasky", awakeningPersonaBody + taskyAwakening, false},
} {
path := filepath.Join(dir, spec.name+".yaml")
if err := os.WriteFile(path, []byte("id: "+spec.name+"\n"+spec.body), 0o600); err != nil {
t.Fatalf("write %s yaml: %v", spec.name, err)
}
p, err := loader.Load(path)
if err != nil {
t.Fatalf("loader.Load(%s): %v", spec.name, err)
}
if err := reg.RegisterPersona(p); err != nil {
t.Fatalf("RegisterPersona(%s): %v", spec.name, err)
}
if err := reg.RegisterCharacter(&personareg.Character{
ID: p.ID, Name: p.Name, Default: spec.isDefault, PersonaID: p.ID,
}); err != nil {
t.Fatalf("RegisterCharacter(%s): %v", spec.name, err)
}
}
a := &app{log: testLog(), personas: reg}
a.cfg = &config.Config{}
a.cfg.Capacity.Enabled = true
return a
}
// roleProbeLoop 按生产接线的形状组装带 capacity 的循环:逐 turn 的档位经
// `resolve` 注入(生产传的正是 `a.awakeningFor`),`resolve` 为 nil 时是
// 「接线前」的对照循环。
func roleProbeLoop(t *testing.T, dec *capacity.Decider, ad model.Adapter, resolve func(string) capacity.Awakening) *loop.Loop {
t.Helper()
caps := registry.New()
d, err := tool.New(caps)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
for _, name := range []string{quietToolName, awakeToolName} {
if err := d.Register(roleProbeTool(name)); err != nil {
t.Fatalf("register %s: %v", name, err)
}
}
pool := make(map[string]*agent.Agent, 2)
for _, id := range []string{"cyrene", "tasky"} {
ag, err := agent.New("你是"+id+"。", d, nil, agent.WithModel(ad, nil))
if err != nil {
t.Fatalf("agent.New(%s): %v", id, err)
}
pool[id] = ag
}
return loop.New(loop.Options{
Agent: pool["cyrene"],
Agents: pool,
Tools: d,
Capacity: dec,
PersonaID: "cyrene",
PersonaAwakening: resolve,
OnStream: func(model.StreamEvent) {},
})
}
// roleTurnTools 以 personaID 的会话身份驱动一个 turn,并返回模型实际看到的工具名。
// 多个 texts 按时间顺序入列,最后一个即本轮的用户消息(与判定器看到的窗口一致)。
func roleTurnTools(t *testing.T, l *loop.Loop, ad *roleProbeAdapter, personaID string, texts ...string) map[string]bool {
t.Helper()
msgs := make([]model.Message, 0, len(texts))
for _, txt := range texts {
msgs = append(msgs, model.TextMessage(model.RoleUser, txt))
}
env := loop.SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true, PersonaID: personaID}
ctx := loop.WithSessionEnv(context.Background(), env)
if _, err := l.Run(ctx, "web:"+personaID, msgs); err != nil {
t.Fatalf("Run(%s): %v", personaID, err)
}
if len(ad.streamReqs) == 0 {
t.Fatalf("no streaming request captured for %s", personaID)
}
req := ad.streamReqs[len(ad.streamReqs)-1]
out := make(map[string]bool, len(req.Tools))
for _, s := range req.Tools {
out[s.Name] = true
}
return out
}
// TestCapacityAwakening_PerRoleAppliesToItsOwnTurns 是裁决 14.4.3 后半的端到端断言:
// 同一个**意图各层都拿不准**的输入("这个")下,声明了档位的角色与没声明的角色
// 得到不同的工具面,而没声明的角色与「完全没接线」的对照循环逐字一致。
func TestCapacityAwakening_PerRoleAppliesToItsOwnTurns(t *testing.T) {
t.Parallel()
a := twoRoleApp(t, "awakening:\n overrides:\n force_state: 唤醒\n")
// 档位确实按角色取出:非默认角色读自己的块,默认角色缺席即没有。
if aw := a.awakeningFor("tasky"); aw.ForceState != capacity.StateAwake {
t.Fatalf("awakeningFor(tasky) = %+v, want force_state awake(取自己的档案)", aw)
}
if aw := a.awakeningFor("cyrene"); !aw.IsZero() {
t.Fatalf("awakeningFor(cyrene) = %+v, want zero(档案里没写该块)", aw)
}
dec := a.buildCapacity()
const ambiguous = "这个"
wired := &roleProbeAdapter{}
withResolver := roleProbeLoop(t, dec, wired, a.awakeningFor)
baseline := &roleProbeAdapter{}
// 对照循环就是接线前的形状:判定器照旧,只是没有人按 turn 传档位。
before := roleProbeLoop(t, dec, baseline, nil)
declaring := roleTurnTools(t, withResolver, wired, "tasky", ambiguous)
undeclaring := roleTurnTools(t, withResolver, wired, "cyrene", ambiguous)
// 对照循环跑**同一个**角色与同一个输入。
undeclaringBefore := roleTurnTools(t, before, baseline, "cyrene", ambiguous)
if !declaring[awakeToolName] || !declaring[quietToolName] {
t.Fatalf("声明档位的角色工具面 = %v, want terminal+web-search(force_state 唤醒)", declaring)
}
if undeclaring[awakeToolName] {
t.Fatalf("未声明档位的角色工具面 = %v, want 静息(web-search 不得被广告)", undeclaring)
}
if len(declaring) == len(undeclaring) {
t.Fatalf("同一模糊输入下两个角色的工具面相同(%v)—— 档位没有按 turn 生效", declaring)
}
if !sameToolSet(undeclaring, undeclaringBefore) {
t.Fatalf("未声明档位者接线上后行为变了:%v vs 接线前 %v", undeclaring, undeclaringBefore)
}
}
// TestCapacityAwakening_ThresholdRoleDiffersFromGlobal 同一落点的第二个判据:档位
// 也可以只抬高阈值(min_confidence)。默认角色(零覆盖)在话题延续的模糊回合会因
// L2 趋势猜到「办事」而唤醒,非默认角色把自己的阈值抬到 0.95 后同一回合回到静息。
func TestCapacityAwakening_ThresholdRoleDiffersFromGlobal(t *testing.T) {
t.Parallel()
a := twoRoleApp(t, "awakening:\n overrides:\n min_confidence: 0.95\n")
dec := a.buildCapacity()
// L2 趋势(置信 0.66 量级):默认角色零覆盖 → 唤醒;抬高阈值者 → 静息。
trend := []string{"帮我查一下资料", "继续"}
wired := &roleProbeAdapter{}
withResolver := roleProbeLoop(t, dec, wired, a.awakeningFor)
// 对照:完全没接线的循环在同一序列上唤醒(证明差异来自该角色的档位)。
baseline := &roleProbeAdapter{}
before := roleProbeLoop(t, dec, baseline, nil)
loose := roleTurnTools(t, before, baseline, "cyrene", trend...)
strict := roleTurnTools(t, withResolver, wired, "tasky", trend...)
if !loose[awakeToolName] {
t.Fatalf("对照循环工具面 = %v, want 唤醒(web-search 被广告)", loose)
}
if strict[awakeToolName] {
t.Fatalf("抬高阈值的角色工具面 = %v, want 静息(0.95 阈值压回)", strict)
}
}
// sameToolSet 比较两个工具名集合是否逐项相同(忽略注册顺序)。
func sameToolSet(a, b map[string]bool) bool {
if len(a) != len(b) {
return false
}
for name := range a {
if !b[name] {
return false
}
}
return true
}
+1
View File
@@ -251,6 +251,7 @@ func (a *app) wireRuntime() error {
PersonaID: personaID,
PersonaName: a.personaName,
PersonaKnobs: a.personaKnobs,
PersonaAwakening: a.awakeningFor,
Timezone: a.cfg.Timezone,
MaxRounds: defaultMaxRounds,
WorkingMemory: memory.NewWorkingMemory(0),
@@ -0,0 +1,85 @@
package loop
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定裁决 14.4.3 后半在**循环**这一层的契约:唤醒档位按该 turn 的角色
// 解析(Options.PersonaAwakening),而不是绑定在 Loop 构造期;未注入解析器或
// 角色未知时一律零覆盖、回落到判定器的全局兜底 —— 即接线前的行为。
// lastStreamTools 返回最新一次流式请求广告给模型的工具名集合。
func lastStreamTools(t *testing.T, ad *mockAdapter) map[string]bool {
t.Helper()
if len(ad.streamReqs) == 0 {
t.Fatal("adapter recorded no streaming request")
}
return toolSet(ad.streamReqs[len(ad.streamReqs)-1].Tools)
}
// TestLoop_CapacityAwakeningFollowsTheTurnRole 断言同一句「意图各层都拿不准」的
// 输入下("这个"),声明了档位的角色唤醒、没声明的角色静息,而**未知角色**与
// **未注入解析器**两种退化都保持判定器的既有行为(静息,且不广告 web-search)。
func TestLoop_CapacityAwakeningFollowsTheTurnRole(t *testing.T) {
t.Parallel()
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "陪你"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
awakened := map[string]capacity.Awakening{"Y": {ForceState: capacity.StateAwake}}
newLoop := func(resolve func(string) capacity.Awakening) *Loop {
return New(Options{
Agent: a,
PersonaID: "X",
Capacity: dec,
OnStream: func(model.StreamEvent) {},
PersonaAwakening: resolve,
})
}
const ambiguous = "这个"
resolved := newLoop(func(id string) capacity.Awakening { return awakened[id] })
runPersonaTurn(t, resolved, roleEnv("X"), "web:x1", ambiguous)
if got := lastStreamTools(t, ad); got["web-search"] {
t.Fatalf("无档位的角色 X 的工具面 = %v, want 静息(不得广告 web-search)", got)
}
runPersonaTurn(t, resolved, roleEnv("Y"), "web:y1", ambiguous)
if got := lastStreamTools(t, ad); !got["web-search"] {
t.Fatalf("声明 force_state=awake 的角色 Y 的工具面 = %v, want 唤醒", got)
}
// 未知角色(解析器不认识它)不得让该 turn 失败,也不得泄漏别人的档位。
runPersonaTurn(t, resolved, roleEnv("nobody"), "web:z1", ambiguous)
if got := lastStreamTools(t, ad); got["web-search"] {
t.Fatalf("未知角色的工具面 = %v, want 静息(按无覆盖处理)", got)
}
// 未注入解析器 = 接线前的形状:所有角色都走全局兜底。
before := newLoop(nil)
runPersonaTurn(t, before, roleEnv("Y"), "web:y2", ambiguous)
if got := lastStreamTools(t, ad); got["web-search"] {
t.Fatalf("未注入解析器时角色 Y 的工具面 = %v, want 与接线前一致(静息)", got)
}
}
// TestLoop_AwakeningForDegradesQuietly 覆盖解析器的两个降级口:未注入解析器、
// 以及该 turn 没有角色(SessionEnv.PersonaID 与 Options.PersonaID 都为空)时,
// awakeningFor 一律返回零覆盖而不是 panic 或猜一个角色。
func TestLoop_AwakeningForDegradesQuietly(t *testing.T) {
t.Parallel()
plain := WithSessionEnv(context.Background(), OwnerEnv())
if got := New(Options{}).awakeningFor(plain); !got.IsZero() {
t.Fatalf("未注入解析器的循环返回了 %+v, want 零覆盖", got)
}
l := New(Options{PersonaAwakening: func(string) capacity.Awakening {
return capacity.Awakening{ForceState: capacity.StateAwake}
}})
if got := l.awakeningFor(plain); !got.IsZero() {
t.Fatalf("没有角色的 turn 返回了 %+v, want 零覆盖(不猜角色)", got)
}
}
+2
View File
@@ -101,6 +101,7 @@ type Loop struct {
personaID string
personaName func(personaID string) string
personaKnobs func(personaID string) (LoopKnobs, bool)
personaAwakening func(personaID string) capacity.Awakening
timeLocation *time.Location
timeZoneName string
maxRounds int
@@ -160,6 +161,7 @@ func New(opts Options) *Loop {
personaID: opts.PersonaID,
personaName: opts.PersonaName,
personaKnobs: opts.PersonaKnobs,
personaAwakening: opts.PersonaAwakening,
timeLocation: timeLocation,
timeZoneName: timeZoneName,
maxRounds: maxRounds,
+10
View File
@@ -109,6 +109,16 @@ type Options struct {
// 输入落到正确角色旋钮的原因。为 nil 时每个 turn 都保持配置的
// 默认值,即多角色之前的行为。
PersonaKnobs func(personaID string) (LoopKnobs, bool)
// PersonaAwakening 非 nil 时,为该 turn 的角色解析唤醒档位
// (design/14 §3,裁决 14.4.3),使绑定到角色 Y 的会话按 Y 档案里的
// `awakening` 块判定能力面 —— 而不是只吃默认角色的全局兜底
// (Capacity 的 Config.Awakening)。没有它,别的角色写了档位也会
// 「被解析、被校验、却静默不生效」(design/01 §2.1 禁止静默无效的配置)。
// 返回零值 = 该角色无覆盖,回落到 Config.Awakening 与全局阈值(档案里
// 没写该块就是没有)。与 PersonaKnobs 同样经角色 id 解析,因此所有路径
// 都由同一个输入落到正确角色的档位。为 nil 时每个 turn 都不传逐调用覆盖,
// 即接线前的行为。
PersonaAwakening func(personaID string) capacity.Awakening
// Timezone 是用于渲染每轮「当前时间」系统消息的 IANA
// 时区(如 "Asia/Shanghai")。为空则完全关闭时间注入
// (向后兼容);加载失败的名字同样关闭它。
+29 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
@@ -13,11 +14,11 @@ import (
// 记忆命名空间、情绪机与推理旋钮 —— 改为从该 turn 的
// SessionEnv 解析。
//
// 解析刻意做成 ctx 上的三个小函数,而不是 Loop 上的
// 解析刻意做成 ctx 上的几个小函数,而不是 Loop 上的
// 字段:Loop 由所有并发会话共享,把解析出的角色存在
// 它上面会产生数据竞争,而 ctx 本就携带按 turn 的 env。
// 所有角色作用域读取都必须走这些辅助函数,使提示词、记忆、
// 情绪与思考永远不会对「谁在回答」产生分歧。
// 情绪、思考与唤醒档位永远不会对「谁在回答」产生分歧。
// LoopKnobs 是注入循环构建的每个请求的按角色模型/推理
// 旋钮。它镜像三个构造期选项
@@ -36,8 +37,8 @@ type LoopKnobs struct {
// personaFor 返回当前 turn 发言所作为的角色(design/22 §2):调用方
// 解析出角色时取会话环境的显式角色,否则取循环
// 配置的角色(Options.PersonaID,全局默认)。提示词/记忆
// 与情绪钩子都读它,因此一个 turn 绝不会混用两个角色。
// 配置的角色(Options.PersonaID,全局默认)。提示词/记忆、
// 情绪钩子与唤醒档位都读它,因此一个 turn 绝不会混用两个角色。
func (l *Loop) personaFor(ctx context.Context) string {
if pid := sessionEnvFrom(ctx).PersonaID; pid != "" {
return pid
@@ -84,3 +85,27 @@ func (l *Loop) knobsFor(ctx context.Context) LoopKnobs {
}
return knobs
}
// awakeningFor 解析当前 turn 的唤醒档位(design/14 §3,裁决 14.4.3):该 turn
// 所属角色在档案里声明的 `awakening` 块,映射成决策层的人格覆盖。档位随 turn
// 解析而不是在 Loop 构造期绑定,理由与提示词/记忆/旋钮相同 —— 一个进程同时
// 承载多个角色,而 Loop 由所有并发会话共享(存在 Loop 上会产生数据竞争)。
// 解析器由组合根注入(Options.PersonaAwakening,按角色 id 查那份 YAML),
// 与 knobsFor 一样按角色 id 解析,因此所有路径(网关、channelbridge、
// backthink、子代理)都从同一个输入落到正确角色的档位。
//
// 下列情形一律返回零值 = **无覆盖**,由判定器回落到 Config.Awakening(全局
// 兜底)与全局阈值,绝不在此处凭空补档位:
// - 未注入解析器(单角色接线、或未接线的调用方)——逐字节等于接线前的行为;
// - 该 turn 没有角色(SessionEnv.PersonaID 与 Options.PersonaID 都为空);
// - 该角色档案里没写 `awakening` 块(缺席就是没有)。
func (l *Loop) awakeningFor(ctx context.Context) capacity.Awakening {
if l.personaAwakening == nil {
return capacity.Awakening{}
}
pid := l.personaFor(ctx)
if pid == "" {
return capacity.Awakening{}
}
return l.personaAwakening(pid)
}
+4
View File
@@ -98,6 +98,10 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
dec := l.capacity.Decide(ctx, capacity.Input{
Messages: capacityTexts(messages),
CurrentState: acc.state,
// 该 turn 所属角色的档位(裁决 14.4.3 的后半):没有它,绑定非默认
// 角色的会话只能吃到默认角色的全局兜底,别的角色写在档案里的
// `awakening` 会被解析、被校验、却静默不生效。
Awakening: l.awakeningFor(ctx),
AllToolNames: names,
Recall: l.capacityRecall(ctx, messages),
})
@@ -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...)