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" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" 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. Accounts // carries that platform's configured accounts (masked, no raw tokens) so the // panel can show and edit the account config; it is omitted when s.channels is // not wired or the platform has no configured account. type ChannelSummary struct { Name string `json:"name"` Connected bool `json:"connected"` Accounts []AdminChannelAccount `json:"accounts,omitempty"` } // 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"` Provider string `json:"provider"` 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 the persistent chat-history sessions (main + sub) when a // history store is wired, otherwise falls back to the channel-session view. It is // the admin counterpart of handleListSessions: the panel shows the real main and // secondary sessions (design/16 §2) rather than stale channel-session rows, and // the fallback keeps the pre-design/16 contract for servers built without history. func (s *Server) adminListSessions(w http.ResponseWriter, r *http.Request) { if s.history != nil { s.handleHistorySessions(w, r) return } 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). When the // channel config store is wired it also attaches each platform's configured // accounts, masked so a raw token never reaches the client. func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) { accountsByPlatform := s.maskedChannelAccounts() botByPlatform := s.liveBotIdentity() 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 } platform, acctID := splitAdapterName(ch.Name()) // channels.json is the source of truth for which accounts exist. A // registered adapter surfaces only while its account is still // configured, so a deleted account disappears from the list. Without a // config store (tests) every registered adapter is listed unchanged. if s.channels != nil { accts, ok := accountsByPlatform[platform] if !ok || !platformHasAccountID(accts, acctID) { continue } } // Aggregate by platform: a bare adapter ("kook") and the multi-account // ones ("kook:fc2048", "kook:conf1") are ONE platform row, so the account // list is not duplicated into a separate row per in-registry adapter. if s.hasChannelSummary(out, platform) { continue } sum := ChannelSummary{Name: platform, Connected: ch.IsConnected()} if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 { sum.Accounts = enrichAccountsWithBot(accts, botByPlatform[platform]) } out = append(out, sum) } } // A platform that has configured accounts but no registered adapter (e.g. the // account was just saved) still surfaces, so its config is visible. for platform, accts := range accountsByPlatform { if len(accts) == 0 || s.hasChannelSummary(out, platform) { continue } out = append(out, ChannelSummary{Name: platform, Accounts: accts}) } writeJSON(w, http.StatusOK, out) } // maskedChannelAccounts returns the effective per-platform configured accounts, // masked (no raw tokens). It is empty when the channel config store is not wired. func (s *Server) maskedChannelAccounts() map[string][]AdminChannelAccount { if s.channels == nil { return map[string][]AdminChannelAccount{} } out := make(map[string][]AdminChannelAccount, 8) for platform, accts := range s.channels.Load() { list := make([]AdminChannelAccount, 0, len(accts)) for _, a := range accts { if !configuredAccount(a) { continue } account := adminChannelAccountOf(runtimeconfig.Masked(a)) account.Capabilities = platformAdminOps(platform) list = append(list, account) } if len(list) > 0 { out[platform] = list } } return out } // platformAdminOps returns the account-level admin operations the platform's // adapter exposes, so the panel renders only the capabilities the account really // has. Platforms without a specialized adapter surface return nil (omitted). func platformAdminOps(platform string) []string { if channelBasePlatform(platform) == "kook" { return []string{"friends", "user_chats", "channels", "guilds", "message_test"} } return nil } // hasChannelSummary reports whether out already contains a summary for platform // (so a platform with a registered adapter is not duplicated). func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool { for _, c := range out { if channelBasePlatform(c.Name) == platform { return true } } return false } // platformHasAccountID reports whether the configured accounts of a platform still // include the account an adapter is bound to. A bare adapter (kook) binds to that // platform's default account (empty id); a multi-account adapter (kook:conf0) // binds to exactly that account id. A mismatch means the adapter is stale — its // account was deleted from channels.json — so it must not surface in the list. func platformHasAccountID(accts []AdminChannelAccount, id string) bool { for _, a := range accts { if a.ID == id { return true } } return false } // botInfo is the live, connected bot's resolved identity (kook fetches it from // /api/v3/user/me on Connect). It is surfaced through the adapter's Config() so // the admin list can show who the bot is without persisting it. type botInfo struct { id string username string nickname string } // empty reports whether no part of the bot identity resolved. func (b botInfo) empty() bool { return b.id == "" && b.username == "" && b.nickname == "" } // liveBotIdentity collects the bot identity each registered channel adapter // reports, keyed by bare platform then account id (a bare adapter name yields an // empty account id). Adapters that do not expose bot identity (telegram, // webhook, ...) contribute nothing, so other platforms are unaffected. func (s *Server) liveBotIdentity() map[string]map[string]botInfo { out := make(map[string]map[string]botInfo) if s.caps == nil { return out } for _, cap := range s.caps.List(capreg.KindChannel) { ch, ok := cap.Value.(interface{ Config() map[string]any }) if !ok { continue } info := botIdentityOf(ch.Config()) if info.empty() { continue } platform, acct := splitAdapterName(cap.Name) if out[platform] == nil { out[platform] = make(map[string]botInfo) } out[platform][acct] = info } return out } // botIdentityOf reads the optional bot-identity fields off an adapter config map. // The keys are non-sensitive (bot_id / bot_username / bot_nickname) so they are // never masked by runtimeconfig.Masked. func botIdentityOf(cfg map[string]any) botInfo { if cfg == nil { return botInfo{} } var b botInfo b.id, _ = cfg["bot_id"].(string) b.username, _ = cfg["bot_username"].(string) b.nickname, _ = cfg["bot_nickname"].(string) return b } // splitAdapterName splits a (possibly multi-account) adapter name like // "kook:conf0" into ("kook", "conf0"); a bare name yields an empty account id. func splitAdapterName(name string) (platform, acct string) { if base, tail, ok := strings.Cut(name, ":"); ok { return base, tail } return name, "" } // enrichAccountsWithBot merges the live bot identity into the matching account(s) // of a platform. An account is matched to its live adapter by id, so a // multi-account platform shows each bot's own identity. Accounts without a live // bot (not connected yet) keep their stored fields unchanged. func enrichAccountsWithBot(accts []AdminChannelAccount, by map[string]botInfo) []AdminChannelAccount { if len(by) == 0 { return accts } out := make([]AdminChannelAccount, 0, len(accts)) for _, a := range accts { if info, ok := by[a.ID]; ok && !info.empty() { a.BotID = info.id a.BotUsername = info.username a.BotNickname = info.nickname } out = append(out, a) } return 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 // provider stack (models.json, falling back to .env). 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) { groups := s.currentLlmGroups() providers := make([]AdminLlmProvider, 0, len(groups)) for i, g := range groups { providers = append(providers, AdminLlmProvider{ ID: g.ID, Name: providerDisplayName(g), Provider: g.Provider, BaseURL: g.BaseURL, Model: g.Model, Multimodal: g.Multimodal, Enabled: i == 0, Fallback: g.Fallback, }) } writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: providers}) } // currentLlmGroups returns the provider groups to display: the live stack's // groups when one is wired, otherwise the env-synthesized single group. func (s *Server) currentLlmGroups() []runtimeconfig.Group { if s.llm != nil { return s.llm.Groups() } return runtimeconfig.GroupFromEnv(s.cfg.LLM) } // providerDisplayName returns a friendly label for a provider group, using the // vendor name for the two known protocols and the group id otherwise. func providerDisplayName(g runtimeconfig.Group) string { switch g.Provider { case runtimeconfig.ProviderOpenAI: return "OpenAI" case runtimeconfig.ProviderAnthropic: return "Anthropic" default: return g.ID } } // 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"` }