refactor(gateway): 拆 admin.go 666 行 → admin.go / admin_query.go / admin_mutate.go(纯搬移)
执行方案 DevDocs/audit/_plan.md 阶段 0 · G0-C:该文件是全仓第二大超限文件(666 行), 且后续多条裁决要改它(工具策略落盘、主人档案 GET/PUT、admin API 补推送结果、模型配置 自动化)⇒ 先拆。 - admin.go(169):共享地基 —— backthinkListLimit 常量、全部 DTO 类型、 collectSessions/collectMemories 收集器、iotDeviceSummary - admin_query.go(467):只读 GET 聚合(personas/sessions/memories/plugins/channels/iot/ llm/automations/users/backthink/rag 的列表与查询 handler + 渠道账号脱敏等私有辅助) - admin_mutate.go(41):写操作(adminSetPolicy,带审计的 owner 写) 纯搬移机械证明:拆前与拆后(非空、非 package、非 import 块行)排序后 **md5 相同** (605=605 行);顶层声明集合 43 条守恒;唯一新增 = 两个新文件的 package 行与 import 块。 未触碰 admin_write_*.go / admin_channels_*.go 及任何 _test.go。 验证:go build/vet 绿;internal/services/gateway 测试绿(含 -run Admin 定向); linux/amd64 + darwin/arm64 交叉编译绿;三文件 169/467/41 行均 ≤500;gofmt -l 无输出。
This commit is contained in:
1 parent
ec292081b2
commit
deb0a3cb97
3 files changed
+509
-498
No files matched your search
@@ -1,21 +1,6 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import "context"
|
||||||
"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 是未指定 limit 时管理面板索取的
|
// backthinkListLimit 是未指定 limit 时管理面板索取的
|
||||||
// 最近 think 记录条数。
|
// 最近 think 记录条数。
|
||||||
@@ -107,378 +92,6 @@ type AdminBackthinkItem struct {
|
|||||||
At int64 `json:"at"`
|
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 列表中一个知识库文档(项目索引
|
// AdminRagDoc 是管理 RAG 列表中一个知识库文档(项目索引
|
||||||
// 条目)的线上结构。
|
// 条目)的线上结构。
|
||||||
type AdminRagDoc struct {
|
type AdminRagDoc struct {
|
||||||
@@ -512,116 +125,6 @@ type ragQueryRequest struct {
|
|||||||
Query string `json:"query"`
|
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 的会话切片。
|
// collectSessions 从已配置的存储返回非 nil 的会话切片。
|
||||||
func (s *Server) collectSessions(ctx context.Context) []SessionSummary {
|
func (s *Server) collectSessions(ctx context.Context) []SessionSummary {
|
||||||
out := make([]SessionSummary, 0)
|
out := make([]SessionSummary, 0)
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user