后端(KOOK REST, 复用 httpclient WithAuthScheme(Bot), token不打印): - kook/admin.go(新): ListFriends/HandleFriendRequest/DeleteFriend/BlockFriend/ ListUserChats/CreateUserChat/ListDirectMessages - kook/admin_guild.go(新): ListGuilds/ListGuildChannels/ListGuildUsers/ListChannelMsgs/SendTestMessage - gateway/admin_channels_platform.go(新): kookAdmin(反射解包namedAdapter命中内嵌PlatformAdapter) + 4 handler + adminKookView聚合(子项失败记errors不伪造) - server.go 注册 4 条 ownerAuth 路由: /view /friends /friend/handle /message/test - admin.go/admin_write_channel_config.go: AdminChannelAccount 加 Capabilities 前端(KOOK 专属页): - KookPanel.tsx(新): 好友&申请/服务器/频道/成员/私信会话/发消息测试卡片 - AdapterDetailPage.tsx: platform==='kook' 渲染 KookPanel, 否则通用 - types.ts/api.ts: KOOK 类型 + adminChannelPlatformView/Friends/FriendHandle/MessageTest 文档: Docs/api-client.md 新增4端点契约(owner-only)+capabilities+v0.17 验收: go build/vet/test ./... 全绿 + npm run build 97模块全绿; 零新依赖 注: api.ts/types.ts 集中契约文件>500行属既有结构(未新增违规); KOOK其余REST方法留能力面待用
678 lines
23 KiB
Go
678 lines
23 KiB
Go
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"
|
||
)
|
||
|
||
// backthinkListLimit is the number of most-recent think records the admin
|
||
// panel asks for when no limit is specified.
|
||
const backthinkListLimit = 50
|
||
|
||
// PluginSummary is the wire shape of a capability/plugin in the admin list.
|
||
type PluginSummary struct {
|
||
Name string `json:"name"`
|
||
Kind string `json:"kind"`
|
||
Description string `json:"description"`
|
||
AllowInGroup bool `json:"allowInGroup"`
|
||
AllowInStranger bool `json:"allowInStranger"`
|
||
}
|
||
|
||
// ChannelSummary is the wire shape of a registered channel adapter. Accounts
|
||
// carries that platform's configured accounts (masked, no raw tokens) so the
|
||
// panel can show and edit the account config; it is omitted when s.channels is
|
||
// not wired or the platform has no configured account.
|
||
type ChannelSummary struct {
|
||
Name string `json:"name"`
|
||
Connected bool `json:"connected"`
|
||
Accounts []AdminChannelAccount `json:"accounts,omitempty"`
|
||
}
|
||
|
||
// policyRequest is the POST /api/v1/admin/plugins/policy body.
|
||
type policyRequest struct {
|
||
Name string `json:"name"`
|
||
AllowInGroup bool `json:"allowInGroup"`
|
||
AllowInStranger bool `json:"allowInStranger"`
|
||
}
|
||
|
||
// AdminLlmProvider is the wire shape of one LLM provider in the admin config.
|
||
// The API key is deliberately absent: it is never returned to the client.
|
||
type AdminLlmProvider struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Provider string `json:"provider"`
|
||
BaseURL string `json:"baseUrl"`
|
||
Model string `json:"model"`
|
||
Multimodal bool `json:"multimodal"`
|
||
Enabled bool `json:"enabled"`
|
||
Fallback string `json:"fallback,omitempty"`
|
||
}
|
||
|
||
// AdminLlmConfig is the wire shape of GET /api/v1/admin/llm.
|
||
type AdminLlmConfig struct {
|
||
Providers []AdminLlmProvider `json:"providers"`
|
||
}
|
||
|
||
// AdminAutomationRecord is the wire shape of one automation/reminder in the
|
||
// admin list. Cron carries the cron expression for the cron kind; lastRun and
|
||
// nextRun are omitted when the value is unknown.
|
||
type AdminAutomationRecord struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Kind string `json:"kind"`
|
||
Cron string `json:"cron,omitempty"`
|
||
Enabled bool `json:"enabled"`
|
||
LastRun string `json:"lastRun,omitempty"`
|
||
NextRun string `json:"nextRun,omitempty"`
|
||
}
|
||
|
||
// AdminUserRecord is the wire shape of one account/API key in the admin list.
|
||
type AdminUserRecord struct {
|
||
ID string `json:"id"`
|
||
Username string `json:"username"`
|
||
Role string `json:"role"`
|
||
CreatedAt string `json:"createdAt,omitempty"`
|
||
}
|
||
|
||
// AdminBackthinkItem is the wire shape of one background-think record in the
|
||
// admin monitor list.
|
||
type AdminBackthinkItem struct {
|
||
ID string `json:"id"`
|
||
Trigger string `json:"trigger"`
|
||
Conclusion string `json:"conclusion,omitempty"`
|
||
ShouldNotify bool `json:"shouldNotify"`
|
||
Pushed bool `json:"pushed"`
|
||
At int64 `json:"at"`
|
||
}
|
||
|
||
// adminListPersonas lists persona summaries (admin read).
|
||
func (s *Server) adminListPersonas(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, http.StatusOK, s.collectPersonas())
|
||
}
|
||
|
||
// adminListSessions lists the persistent chat-history sessions (main + sub) when a
|
||
// history store is wired, otherwise falls back to the channel-session view. It is
|
||
// the admin counterpart of handleListSessions: the panel shows the real main and
|
||
// secondary sessions (design/16 §2) rather than stale channel-session rows, and
|
||
// the fallback keeps the pre-design/16 contract for servers built without history.
|
||
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 lists memory entries (admin read).
|
||
func (s *Server) adminListMemories(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, http.StatusOK, s.collectMemories(r.Context()))
|
||
}
|
||
|
||
// adminListPlugins lists every registered capability (admin read).
|
||
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 lists the registered channel adapters (admin read). When the
|
||
// channel config store is wired it also attaches each platform's configured
|
||
// accounts, masked so a raw token never reaches the client.
|
||
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 is the source of truth for which accounts exist. A
|
||
// registered adapter surfaces only while its account is still
|
||
// configured, so a deleted account disappears from the list. Without a
|
||
// config store (tests) every registered adapter is listed unchanged.
|
||
if s.channels != nil {
|
||
accts, ok := accountsByPlatform[platform]
|
||
if !ok || !platformHasAccountID(accts, acctID) {
|
||
continue
|
||
}
|
||
}
|
||
// Aggregate by platform: a bare adapter ("kook") and the multi-account
|
||
// ones ("kook:fc2048", "kook:conf1") are ONE platform row, so the account
|
||
// list is not duplicated into a separate row per in-registry adapter.
|
||
if s.hasChannelSummary(out, platform) {
|
||
continue
|
||
}
|
||
sum := ChannelSummary{Name: platform, Connected: ch.IsConnected()}
|
||
if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 {
|
||
sum.Accounts = enrichAccountsWithBot(accts, botByPlatform[platform])
|
||
}
|
||
out = append(out, sum)
|
||
}
|
||
}
|
||
// A platform that has configured accounts but no registered adapter (e.g. the
|
||
// account was just saved) still surfaces, so its config is visible.
|
||
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 returns the effective per-platform configured accounts,
|
||
// masked (no raw tokens). It is empty when the channel config store is not wired.
|
||
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 returns the account-level admin operations the platform's
|
||
// adapter exposes, so the panel renders only the capabilities the account really
|
||
// has. Platforms without a specialized adapter surface return nil (omitted).
|
||
func platformAdminOps(platform string) []string {
|
||
if channelBasePlatform(platform) == "kook" {
|
||
return []string{"friends", "user_chats", "channels", "guilds", "message_test"}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// hasChannelSummary reports whether out already contains a summary for platform
|
||
// (so a platform with a registered adapter is not duplicated).
|
||
func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool {
|
||
for _, c := range out {
|
||
if channelBasePlatform(c.Name) == platform {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// platformHasAccountID reports whether the configured accounts of a platform still
|
||
// include the account an adapter is bound to. A bare adapter (kook) binds to that
|
||
// platform's default account (empty id); a multi-account adapter (kook:conf0)
|
||
// binds to exactly that account id. A mismatch means the adapter is stale — its
|
||
// account was deleted from channels.json — so it must not surface in the list.
|
||
func platformHasAccountID(accts []AdminChannelAccount, id string) bool {
|
||
for _, a := range accts {
|
||
if a.ID == id {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// botInfo is the live, connected bot's resolved identity (kook fetches it from
|
||
// /api/v3/user/me on Connect). It is surfaced through the adapter's Config() so
|
||
// the admin list can show who the bot is without persisting it.
|
||
type botInfo struct {
|
||
id string
|
||
username string
|
||
nickname string
|
||
}
|
||
|
||
// empty reports whether no part of the bot identity resolved.
|
||
func (b botInfo) empty() bool { return b.id == "" && b.username == "" && b.nickname == "" }
|
||
|
||
// liveBotIdentity collects the bot identity each registered channel adapter
|
||
// reports, keyed by bare platform then account id (a bare adapter name yields an
|
||
// empty account id). Adapters that do not expose bot identity (telegram,
|
||
// webhook, ...) contribute nothing, so other platforms are unaffected.
|
||
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 reads the optional bot-identity fields off an adapter config map.
|
||
// The keys are non-sensitive (bot_id / bot_username / bot_nickname) so they are
|
||
// never masked by 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 splits a (possibly multi-account) adapter name like
|
||
// "kook:conf0" into ("kook", "conf0"); a bare name yields an empty account id.
|
||
func splitAdapterName(name string) (platform, acct string) {
|
||
if base, tail, ok := strings.Cut(name, ":"); ok {
|
||
return base, tail
|
||
}
|
||
return name, ""
|
||
}
|
||
|
||
// enrichAccountsWithBot merges the live bot identity into the matching account(s)
|
||
// of a platform. An account is matched to its live adapter by id, so a
|
||
// multi-account platform shows each bot's own identity. Accounts without a live
|
||
// bot (not connected yet) keep their stored fields unchanged.
|
||
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 lists IoT devices (admin read).
|
||
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 returns the LLM provider/model configuration read from the live
|
||
// provider stack (models.json, falling back to .env). API keys are never
|
||
// returned — the wire shape carries no key field — so the handler leaks nothing
|
||
// sensitive.
|
||
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,
|
||
})
|
||
}
|
||
writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: providers})
|
||
}
|
||
|
||
// currentLlmGroups returns the provider groups to display: the live stack's
|
||
// groups when one is wired, otherwise the env-synthesized single group.
|
||
func (s *Server) currentLlmGroups() []runtimeconfig.Group {
|
||
if s.llm != nil {
|
||
return s.llm.Groups()
|
||
}
|
||
return runtimeconfig.GroupFromEnv(s.cfg.LLM)
|
||
}
|
||
|
||
// providerDisplayName returns a friendly label for a provider group, using the
|
||
// vendor name for the two known protocols and the group id otherwise.
|
||
func providerDisplayName(g runtimeconfig.Group) string {
|
||
switch g.Provider {
|
||
case runtimeconfig.ProviderOpenAI:
|
||
return "OpenAI"
|
||
case runtimeconfig.ProviderAnthropic:
|
||
return "Anthropic"
|
||
default:
|
||
return g.ID
|
||
}
|
||
}
|
||
|
||
// adminListAutomations lists automations and reminders (admin read).
|
||
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 lists accounts and API keys (admin read). Without a wired
|
||
// UserStore it reports the single configured admin account rather than
|
||
// fabricating a column of virtual users.
|
||
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 lists the most recent background-think records (admin read).
|
||
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 is the wire shape of one knowledge-base document (project-index
|
||
// entry) in the admin RAG list.
|
||
type AdminRagDoc struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Desc string `json:"desc,omitempty"`
|
||
Chunks int `json:"chunks"`
|
||
Embedded bool `json:"embedded"`
|
||
}
|
||
|
||
// AdminRagHit is the wire shape of one RAG retrieval hit.
|
||
type AdminRagHit struct {
|
||
DocID string `json:"docId"`
|
||
DocName string `json:"docName"`
|
||
Chapter string `json:"chapter,omitempty"`
|
||
Text string `json:"text"`
|
||
Score float64 `json:"score"`
|
||
Semantic bool `json:"semantic"`
|
||
}
|
||
|
||
// AdminRagResponse is the wire shape of GET /api/v1/admin/rag. Hits is present
|
||
// only when a query parameter was supplied, so callers distinguish "no search
|
||
// requested" from "searched and found nothing".
|
||
type AdminRagResponse struct {
|
||
Documents []AdminRagDoc `json:"documents"`
|
||
Hits []AdminRagHit `json:"hits,omitempty"`
|
||
}
|
||
|
||
// ragQueryRequest is the POST /api/v1/admin/rag/query body.
|
||
type ragQueryRequest struct {
|
||
Query string `json:"query"`
|
||
}
|
||
|
||
// ragDeleteRequest is the POST /api/v1/admin/rag/delete body.
|
||
type ragDeleteRequest struct {
|
||
ID string `json:"id"`
|
||
}
|
||
|
||
// adminListRag lists the knowledge-base documents and, when ?query= is present,
|
||
// returns the matching hits too. Without a wired RagStore it degrades to an empty
|
||
// list (backward compatible — never a 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 runs a knowledge-base query and returns the matching hits.
|
||
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)})
|
||
}
|
||
|
||
// adminDeleteRag answers the document delete honestly: write operations
|
||
// (upload/chunk/embed/delete) are milestone M10, so it returns a clear 501 rather
|
||
// than a fabricated success. The route is audited as an admin write.
|
||
func (s *Server) adminDeleteRag(w http.ResponseWriter, r *http.Request) {
|
||
if s.rag == nil {
|
||
s.auditClaims(r, "rag.delete", "error")
|
||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "知识库 RAG 服务未配置(未注入 RagStore)", HTTPCode: http.StatusNotImplemented})
|
||
return
|
||
}
|
||
var req ragDeleteRequest
|
||
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.ID) == "" {
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required"))
|
||
return
|
||
}
|
||
if err := s.rag.DeleteDocument(r.Context(), req.ID); err != nil {
|
||
s.auditClaims(r, "rag.delete", "error")
|
||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "知识库写操作(上传/分块/嵌入/删除)由 M10 后端提供", HTTPCode: http.StatusNotImplemented})
|
||
return
|
||
}
|
||
s.auditClaims(r, "rag.delete", "ok")
|
||
writeJSON(w, http.StatusOK, map[string]any{"id": req.ID})
|
||
}
|
||
|
||
// ragDocsFrom maps knowledge docs to their admin wire shape.
|
||
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 maps knowledge hits to their admin wire shape.
|
||
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 upserts the reachability markers for a capability. It is the
|
||
// audited admin write example (owner-only, enforced by the route registration).
|
||
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 returns a non-nil session slice from the configured store.
|
||
func (s *Server) collectSessions(ctx context.Context) []SessionSummary {
|
||
out := make([]SessionSummary, 0)
|
||
if s.sessions == nil {
|
||
return out
|
||
}
|
||
list, err := s.sessions.List(ctx)
|
||
if err != nil {
|
||
s.log.Error("list sessions", "err", err)
|
||
return out
|
||
}
|
||
for _, sess := range list {
|
||
out = append(out, summaryOfSession(sess))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// collectMemories returns a non-nil memory slice from the configured store.
|
||
func (s *Server) collectMemories(ctx context.Context) []MemorySummary {
|
||
out := make([]MemorySummary, 0)
|
||
if s.memories == nil {
|
||
return out
|
||
}
|
||
list, err := s.memories.List(ctx)
|
||
if err != nil {
|
||
s.log.Error("list memories", "err", err)
|
||
return out
|
||
}
|
||
for _, it := range list {
|
||
out = append(out, MemorySummary{ID: it.ID, Scope: string(it.Scope), Text: it.Content})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// iotDeviceSummary is the wire shape of an IoT device.
|
||
type iotDeviceSummary struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
State map[string]any `json:"state"`
|
||
Online bool `json:"online"`
|
||
}
|