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"` }