按 DevDocs/design/01 §2.1/§2.2 与 design/04 §3.2 实现(规格含联网核过的官方依据:思考内容刻意不角色化、不写分步思考指令、思考控制只走参数级)。
- 人格 schema 新增 thinking:budgetTokens / effort / showToUser(*bool,省略=nil=保持现行为,显式 false=只落库) / mode(default|on|off→Enable 三态);非法 mode/负预算 → ErrInvalidThinking(不静默)
- 5 个死字段接上注入:personality→values→speechStyle→catchphrase→background 拼为独立段落(带【】小标题,空值不拼);**全空时输出与旧实现逐字节一致**(有专门测试)
- model.Request 新增 Thinking{ BudgetTokens, Enable } 与 ReasoningEffort;新增 model/reasoning.go:厂商识别(模型名→baseURL 兜底)、取值白名单(OpenAI 系 none…max / DeepSeek low|high|max)、非法值降级+WARN、**不支持一律 WARN 而非静默**;Anthropic 映射 thinking{budget_tokens} 并**保证 budget<max_tokens**(不足时抬高 max_tokens+WARN,正是历史上思考吃光预算→空回复那个坑)
- loop:新增 Options.ThinkingShowToUser(*bool) 仅门控 thinking_* 帧(正文/工具帧不受影响);场景级 mute 语义不变
- 补一处任务清单外但必需的接线:persona→model.Request 的 thinking 映射(否则 budget/effort/mode 会变成新的死字段);showToUser 刻意不产生 Thinking(纯呈现开关)
- 管理端:personaUpsertRequest 补 thinking DTO + 校验(400);PUT 省略时**保留已存块**(防旧客户端抹配置);写侧 yaml round-trip 已验证
- Docs/api-client v0.40→v0.41(可选请求字段)
- 测试:26 个新用例(loader 解析/拼接逐字节/映射、anthropic 非流式+流式预算约束、openai 透传/降级/丢弃、Qwen 诚实 WARN、loop 门控、DTO round-trip);3 次变异验证(省略与 false 混同 / 去掉预算约束 / 短路门控 各自 FAIL)
- 验收:go build/vet/test 全绿(含 57 包)+ linux/darwin 交叉编译 + 前端 build;唯一 FAIL 是既有 flake TestInterruptCancelsTurnAndClearsPending(3/20 新代码 vs 3/20 纯 HEAD,已归因非本次引入)
347 lines
13 KiB
Go
347 lines
13 KiB
Go
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, 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, 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.
|
||
func 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(),
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
}
|