diff --git a/internal/services/gateway/admin.go b/internal/services/gateway/admin.go index 44761c7..cf565fe 100644 --- a/internal/services/gateway/admin.go +++ b/internal/services/gateway/admin.go @@ -1,21 +1,6 @@ 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" -) +import "context" // backthinkListLimit 是未指定 limit 时管理面板索取的 // 最近 think 记录条数。 @@ -107,378 +92,6 @@ type AdminBackthinkItem struct { At int64 `json:"at"` } -// 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() - 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 - } - sum := ChannelSummary{Name: platform, Connected: ch.IsConnected()} - // 可选的 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 = enrichAccountsWithBot(accts, botByPlatform[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 -// 不泄露任何敏感信息。 -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, - MaxTokens: g.MaxTokens, - }) - } - writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: providers}) -} - -// 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 列出最近的后台思考记录(管理读)。 -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 是管理 RAG 列表中一个知识库文档(项目索引 // 条目)的线上结构。 type AdminRagDoc struct { @@ -512,116 +125,6 @@ type ragQueryRequest struct { Query string `json:"query"` } -// 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 -} - -// adminSetPolicy 更新插入某个能力的可达性标记。它是 -// 带审计的管理写示例(仅 owner,由路由注册强制)。 -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 从已配置的存储返回非 nil 的会话切片。 func (s *Server) collectSessions(ctx context.Context) []SessionSummary { out := make([]SessionSummary, 0) diff --git a/internal/services/gateway/admin_mutate.go b/internal/services/gateway/admin_mutate.go new file mode 100644 index 0000000..2e2d607 --- /dev/null +++ b/internal/services/gateway/admin_mutate.go @@ -0,0 +1,41 @@ +package gateway + +import ( + "encoding/json" + "errors" + "net/http" + + capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" +) + +// adminSetPolicy 更新插入某个能力的可达性标记。它是 +// 带审计的管理写示例(仅 owner,由路由注册强制)。 +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}) +} diff --git a/internal/services/gateway/admin_query.go b/internal/services/gateway/admin_query.go new file mode 100644 index 0000000..c1ede4b --- /dev/null +++ b/internal/services/gateway/admin_query.go @@ -0,0 +1,467 @@ +package gateway + +import ( + "encoding/json" + "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" +) + +// 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() + 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 + } + sum := ChannelSummary{Name: platform, Connected: ch.IsConnected()} + // 可选的 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 = enrichAccountsWithBot(accts, botByPlatform[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 +// 不泄露任何敏感信息。 +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, + MaxTokens: g.MaxTokens, + }) + } + writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: providers}) +} + +// 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 列出最近的后台思考记录(管理读)。 +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) +} + +// 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 +}