diff --git a/.env.example b/.env.example index 013465e..97d0913 100644 --- a/.env.example +++ b/.env.example @@ -52,19 +52,9 @@ THINK_INTERVAL_SEC=300 THINK_SILENCE_TIMEOUT_SEC=120 # ---- 平台桥接 ---- -# QQ (OBv11 协议) -# QQ_OBV11_WS_URL=ws://localhost:3001 -# QQ_ACCESS_TOKEN= -# QQ_BOT_QQ= - -# Telegram -# TG_BOT_TOKEN= - -# Discord -# DC_BOT_TOKEN= - -# Webhook -# WEBHOOK_LISTEN_ADDR=:9000 +# 桥接配置不在 .env 中管理。 +# 通过 Admin 面板 → 平台桥接 页面进行配置。 +# 配置将保存在 configs/bridge.json,支持热重载。 # ---- 搜索 (SearXNG) ---- # SEARXNG_URL=http://localhost:8088 diff --git a/backend/cmd/cyrene/main.go b/backend/cmd/cyrene/main.go index 3069265..d06913b 100644 --- a/backend/cmd/cyrene/main.go +++ b/backend/cmd/cyrene/main.go @@ -185,49 +185,21 @@ func main() { log.Printf("[orchestrator] pipeline ready") // === 11.5 Platform Bridge === + bridgeCfgPath := "configs/bridge.json" bridgeRouter := bridge.NewRouter() - // QQ adapter — enabled when OBv11 WebSocket URL is configured - if qqURL := os.Getenv("QQ_OBV11_WS_URL"); qqURL != "" { - qqAdapter := bridgeqq.New(bridgeqq.Config{ - Name: "qq-main", WSURL: qqURL, - AccessToken: os.Getenv("QQ_ACCESS_TOKEN"), - BotQQ: os.Getenv("QQ_BOT_QQ"), - }) - bridgeRouter.Register(qqAdapter) - } - // Telegram - if tgToken := os.Getenv("TG_BOT_TOKEN"); tgToken != "" { - bridgeRouter.Register(bridgetg.New(bridgetg.Config{Name: "tg-main", Token: tgToken})) - } - // Discord - if dcToken := os.Getenv("DC_BOT_TOKEN"); dcToken != "" { - bridgeRouter.Register(bridgedc.New(bridgedc.Config{Name: "dc-main", Token: dcToken})) - } - // Webhook - if whAddr := os.Getenv("WEBHOOK_LISTEN_ADDR"); whAddr != "" { - bridgeRouter.Register(bridgewh.New(bridgewh.Config{Name: "webhook-main", ListenAddr: whAddr})) - } - - // Route incoming messages to orchestrator - bridgeRouter.SetMessageHandler(func(msg *bridge.UnifiedMessage) { - log.Printf("[bridge] incoming %s from %s: %s", msg.Platform, msg.SenderName, msg.Content) - tokenCh, _, err := orch.ProcessInput(context.Background(), "bridge-"+msg.Source, msg.Content) - if err != nil { - log.Printf("[bridge] orchestrator error: %v", err) - return - } - var fullContent string - for token := range tokenCh { - fullContent += token.Content - } - if fullContent != "" { - bridgeRouter.SendTo(msg.Platform, msg.Source, fullContent, bridge.SendOptions{ - MaxLength: 500, MinGap: 3 * time.Second, - }) - } + bridgeStore := bridge.NewConfigStore(bridgeCfgPath, func(cfg *bridge.BridgeConfig) { + bridgeRouter.StopAll() + bridgeRouter = bridge.NewRouter() + loadAdaptersFromConfig(bridgeRouter, cfg, orch) + bridgeRouter.StartAll() }) + bridgeStore.StartWatching(5 * time.Second) - bridgeRouter.StartAll(); log.Printf("[bridge] platform bridge ready") + // Load initial adapters + loadAdaptersFromConfig(bridgeRouter, &bridge.BridgeConfig{Adapters: bridgeStore.GetAdapters()}, orch) + bridgeRouter.StartAll() + + log.Printf("[bridge] config store ready (path=%s, %d adapters)", bridgeCfgPath, bridgeStore.Count()) // === 12. Gateway === userStore := api.NewMemUserStore(cfg.AdminPassword) @@ -246,6 +218,7 @@ func main() { TraceLog: &traceAdapter{Logger: traceLogger}, PerfMon: &perfAdapter{Monitor: perfMon}, ModelConfig: api.NewModelConfigStore(), + BridgeCfg: &bridgeConfigAdapter{ConfigStore: bridgeStore}, PluginList: pluginNamesToMap(reg.ListNames()), } if memSvc != nil { @@ -340,3 +313,54 @@ func (a *perfAdapter) History() []map[string]any { } return result } + +// bridgeConfigAdapter adapts bridge.ConfigStore to api.BridgeConfigManager. +type bridgeConfigAdapter struct{ *bridge.ConfigStore } +func (a *bridgeConfigAdapter) GetAdapters() []map[string]any { + adapters := a.ConfigStore.GetAdapters() + result := make([]map[string]any, len(adapters)) + for i, ac := range adapters { + result[i] = map[string]any{ + "name": ac.Name, "type": ac.Type, "enabled": ac.Enabled, + "settings": ac.Settings, "created_at": ac.CreatedAt, "updated_at": ac.UpdatedAt, + } + } + return result +} +func (a *bridgeConfigAdapter) SaveAdapter(name, adapterType string, enabled bool, settings map[string]string) error { + return a.ConfigStore.SaveAdapter(bridge.AdapterConfig{ + Name: name, Type: adapterType, Enabled: enabled, Settings: settings, + }) +} +func (a *bridgeConfigAdapter) DeleteAdapter(name string) error { + return a.ConfigStore.DeleteAdapter(name) +} + +func loadAdaptersFromConfig(router *bridge.Router, cfg *bridge.BridgeConfig, orch *orchestrator.Pipeline) { + for _, ac := range cfg.Adapters { + if !ac.Enabled { continue } + switch ac.Type { + case "qq-obv11": + router.Register(bridgeqq.New(bridgeqq.Config{ + Name: ac.Name, WSURL: ac.Settings["ws_url"], + AccessToken: ac.Settings["access_token"], BotQQ: ac.Settings["bot_qq"], + })) + case "telegram": + router.Register(bridgetg.New(bridgetg.Config{Name: ac.Name, Token: ac.Settings["token"]})) + case "discord": + router.Register(bridgedc.New(bridgedc.Config{Name: ac.Name, Token: ac.Settings["token"]})) + case "webhook": + router.Register(bridgewh.New(bridgewh.Config{Name: ac.Name, ListenAddr: ac.Settings["listen_addr"]})) + } + } + + router.SetMessageHandler(func(msg *bridge.UnifiedMessage) { + tokenCh, _, err := orch.ProcessInput(context.Background(), "bridge-"+msg.Source, msg.Content) + if err != nil { return } + var fc string + for t := range tokenCh { fc += t.Content } + if fc != "" { + router.SendTo(msg.Platform, msg.Source, fc, bridge.SendOptions{MaxLength: 500, MinGap: 3 * time.Second}) + } + }) +} diff --git a/backend/services/gateway/api/router.go b/backend/services/gateway/api/router.go index feb0930..4fa645b 100644 --- a/backend/services/gateway/api/router.go +++ b/backend/services/gateway/api/router.go @@ -77,6 +77,13 @@ type PerfMonitor interface { History() []map[string]any } +// BridgeConfigManager manages platform adapter configurations. +type BridgeConfigManager interface { + GetAdapters() []map[string]any + SaveAdapter(name, adapterType string, enabled bool, settings map[string]string) error + DeleteAdapter(name string) error +} + // Dependencies holds all injectable services. type Dependencies struct { AuthMgr AuthManager @@ -87,6 +94,7 @@ type Dependencies struct { LLMLogger LLMCallLogger TraceLog TraceLogger PerfMon PerfMonitor + BridgeCfg BridgeConfigManager ModelConfig *ModelConfigStore PluginList []map[string]string } @@ -133,6 +141,8 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/v1/admin/models/config/", r.authMW(r.adminMW(r.modelConfigItemHandler))) r.mux.HandleFunc("/api/v1/admin/trace", r.authMW(r.adminMW(r.traceHandler))) r.mux.HandleFunc("/api/v1/admin/perf", r.authMW(r.adminMW(r.perfHandler))) + r.mux.HandleFunc("/api/v1/admin/bridge/config", r.authMW(r.adminMW(r.bridgeConfigHandler))) + r.mux.HandleFunc("/api/v1/admin/bridge/config/", r.authMW(r.adminMW(r.bridgeConfigItemHandler))) } // ServeHTTP implements http.Handler. @@ -522,6 +532,58 @@ func (r *Router) perfHandler(w http.ResponseWriter, req *http.Request) { }) } +// --- Bridge Config --- + +func (r *Router) bridgeConfigHandler(w http.ResponseWriter, req *http.Request) { + if r.deps.BridgeCfg == nil { + writeJSON(w, 503, M{"error": "bridge config unavailable"}) + return + } + switch req.Method { + case "GET": + writeJSON(w, 200, M{"adapters": r.deps.BridgeCfg.GetAdapters()}) + default: + writeJSON(w, 405, M{"error": "method not allowed"}) + } +} + +func (r *Router) bridgeConfigItemHandler(w http.ResponseWriter, req *http.Request) { + if r.deps.BridgeCfg == nil { + writeJSON(w, 503, M{"error": "bridge config unavailable"}) + return + } + name := strings.TrimPrefix(req.URL.Path, "/api/v1/admin/bridge/config/") + if name == "" { + writeJSON(w, 400, M{"error": "missing adapter name"}) + return + } + + switch req.Method { + case "PUT", "POST": + var body struct { + Type string `json:"type"` + Enabled bool `json:"enabled"` + Settings map[string]string `json:"settings"` + } + json.NewDecoder(req.Body).Decode(&body) + if err := r.deps.BridgeCfg.SaveAdapter(name, body.Type, body.Enabled, body.Settings); err != nil { + writeJSON(w, 500, M{"error": err.Error()}) + return + } + writeJSON(w, 200, M{"ok": true}) + + case "DELETE": + if err := r.deps.BridgeCfg.DeleteAdapter(name); err != nil { + writeJSON(w, 500, M{"error": err.Error()}) + return + } + writeJSON(w, 204, nil) + + default: + writeJSON(w, 405, M{"error": "method not allowed"}) + } +} + // --- Helpers --- type M = map[string]any diff --git a/backend/services/platform-bridge/config.go b/backend/services/platform-bridge/config.go new file mode 100644 index 0000000..780884e --- /dev/null +++ b/backend/services/platform-bridge/config.go @@ -0,0 +1,209 @@ +// Package bridge provides configuration management for platform adapters. +// Configs are stored as JSON on disk and loaded only when created via the admin panel. +// Supports hot-reload: the router watches the config file and reloads adapters on change. +package bridge + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sync" + "time" +) + +// AdapterConfig holds configuration for a single platform adapter. +type AdapterConfig struct { + Name string `json:"name"` + Type string `json:"type"` // "qq-obv11" | "telegram" | "discord" | "webhook" + Enabled bool `json:"enabled"` + Settings map[string]string `json:"settings"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// BridgeConfig is the top-level configuration file structure. +type BridgeConfig struct { + Version string `json:"version"` + UpdatedAt string `json:"updated_at"` + Adapters []AdapterConfig `json:"adapters"` +} + +// ConfigStore manages bridge configurations with file persistence. +type ConfigStore struct { + mu sync.RWMutex + path string + config *BridgeConfig + onChange func(*BridgeConfig) // Called when config is loaded or reloaded + stopCh chan struct{} +} + +// NewConfigStore creates a bridge config store. Path is the config file location. +// If the file doesn't exist, no config is created — it will be created on first save via admin panel. +func NewConfigStore(path string, onChange func(*BridgeConfig)) *ConfigStore { + s := &ConfigStore{ + path: path, + config: &BridgeConfig{Version: "1.0", Adapters: []AdapterConfig{}}, + onChange: onChange, + stopCh: make(chan struct{}), + } + + // Try to load existing file + if _, err := os.Stat(path); err == nil { + s.load() + if s.onChange != nil { + s.onChange(s.config) + } + } + + return s +} + +// StartWatching begins polling the config file for changes (hot-reload). +func (s *ConfigStore) StartWatching(interval time.Duration) { + var lastMod time.Time + if info, err := os.Stat(s.path); err == nil { + lastMod = info.ModTime() + } + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + info, err := os.Stat(s.path) + if err != nil { + continue + } + if info.ModTime().After(lastMod) { + lastMod = info.ModTime() + s.mu.Lock() + if s.load() { + log.Printf("[bridge] config hot-reloaded (%d adapters)", len(s.config.Adapters)) + if s.onChange != nil { + s.onChange(s.config) + } + } + s.mu.Unlock() + } + } + } + }() +} + +// Stop stops the file watcher. +func (s *ConfigStore) Stop() { close(s.stopCh) } + +// GetAdapters returns all configured adapters. +func (s *ConfigStore) GetAdapters() []AdapterConfig { + s.mu.RLock() + defer s.mu.RUnlock() + result := make([]AdapterConfig, len(s.config.Adapters)) + copy(result, s.config.Adapters) + return result +} + +// GetAdapter returns a single adapter by name. +func (s *ConfigStore) GetAdapter(name string) (*AdapterConfig, error) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, a := range s.config.Adapters { + if a.Name == name { + ac := a + return &ac, nil + } + } + return nil, fmt.Errorf("adapter %s not found", name) +} + +// SaveAdapter creates or updates an adapter config. +func (s *ConfigStore) SaveAdapter(cfg AdapterConfig) error { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().Format(time.RFC3339) + cfg.UpdatedAt = now + if cfg.CreatedAt == "" { + cfg.CreatedAt = now + } + + found := false + for i, a := range s.config.Adapters { + if a.Name == cfg.Name { + // Preserve creation time + if a.CreatedAt != "" { cfg.CreatedAt = a.CreatedAt } + s.config.Adapters[i] = cfg + found = true + break + } + } + if !found { + s.config.Adapters = append(s.config.Adapters, cfg) + } + + s.config.UpdatedAt = now + return s.persist() +} + +// DeleteAdapter removes an adapter config. +func (s *ConfigStore) DeleteAdapter(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + + filtered := make([]AdapterConfig, 0) + for _, a := range s.config.Adapters { + if a.Name != name { + filtered = append(filtered, a) + } + } + s.config.Adapters = filtered + s.config.UpdatedAt = time.Now().Format(time.RFC3339) + return s.persist() +} + +// Count returns the number of enabled adapters. +func (s *ConfigStore) Count() int { + s.mu.RLock() + defer s.mu.RUnlock() + n := 0 + for _, a := range s.config.Adapters { + if a.Enabled { n++ } + } + return n +} + +// load reads the config file from disk. Returns true if loaded successfully. +func (s *ConfigStore) load() bool { + data, err := os.ReadFile(s.path) + if err != nil { + return false + } + var cfg BridgeConfig + if err := json.Unmarshal(data, &cfg); err != nil { + log.Printf("[bridge] config parse error: %v", err) + return false + } + if cfg.Adapters == nil { + cfg.Adapters = []AdapterConfig{} + } + s.config = &cfg + return true +} + +// persist writes the current config to disk. +func (s *ConfigStore) persist() error { + data, err := json.MarshalIndent(s.config, "", " ") + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + // Ensure parent dir exists + os.MkdirAll(filepath.Dir(s.path), 0o755) + if err := os.WriteFile(s.path, data, 0o644); err != nil { + return fmt.Errorf("write config: %w", err) + } + return nil +} diff --git a/frontend/web/src/pages/admin/BridgePage.tsx b/frontend/web/src/pages/admin/BridgePage.tsx index 4380106..e3b957c 100644 --- a/frontend/web/src/pages/admin/BridgePage.tsx +++ b/frontend/web/src/pages/admin/BridgePage.tsx @@ -1,39 +1,159 @@ +import { useEffect, useState } from 'react' +import { getToken } from '../../api/client' + +const platformTypes = [ + { value: 'qq-obv11', label: 'QQ (OBv11)', fields: ['ws_url', 'access_token', 'bot_qq'] }, + { value: 'telegram', label: 'Telegram', fields: ['token'] }, + { value: 'discord', label: 'Discord', fields: ['token'] }, + { value: 'webhook', label: 'Webhook', fields: ['listen_addr'] }, +] + +const fieldLabels: Record = { + ws_url: 'WebSocket 地址', access_token: 'Access Token', bot_qq: 'Bot QQ 号', + token: 'Bot Token', listen_addr: '监听地址', +} + +interface Adapter { name:string; type:string; enabled:boolean; settings:Record; created_at:string } + export default function BridgePage() { - const adapters = [ - { name: 'qq-main', platform: 'QQ (OBv11)', status: 'ready', desc: '等待环境变量 QQ_OBV11_WS_URL 配置' }, - { name: 'tg-main', platform: 'Telegram', status: 'ready', desc: '等待环境变量 TG_BOT_TOKEN 配置' }, - { name: 'dc-main', platform: 'Discord', status: 'ready', desc: '等待环境变量 DC_BOT_TOKEN 配置' }, - { name: 'webhook-main', platform: 'Webhook', status: 'ready', desc: '等待环境变量 WEBHOOK_LISTEN_ADDR 配置' }, - ] + const [adapters, setAdapters] = useState([]) + const [editing, setEditing] = useState(false) + const [name, setName] = useState('') + const [adType, setAdType] = useState('qq-obv11') + const [settings, setSettings] = useState>({}) + + useEffect(() => { fetchAll() }, []) + + const fetchAll = async () => { + const token = getToken() + const res = await fetch('/api/v1/admin/bridge/config', { headers: { Authorization: `Bearer ${token}` } }) + const data = await res.json() + setAdapters(data.adapters || []) + } + + const save = async () => { + if (!name.trim()) return + const token = getToken() + await fetch(`/api/v1/admin/bridge/config/${name}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ type: adType, enabled: true, settings }), + }) + setEditing(false); setName(''); setSettings({}); fetchAll() + } + + const toggle = async (a: Adapter) => { + const token = getToken() + await fetch(`/api/v1/admin/bridge/config/${a.name}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ type: a.type, enabled: !a.enabled, settings: a.settings }), + }) + fetchAll() + } + + const remove = async (name: string) => { + const token = getToken() + await fetch(`/api/v1/admin/bridge/config/${name}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }) + fetchAll() + } + + const startEdit = (a?: Adapter) => { + if (a) { setName(a.name); setAdType(a.type); setSettings(a.settings || {}) } + else { setName(''); setSettings({}) } + setEditing(true) + } + + const fields = platformTypes.find(p => p.value === adType)?.fields || [] return (
-

平台桥接

-

多平台消息桥接 — QQ (OBv11) / Telegram / Discord / Webhook。通过环境变量配置启用。

+
+

平台桥接配置

+ +
+

+ 配置文件: configs/bridge.json · 支持热重载 · 仅在有适配器时创建 +

+ + {/* Adapter list */} + {adapters.length === 0 && !editing && ( +
+ 暂无平台适配器。点击「添加适配器」开始配置。 +
+ )}
{adapters.map(a => ( -
-
-
- - {a.name} +
+
+
+
+

{a.name}

+

+ {platformTypes.find(p=>p.value===a.type)?.label || a.type} + {a.created_at && ` · ${a.created_at.slice(0,10)}`} +

- {a.platform}
-

{a.desc}

+
+ + + +
))}
-
-

群聊行为规则

-
    + {/* Edit panel */} + {editing && ( +
    +

    {name ? '编辑适配器' : '新建适配器'}

    + +
    + setName(e.target.value)} placeholder="名称 (如 qq-main)" + className="flex-1 rounded-[14px] border px-3 py-2 text-sm outline-none" + style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text)' }} /> + +
    + + {fields.map(f => ( + setSettings({...settings, [f]: e.target.value})} + placeholder={fieldLabels[f] || f} + className="w-full rounded-[14px] border px-3 py-2 text-sm outline-none" + style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text)' }} /> + ))} + +
    + + +
    +
    + )} + + {/* Group chat rules */} +
    +

    群聊行为规则

    +
    • 消息最大 500 字,超出截断
    • 最短发送间隔 3 秒
    • 每轮最多 2 条消息
    • -
    • 非 @ 消息走审查模式(LLM 自主判断是否回复)
    • -
    • 闭嘴关键词:闭嘴 / 别说话 / 别说了 / 安静
    • +
    • 非 @ 消息走审查模式
    • +
    • 闭嘴关键词:闭嘴 / 别说话 / 安静