Files
Cyrene_v2/internal/services/gateway/admin_write_personas_users.go
T
AskaEth 2aa3233f84
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(persona,web): 人格读侧(thinking 三态 + 角色级 mood)+ 面板表单 + mood 播种
按 design/04 §3.2(thinking 语义,showToUser 三态)+ design/01 §2.1 + 主人两条定论(心情跟角色不跟会话;mood 初值走口径 ①:由角色读端点暴露、前端加载播种)。
- 管理侧 GET /admin/personas 的 PersonaSummary 增 thinking(budgetTokens/effort/mode/showToUser)——**showToUser 保持三态**(未配置 ≠ false)
- 用户侧 GET /personas 增**角色级 mood**(取自 emotion 层该角色当前状态,非从消息推断)→ 前端加载播种 currentMood,消除「刷新后心情徽标短暂为空」
- 前端 Personas 页加 thinking 四控件(预算/effort/模式/showToUser 三态):只做输入校验,**provider 是否真支持由后端 WARN 决定,前端不假装能力**
- 先拆 useAppStore 选择器(原 494/500)到独立模块再做,避免越线
- Docs/api-client 版本号 +1(两处契约 + 三态与角色级 mood 语义)
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + 前端 build 绿 + personaSession/messageSync 逻辑测试通过
2026-10-03 20:40:44 +08:00

352 lines
14 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"
)
// persona-to-identity and API-key admin writes (owner-only, audited). Persona
// writes register into the persona registry (real runtime effect) and persist a
// YAML file so they survive a restart; API keys are minted/revoked in the
// auth.APIKey store.
const personaDir = "configs/personas"
// personaUpsertRequest is the POST/PUT /api/v1/admin/personas body.
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 is the optional reasoning/presentation block (design/01 §2.1). It
// is persisted into the persona YAML as-is; omitting it on an update keeps the
// stored block (see adminUpdatePersona).
Thinking *personaThinkingRequest `json:"thinking,omitempty"`
}
// personaThinkingRequest is the wire form of the persona `thinking` section. The
// pointers/naming mirror the YAML so a panel can round-trip it verbatim.
type personaThinkingRequest struct {
BudgetTokens int `json:"budgetTokens,omitempty"`
Effort string `json:"effort,omitempty"`
ShowToUser *bool `json:"showToUser,omitempty"`
Mode string `json:"mode,omitempty"`
}
// adminCreatePersona registers a new persona in the registry (real runtime
// effect) and persists it to the persona YAML library so it survives a restart.
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
}
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 edits an existing persona, persisting the YAML and
// replacing the in-memory persona/character.
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
}
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
}
// A client that does not know the optional `thinking` block (an older panel, a
// script) omits it; keep the stored one instead of silently wiping a
// hand-edited config. Sending the object replaces it, and sending it with all
// fields default clears it.
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 answers honestly: there is no persistent user table and no
// user-authenticated login to back one (the deployment is a single admin
// account from .env plus runtime API keys; design/11 is single-user first), so
// user creation would be a row nobody could log in with. It reports the missing
// store instead of fabricating a user.
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,
})
}
// apiKeyIssueRequest is the POST /api/v1/admin/users/apikey body.
type apiKeyIssueRequest struct {
Role string `json:"role"`
}
// adminIssueApiKey mints a real API key bound to a role and returns the plaintext
// once (the only time it is visible). The id in the response is the masked form
// the admin list shows, so it can be used to revoke the key later.
func (s *Server) adminIssueApiKey(w http.ResponseWriter, r *http.Request) {
var req apiKeyIssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
role, err := auth.ParseRole(req.Role)
if err != nil {
s.auditClaims(r, "apikey.issue", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid role"))
return
}
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(role)
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(role)})
}
// adminRevokeApiKey revokes a key by its masked id (the identifier the admin list
// returns). It reports not-found when no key carries that mask.
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 builds a loader.Persona from the upsert request, filling a
// derived system prompt/characterRef when omitted so a minimal create validates.
func personaFromRequest(req personaUpsertRequest) *loader.Persona {
systemPrompt := strings.TrimSpace(req.SystemPrompt)
if systemPrompt == "" {
systemPrompt = fmt.Sprintf("你是 %s,按角色人设 %q 与主人交流。", req.Name, req.Role)
}
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: 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 maps the wire `thinking` block onto the persona field; a
// missing block yields the zero value, which yaml.Marshal omits entirely.
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 returns the stored persona for id, or nil when it is absent.
func existingPersona(reg *personareg.Registry, id string) *loader.Persona {
if p, ok := reg.Persona(id); ok {
return p
}
return nil
}
// applyThinkingEdit returns the `thinking` block an update should persist: the
// request's own block when it carries one, otherwise the stored persona's block —
// so a client that does not know the optional field can never wipe it. An update
// that sends the object replaces the block outright (all-default fields clear it).
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 builds the registry.Character bound to the persona.
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 maps a persona+character to its wire summary. It is the same
// shape the admin list returns — the role-level mood plus the `thinking` block —
// so a write response and a subsequent read describe the persona identically
// (a client can trust either for backfill).
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 persists p to the persona library as <id>.yaml so it survives
// a restart (wire.go loads this directory). The id is sanitized so user input
// cannot traverse out of the library directory.
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 keeps only URL/path-safe characters so a persona id cannot escape
// the persona directory.
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()
}