【capacity 唤醒式核心(77)】internal/runtime/capacity:(Decider L1启发式+L2上下文+人格覆盖→三态静息/唤醒/全能→允许工具集+capacity_awaken事件)。loop 外围允许集过滤(不硬阻断=能力≠边界红线)。config 加 Capacity(Enabled/MinConfidence/ToolSets)。wire buildCapacity 接入 loop。 【L4 记忆驱动意图(80)】recall.go:从记忆召回用户习惯(搞一下xx=办事)→唤醒L1/L2漏掉/升置信;群聊陌生人不读owner习惯(隔离红线);RecordIntent闭环(判定写回记忆越用越准);默认保守静息。 【会话消息编排(78)】gateway/session_orchestrator.go:每会话(模式/pending有界5/tail上下文尾巴20);Submit非阻塞(idle立即/busy入队ack/满拒绝);完成回调按序续接(tail+新消息合并增量注入非全量重放);Interrupt中断;REST 202/409+WS session_busy/pending_ack事件;不重造任务引擎复用loop。 【前端集成(79)】ws.ts 加 capacity_awaken/session_busy/pending_ack 解析;store 加 awakenState/sessionBusy;TopBar/ChatShell 加唤醒态徽标+忙态提示(不阻塞输入框);状态组件 status.tsx;Miuix风格。 【管理面板写CRUD(81)】20个写端点(POST/PUT/DELETE /admin/*,owner+审计落库):personas(真持久化YAML)/users apikey(签发掩码+撤销)/iot控制(真下发)/automations(建/触发/删)/llm(test真调用+临时)/channels(connect/close/身份测试actor白名单)/plugins(卸载)/memories(search真检索)/sessions(export)。无存储如实501不伪造。拆4文件满足≤500红线。前端9模块接真写。 【wire收口】buildCapacity接入loop+OnCapacity事件;server.go newOrchestrator接hub。 实机验证:iot ac.living control 200 真实改变 on:true;observability/metrics 200、health ok;全仓 build/vet/test 47包绿+前端tsc/vite。
276 lines
10 KiB
Go
276 lines
10 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"`
|
|
}
|
|
|
|
// 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 := 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 := 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 yet (the
|
|
// deployment is a single admin account + API keys), so user creation is deferred
|
|
// to M10 rather than fabricated.
|
|
func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) {
|
|
s.auditClaims(r, "user.create", "error")
|
|
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "新增用户/角色存储为 M10 提供(当前为单 admin + APIKey 现状)", 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)},
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|