Files
Cyrene_v2/internal/services/gateway/admin_query.go
T
AskaEth 1ac02bd77e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,admin-web): 面板可编辑 provider 推理形状 / 角色级路由 / multimodal 真语义(裁决 03.4.2/03.4.1)
- 后端:AdminLlmProvider 暴露 reasoning{kind}(恒下发,""=未声明=auto)、personaModels、personaProviders;
  顶层新增 reasoningKinds / personaProviderOptions 两个**取值域**(唯一来源= runtimeconfig 导出枚举,
  校验错误文案也由枚举拼接 ⇒ 不可能漂移)
- 读写共用同一映射(admin_llm_provider.go),避免「读侧少一个键 ⇒ 面板一保存就抹掉」
- 写侧语义:缺失=保留、给出=整块/整表替换;非法值一律 runtimeconfig.Group.Validate() ⇒ 400 bad_request
  (实测非法 kind 返回 400 且 models.json 完全不动,而非 500 静默失败)
- 前端:新增 LlmProviderRouting(推理形状下拉 + multimodal 开关 + 按角色路由编辑器);
  目录能力位改三态(true/false/未标注),「未标注」不再谎称「不支持」;目录/角色清单不可用时
  **不阻塞编辑**且与「字段为空」是不同状态;绝无 mock/sample 回退
- 复核:build/vet 绿 + gateway/config 包测试绿 + tsc 0 错 + vite build 成功;2 条变异反证(删 reasoning
  补丁 → 2 例红;删 Group.Validate → 该 400 用例红)已还原
- 注意(主会话已知):npm run build 把新 SPA 写进 frontend/web/dist,而运行中的二进制优先从磁盘读该目录
  ⇒ :10942 目前是「新面板 + 旧后端」,等阶段 5 全绿后按红线「先 build 到位、再一次 restart」切换
2026-10-04 13:06:54 +08:00

464 lines
16 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"
"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
// 不泄露任何敏感信息。除分组本身外还下发**表单选项的取值域**
// (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 列出最近的后台思考记录(管理读)。
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
}