- 15.4.2:管理 API 与 Bearer 共用同一 Authorize 路径,X-API-Key 命中签发表即过门;未知/已吊销/空值 401 且不回落 Bearer(fail-closed) - 15.4.3:key 表去掉角色维度,任一有效 key 鉴权后即 owner;签发接口取消 role 分级(不再读请求体) - 10.4.5:新增 GET/PUT /api/v1/admin/owner-profile(owner-only,PUT 带审计 owner.profile.update),0600 原子落盘 + 活档案热替换;前端主人档案改接真实 API,读不到即禁用保存,删掉硬编码默认档案 - 08.4.2:persona 的 identity.systemPrompt 必填,缺失/空白 400 显式报错,不再派生回落
481 lines
20 KiB
Go
481 lines
20 KiB
Go
package gateway
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"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"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||
)
|
||
|
||
// 人格到身份与 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
|
||
}
|
||
// 不认识可选 `thinking` 块的客户端(旧面板、脚本)会省略它;
|
||
// 应保留已存的那个,而不是静默清掉手工编辑的
|
||
// 配置。发送该对象则替换它,全字段为默认值
|
||
// 时清除它。
|
||
p.Thinking = applyThinkingEdit(req.Thinking, existingPersona(reg, id))
|
||
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})
|
||
}
|
||
|
||
// --- 主人档案(裁决 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),这样写进
|
||
// 磁盘的人格必然带着真实身份,也必然能被 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{}
|
||
}
|
||
|
||
// 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()
|
||
}
|