Files
Cyrene_v2/internal/services/gateway/admin_query.go
T
AskaEth 86aef7b454 feat(capability): 工具按上游 MCP server 分节(来源章 + 呈现隔离)
主人 2026-10-24:「能否将每个 MCP 服务器所承载的插件和工具按上游(来源)服务器分类
并与其他插件相对隔离?」

归属信息此前后端根本没有(GET /admin/plugins 每条只有 name/kind/description/两个
布尔),而 `mcp:` 是每台 server 一条的 KindMCP 条目的注册键,**不是**工具的命名空间
(工具名由 server 原样给出,连前缀都没有)⇒ 只能靠注册时的元数据,不能解析名字。

后端(唯一汇点盖章):
- 新增 pkg/plugins/sdk/origin.go:Origin{Kind,Server,Transport} + 可选接口
  OriginAware(没实现 = 宿主内置,老工具零改动)+ OriginOf(零值视为无来源)。
- registry.Capability 加 Origin 字段;RegisterTool 盖章(覆盖热装与 .env
  MCP_SERVERS_JSON 两条接入路径的合流处),ReplaceTool **同样重新盖章**(它逐字段
  重建条目,漏了就是「热替换把来源抹掉」)。
- mcp.mcpTool 实现 Origin()(server 名与传输都在 cfg 上),transport 用新的
  transportNameOf 报**实际会用的**形态(stdio/sse/http),与 newTransport 同一推断。
- 网关 PluginSummary 加可选 origin(omitempty):零值 ⇒ 键缺席,不是空对象。
- 卸载守卫:对单把 MCP 工具或 `mcp:<server>` 登记条目直接 400
  (plugin_uninstall_scope)并指向「MCP 服务器」页 —— 原来会绕过插件管理器裸删,
  server 的 names 账本不同步、重连后工具复活。判据是来源章,不是名字前缀。

前端(呈现分组):
- admin/modules/pluginOrigin.ts:纯函数按 origin.server 分节(按 server 名排序),
  transport 未报告时标题省略括号,缺 server 名不冒充内置。
- Plugins.tsx:工具段两级(`来自 fs(stdio)· N 把工具` + 宿主内置),MCP 工具行去掉
  「卸载」改为指向 MCP 页;`kind:"mcp"` 登记条目与未知 kind(iot)各有段如实显示,
  不再静默丢弃(design/09 §5.2.1 红线③)。

零影响(有断言):tool_policies 表/策略写路径未改;ToolSchemas 不发来源(测试断言
模型面既无 origin 键也无 server 名);停用 server ⇒ 条目连来源一起消失,无孤儿。

测试:registry 盖章/保章(两条均已实测变异变红)/内置零值/改策略不清章;mcp 注册
路径盖章 + transport 推断表;网关读面缺席与存在、热装整链、卸载守卫;前端 esbuild+cjs
逻辑测试 12 项。验收:go build/vet/test 全绿,frontend `npm run build` 通过。
2026-10-24 20:21:22 +08:00

488 lines
17 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 (
"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 列出所有已注册能力(管理读)。
//
// 每条能力带上它的上游来源(若有):来源章的读取面在这里一次性给出,
// 前端「插件与能力」页据此把工具按 server 分节。**策略字段与集合完全不变** ——
// 只是多了一个可选键,权限判定与模型可见的工具面都不读它。
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,
Origin: pluginOriginOf(cap),
})
}
}
writeJSON(w, http.StatusOK, out)
}
// pluginOriginOf 把能力条目上的来源章映射为线上结构;零值(宿主内置 / 来源未知)
// 返回 nil,使 JSON 里**没有** origin 键 —— 缺席是「宿主内置」的表达。
func pluginOriginOf(cap capreg.Capability) *PluginOrigin {
if cap.Origin.IsZero() {
return nil
}
return &PluginOrigin{
Kind: cap.Origin.Kind,
Server: cap.Origin.Server,
Transport: cap.Origin.Transport,
}
}
// 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
}