Files
Cyrene_v2/internal/services/gateway/admin.go
T
Aska 6dbb4a2e18
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: 唤醒式架构批次——capacity/L4/会话编排/管理面板写CRUD/前端集成(77-81)
【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。
2026-09-06 21:54:40 +08:00

480 lines
16 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 (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// backthinkListLimit is the number of most-recent think records the admin
// panel asks for when no limit is specified.
const backthinkListLimit = 50
// PluginSummary is the wire shape of a capability/plugin in the admin list.
type PluginSummary struct {
Name string `json:"name"`
Kind string `json:"kind"`
Description string `json:"description"`
AllowInGroup bool `json:"allowInGroup"`
AllowInStranger bool `json:"allowInStranger"`
}
// ChannelSummary is the wire shape of a registered channel adapter.
type ChannelSummary struct {
Name string `json:"name"`
Connected bool `json:"connected"`
}
// policyRequest is the POST /api/v1/admin/plugins/policy body.
type policyRequest struct {
Name string `json:"name"`
AllowInGroup bool `json:"allowInGroup"`
AllowInStranger bool `json:"allowInStranger"`
}
// AdminLlmProvider is the wire shape of one LLM provider in the admin config.
// The API key is deliberately absent: it is never returned to the client.
type AdminLlmProvider struct {
ID string `json:"id"`
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
Model string `json:"model"`
Multimodal bool `json:"multimodal"`
Enabled bool `json:"enabled"`
Fallback string `json:"fallback,omitempty"`
}
// AdminLlmConfig is the wire shape of GET /api/v1/admin/llm.
type AdminLlmConfig struct {
Providers []AdminLlmProvider `json:"providers"`
}
// AdminAutomationRecord is the wire shape of one automation/reminder in the
// admin list. Cron carries the cron expression for the cron kind; lastRun and
// nextRun are omitted when the value is unknown.
type AdminAutomationRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Cron string `json:"cron,omitempty"`
Enabled bool `json:"enabled"`
LastRun string `json:"lastRun,omitempty"`
NextRun string `json:"nextRun,omitempty"`
}
// AdminUserRecord is the wire shape of one account/API key in the admin list.
type AdminUserRecord struct {
ID string `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
CreatedAt string `json:"createdAt,omitempty"`
}
// AdminBackthinkItem is the wire shape of one background-think record in the
// admin monitor list.
type AdminBackthinkItem struct {
ID string `json:"id"`
Trigger string `json:"trigger"`
Conclusion string `json:"conclusion,omitempty"`
ShouldNotify bool `json:"shouldNotify"`
Pushed bool `json:"pushed"`
At int64 `json:"at"`
}
// adminListPersonas lists persona summaries (admin read).
func (s *Server) adminListPersonas(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.collectPersonas())
}
// adminListSessions lists channel sessions (admin read).
func (s *Server) adminListSessions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.collectSessions(r.Context()))
}
// adminListMemories lists memory entries (admin read).
func (s *Server) adminListMemories(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.collectMemories(r.Context()))
}
// adminListPlugins lists every registered capability (admin read).
func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
out := make([]PluginSummary, 0)
if s.caps != nil {
for _, cap := range s.caps.List("") {
out = append(out, PluginSummary{
Name: cap.Name,
Kind: string(cap.Kind),
Description: cap.Description,
AllowInGroup: cap.Policy.AllowInGroup,
AllowInStranger: cap.Policy.AllowInStranger,
})
}
}
writeJSON(w, http.StatusOK, out)
}
// adminListChannels lists the registered channel adapters (admin read).
func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
out := make([]ChannelSummary, 0)
if s.caps != nil {
for _, cap := range s.caps.List(capreg.KindChannel) {
ch, ok := cap.Value.(sdk.Channel)
if !ok {
continue
}
out = append(out, ChannelSummary{Name: ch.Name(), Connected: ch.IsConnected()})
}
}
writeJSON(w, http.StatusOK, out)
}
// adminListIoT lists IoT devices (admin read).
func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
out := make([]iotDeviceSummary, 0)
if s.iot != nil {
devices, err := s.iot.List(r.Context())
if err != nil {
s.log.Error("list iot", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "iot_failed", "failed to list devices"))
return
}
for _, d := range devices {
out = append(out, iotDeviceSummary{ID: d.ID, Name: d.Name, Type: d.Type, State: d.State, Online: d.Online})
}
}
writeJSON(w, http.StatusOK, out)
}
// adminGetLlm returns the LLM provider/model configuration read from the live
// config. API keys are never returned (the wire shape carries no key field), so
// the handler leaks nothing sensitive.
func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) {
cfg := s.cfg.LLM
providers := []AdminLlmProvider{
{
ID: "openai",
Name: "OpenAI",
BaseURL: cfg.BaseURL,
Model: cfg.Model,
Multimodal: cfg.VisionModel != "",
Enabled: cfg.Provider != "anthropic",
Fallback: cfg.Fallback,
},
{
ID: "anthropic",
Name: "Anthropic",
BaseURL: cfg.AnthropicBaseURL,
Model: cfg.AnthropicModel,
Multimodal: false,
Enabled: cfg.Provider == "anthropic",
Fallback: cfg.AnthropicFallback,
},
}
// Fold in providers added at runtime via the admin "add provider" write.
// They are process-lifetime and not bound to the model router, so the panel
// reflects them but the response marks nothing else.
s.llmMu.RLock()
providers = append(providers, s.llmExtra...)
s.llmMu.RUnlock()
writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: providers})
}
// adminListAutomations lists automations and reminders (admin read).
func (s *Server) adminListAutomations(w http.ResponseWriter, r *http.Request) {
out := make([]AdminAutomationRecord, 0)
if s.automations != nil {
for _, a := range s.automations.List() {
rec := AdminAutomationRecord{
ID: a.ID,
Name: a.Name,
Kind: a.Kind,
Cron: a.Cron,
Enabled: a.Enabled,
}
if !a.LastRun.IsZero() {
rec.LastRun = a.LastRun.Format(time.RFC3339)
}
if !a.NextRun.IsZero() {
rec.NextRun = a.NextRun.Format(time.RFC3339)
}
out = append(out, rec)
}
}
writeJSON(w, http.StatusOK, out)
}
// adminListUsers lists accounts and API keys (admin read). Without a wired
// UserStore it reports the single configured admin account rather than
// fabricating a column of virtual users.
func (s *Server) adminListUsers(w http.ResponseWriter, r *http.Request) {
out := []AdminUserRecord{{
ID: "admin",
Username: s.cfg.Auth.AdminUser,
Role: string(auth.RoleOwner),
}}
if s.users != nil {
for _, u := range s.users.List() {
rec := AdminUserRecord{ID: u.ID, Username: u.Username, Role: string(u.Role)}
if u.CreatedAt != nil {
rec.CreatedAt = u.CreatedAt.Format(time.RFC3339)
}
out = append(out, rec)
}
}
writeJSON(w, http.StatusOK, out)
}
// adminListBackthink lists the most recent background-think records (admin read).
func (s *Server) adminListBackthink(w http.ResponseWriter, r *http.Request) {
out := make([]AdminBackthinkItem, 0)
if s.backthink == nil {
writeJSON(w, http.StatusOK, out)
return
}
records, err := s.backthink.List(r.Context(), backthinkListLimit)
if err != nil {
s.log.Error("list backthink", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "backthink_failed", "failed to list backthink records"))
return
}
for _, rec := range records {
out = append(out, AdminBackthinkItem{
ID: strconv.FormatInt(rec.ID, 10),
Trigger: rec.Trigger,
Conclusion: rec.Conclusion,
ShouldNotify: rec.ShouldPush,
Pushed: rec.PushResult.Pushed,
At: rec.At.UnixMilli(),
})
}
writeJSON(w, http.StatusOK, out)
}
// AdminRagDoc is the wire shape of one knowledge-base document (project-index
// entry) in the admin RAG list.
type AdminRagDoc struct {
ID string `json:"id"`
Name string `json:"name"`
Desc string `json:"desc,omitempty"`
Chunks int `json:"chunks"`
Embedded bool `json:"embedded"`
}
// AdminRagHit is the wire shape of one RAG retrieval hit.
type AdminRagHit struct {
DocID string `json:"docId"`
DocName string `json:"docName"`
Chapter string `json:"chapter,omitempty"`
Text string `json:"text"`
Score float64 `json:"score"`
Semantic bool `json:"semantic"`
}
// AdminRagResponse is the wire shape of GET /api/v1/admin/rag. Hits is present
// only when a query parameter was supplied, so callers distinguish "no search
// requested" from "searched and found nothing".
type AdminRagResponse struct {
Documents []AdminRagDoc `json:"documents"`
Hits []AdminRagHit `json:"hits,omitempty"`
}
// ragQueryRequest is the POST /api/v1/admin/rag/query body.
type ragQueryRequest struct {
Query string `json:"query"`
}
// ragDeleteRequest is the POST /api/v1/admin/rag/delete body.
type ragDeleteRequest struct {
ID string `json:"id"`
}
// adminListRag lists the knowledge-base documents and, when ?query= is present,
// returns the matching hits too. Without a wired RagStore it degrades to an empty
// list (backward compatible — never a 500).
func (s *Server) adminListRag(w http.ResponseWriter, r *http.Request) {
out := AdminRagResponse{Documents: make([]AdminRagDoc, 0)}
if s.rag == nil {
writeJSON(w, http.StatusOK, out)
return
}
docs, err := s.rag.ListDocuments(r.Context())
if err != nil {
s.log.Error("list rag", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_failed", "failed to list knowledge documents"))
return
}
out.Documents = ragDocsFrom(docs)
if query := strings.TrimSpace(r.URL.Query().Get("query")); query != "" {
hits, herr := s.rag.Search(r.Context(), query)
if herr != nil {
s.log.Error("search rag", "err", herr)
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_search_failed", "failed to search knowledge base"))
return
}
out.Hits = ragHitsFrom(hits)
}
writeJSON(w, http.StatusOK, out)
}
// adminSearchRag runs a knowledge-base query and returns the matching hits.
func (s *Server) adminSearchRag(w http.ResponseWriter, r *http.Request) {
if s.rag == nil {
s.auditClaims(r, "rag.query", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "知识库 RAG 服务未配置(未注入 RagStore)", HTTPCode: http.StatusNotImplemented})
return
}
var req ragQueryRequest
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.Query) == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "query is required"))
return
}
hits, err := s.rag.Search(r.Context(), req.Query)
if err != nil {
s.log.Error("search rag", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_search_failed", "failed to search knowledge base"))
return
}
s.auditClaims(r, "rag.query", "ok")
writeJSON(w, http.StatusOK, map[string]any{"query": req.Query, "hits": ragHitsFrom(hits)})
}
// adminDeleteRag answers the document delete honestly: write operations
// (upload/chunk/embed/delete) are milestone M10, so it returns a clear 501 rather
// than a fabricated success. The route is audited as an admin write.
func (s *Server) adminDeleteRag(w http.ResponseWriter, r *http.Request) {
if s.rag == nil {
s.auditClaims(r, "rag.delete", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "知识库 RAG 服务未配置(未注入 RagStore)", HTTPCode: http.StatusNotImplemented})
return
}
var req ragDeleteRequest
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) == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required"))
return
}
if err := s.rag.DeleteDocument(r.Context(), req.ID); err != nil {
s.auditClaims(r, "rag.delete", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "知识库写操作(上传/分块/嵌入/删除)由 M10 后端提供", HTTPCode: http.StatusNotImplemented})
return
}
s.auditClaims(r, "rag.delete", "ok")
writeJSON(w, http.StatusOK, map[string]any{"id": req.ID})
}
// ragDocsFrom maps knowledge docs to their admin wire shape.
func ragDocsFrom(docs []knowledge.Doc) []AdminRagDoc {
out := make([]AdminRagDoc, 0, len(docs))
for _, d := range docs {
out = append(out, AdminRagDoc{ID: d.ID, Name: d.Name, Desc: d.Desc, Chunks: d.Chunks, Embedded: d.Embedded})
}
return out
}
// ragHitsFrom maps knowledge hits to their admin wire shape.
func ragHitsFrom(hits []knowledge.Hit) []AdminRagHit {
out := make([]AdminRagHit, 0, len(hits))
for _, h := range hits {
out = append(out, AdminRagHit{
DocID: h.DocID,
DocName: h.DocName,
Chapter: h.Chapter,
Text: h.Text,
Score: h.Score,
Semantic: h.Semantic,
})
}
return out
}
// adminSetPolicy upserts the reachability markers for a capability. It is the
// audited admin write example (owner-only, enforced by the route registration).
func (s *Server) adminSetPolicy(w http.ResponseWriter, r *http.Request) {
var req policyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if req.Name == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
return
}
if s.caps == nil {
s.auditClaims(r, "policy.set", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "policy_unavailable", "capability service is not configured"))
return
}
err := s.caps.SetPolicy(req.Name, capreg.ToolPolicy{AllowInGroup: req.AllowInGroup, AllowInStranger: req.AllowInStranger})
if err != nil {
s.auditClaims(r, "policy.set", "error")
if errors.Is(err, capreg.ErrNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "capability %q not found", req.Name))
return
}
writeError(w, apperr.Wrap(apperr.ErrInternal, "policy_failed", "failed to set policy"))
return
}
s.auditClaims(r, "policy.set", "ok")
writeJSON(w, http.StatusOK, map[string]any{"name": req.Name, "allowInGroup": req.AllowInGroup, "allowInStranger": req.AllowInStranger})
}
// collectSessions returns a non-nil session slice from the configured store.
func (s *Server) collectSessions(ctx context.Context) []SessionSummary {
out := make([]SessionSummary, 0)
if s.sessions == nil {
return out
}
list, err := s.sessions.List(ctx)
if err != nil {
s.log.Error("list sessions", "err", err)
return out
}
for _, sess := range list {
out = append(out, summaryOfSession(sess))
}
return out
}
// collectMemories returns a non-nil memory slice from the configured store.
func (s *Server) collectMemories(ctx context.Context) []MemorySummary {
out := make([]MemorySummary, 0)
if s.memories == nil {
return out
}
list, err := s.memories.List(ctx)
if err != nil {
s.log.Error("list memories", "err", err)
return out
}
for _, it := range list {
out = append(out, MemorySummary{ID: it.ID, Scope: string(it.Scope), Text: it.Content})
}
return out
}
// iotDeviceSummary is the wire shape of an IoT device.
type iotDeviceSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
State map[string]any `json:"state"`
Online bool `json:"online"`
}