package gateway import ( "encoding/json" "net/http" "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" ) // adminListPersonas 列出人格摘要(管理读)。它是管理侧结构: // 角色级 mood 加上面板可编辑的 `thinking` 开关。 func (s *Server) adminListPersonas(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.collectPersonas(true)) } // adminListSessions 在接线了 history store 时列出持久化聊天历史 // 会话(main + sub),否则回退到渠道会话视图。它是 // handleListSessions 的管理侧对应物:面板展示真实的 main 与 // 次级会话(design/16 §2),而不是陈旧的渠道会话行; // 回退路径为未接 history 的服务器保留 design/16 之前的契约。 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 列出记忆条目(管理读)。 func (s *Server) adminListMemories(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.collectMemories(r.Context())) } // adminListPlugins 列出所有已注册能力(管理读)。 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 列出已注册的渠道适配器(管理读)。当接线了 // 渠道配置存储时,它还会附上各平台已配置的账号,并做脱敏, // 确保原始 token 绝不抵达客户端。 func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) { accountsByPlatform := s.maskedChannelAccounts() botByPlatform := s.liveBotIdentity() connectedByPlatform := s.liveAccountConnections() onlineByPlatform := s.liveAccountOnline() outcomeByPlatform := s.liveAccountConnectOutcomes() 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 是「存在哪些账号」的唯一事实来源。已注册的 // 适配器只有在其账号仍处于配置状态时才浮现,因此已删除的 // 账号会从列表中消失。没有配置存储时(测试),每个已注册 // 适配器原样列出。 if s.channels != nil { accts, ok := accountsByPlatform[platform] if !ok || !platformHasAccountID(accts, acctID) { continue } } // 按平台聚合:裸适配器("kook")与多账号适配器 // ("kook:fc2048"、"kook:conf1")共用一个平台行, // 因此账号列表不会按注册表中的每个适配器重复成独立行。 if s.hasChannelSummary(out, platform) { continue } // 连接态三态(v0.68):`connected=false` 可能只是「正在后台重试」或 // 「监听已就绪、等对端接入」,两者都不是「未连接」这样一个静态结论。 // 只有适配器确实自报了才写这三个键,否则一律省略(同 NeedsRescan 口径)。 connected, outcome := channelConnectStateOf(ch) sum := ChannelSummary{ Name: platform, Connected: connected, Pending: outcome.Pending, Ready: outcome.Ready, Reason: outcome.Reason, } // 可选的 rescan 能力(weixin-iLink 会话被拒,v0.39):仅在适配器 // 确实要求重新扫码时才报告,因此健康的适配器保持字段省略, // 而不是送出一个编造的值。 if ra, ok := asRescanAware(cap.Value); ok { if required, reason := ra.RescanRequired(); required { sum.NeedsRescan = true sum.LastError = reason } } if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 { sum.Accounts = enrichAccountsWithConnectOutcome( enrichAccountsWithAccountOnline( enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform]), onlineByPlatform[platform], ), outcomeByPlatform[platform], ) } out = append(out, sum) } } // 有已配置账号但没有已注册适配器的平台(例如账号刚刚保存) // 依然会浮现,以便其配置可见。 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 返回按平台生效的已配置账号, // 已脱敏(不含原始 token)。渠道配置存储未接线时为空。 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 返回该平台适配器暴露的账号级管理操作, // 使面板只渲染账号真正具备的能力。没有专用适配器 // 表面的平台返回 nil(省略)。 func platformAdminOps(platform string) []string { if channelBasePlatform(platform) == "kook" { return []string{"friends", "user_chats", "channels", "guilds", "message_test"} } return nil } // hasChannelSummary 报告 out 中是否已含该平台的摘要 // (以免有已注册适配器的平台被重复)。 func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool { for _, c := range out { if channelBasePlatform(c.Name) == platform { return true } } return false } // platformHasAccountID 报告某平台已配置的账号中是否仍包含 // 适配器所绑定的账号。裸适配器(kook)绑定到该平台的 // 默认账号(空 id);多账号适配器(kook:conf0)绑定到 // 恰好那个账号 id。不匹配意味着适配器已陈旧 —— 它的 // 账号已从 channels.json 删除 —— 因此不得出现在列表中。 func platformHasAccountID(accts []AdminChannelAccount, id string) bool { for _, a := range accts { if a.ID == id { return true } } return false } // botInfo 是已连接机器人的实际解析身份(kook 在 Connect 时从 // /api/v3/user/me 获取)。它通过适配器的 Config() 浮现, // 使管理列表能展示机器人是谁而无需持久化。 type botInfo struct { id string username string nickname string } // empty 报告机器人身份是否没有任何部分解析成功。 func (b botInfo) empty() bool { return b.id == "" && b.username == "" && b.nickname == "" } // liveBotIdentity 收集每个已注册渠道适配器报告的机器人身份, // 以裸平台名再按账号 id 为键(裸适配器名得到空账号 id)。 // 不暴露机器人身份的适配器(telegram、webhook……)不贡献 // 任何内容,因此其他平台不受影响。 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 从适配器配置 map 中读取可选的机器人身份字段。 // 这些键是非敏感的(bot_id / bot_username / bot_nickname),因此 // 绝不会被 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 把形如 "kook:conf0" 的(可能多账号的)适配器名 // 拆成 ("kook", "conf0");裸名得到空账号 id。 func splitAdapterName(name string) (platform, acct string) { if base, tail, ok := strings.Cut(name, ":"); ok { return base, tail } return name, "" } // enrichAccountsWithBot 把实时机器人身份合并进平台中匹配的账号。 // 账号按 id 与其实时适配器配对,因此多账号平台会展示 // 各自机器人的身份。没有实时机器人的账号(尚未连接) // 保持其已存字段不变。 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 列出 IoT 设备(管理读)。 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 返回从实时 provider 栈读取的 LLM provider/model // 配置(models.json,回退到 .env)。API key 绝不返回 —— // 线上结构中不含 key 字段 —— 因此该 handler // 不泄露任何敏感信息。除分组本身外还下发**表单选项的取值域** // (reasoningKinds / personaProviderOptions),使面板的下拉框与后端校验 // 永远同一份来源。 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, adminLlmProviderDTO(g, providerDisplayName(g), i == 0)) } writeJSON(w, http.StatusOK, AdminLlmConfig{ Providers: providers, ReasoningKinds: runtimeconfig.ReasoningKinds(), PersonaProviderOptions: runtimeconfig.ProviderNames(), }) } // currentLlmGroups 返回要展示的 provider 分组:接线了实时栈时 // 用它的分组,否则用由环境变量合成的单一分组。 func (s *Server) currentLlmGroups() []runtimeconfig.Group { if s.llm != nil { return s.llm.Groups() } return runtimeconfig.GroupFromEnv(s.cfg.LLM) } // providerDisplayName 返回 provider 分组的友好标签:两种已知协议 // 用厂商名,其余用分组 id。 func providerDisplayName(g runtimeconfig.Group) string { switch g.Provider { case runtimeconfig.ProviderOpenAI: return "OpenAI" case runtimeconfig.ProviderAnthropic: return "Anthropic" default: return g.ID } } // adminListAutomations 列出自动化与提醒(管理读)。 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 列出账号与 API key(管理读)。未接线 UserStore 时 // 它只报告那一个已配置的管理账号,而不是编造 // 一列虚拟用户。 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 列出最近的后台思考记录(管理读)。 // // 它现在只做**筛选参数校验**并把读侧交给 writeBackthinkList // (admin_backthink_filter.go,切片 C2):`?trigger=` 未知取值如实 400 // (design/12 §N C.6 的服务端筛选),合法取值则下推到存储层取匹配条数。 func (s *Server) adminListBackthink(w http.ResponseWriter, r *http.Request) { trigger, err := parseBackthinkTrigger(r.URL.Query()) if err != nil { writeError(w, err) return } s.writeBackthinkList(w, r, trigger) } // adminListRag 列出知识库文档,并在带 ?query= 时 // 一并返回匹配的命中。未接线 RagStore 时它退化为 // 空列表(向后兼容 —— 绝不返回 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 执行一次知识库查询并返回匹配的命中。 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)}) } // ragDocsFrom 把知识文档映射为其管理侧线上结构。 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 把知识命中映射为其管理侧线上结构。 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 }