- 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 仍写不出这五个块
138 lines
5.9 KiB
Go
138 lines
5.9 KiB
Go
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))
|
||
}
|