feat(bridge): separate bridge config from .env, web panel + hot-reload
- BridgeConfigStore: file-backed JSON config (configs/bridge.json) - Auto-creates config file only when user saves via admin panel - Hot-reload: watches config file every 5s, reloads adapters live - Admin API: GET/PUT/DELETE /api/v1/admin/bridge/config/:name - Frontend: full CRUD page with platform type selector + toggle - Removed bridge env vars from .env.example
This commit is contained in:
1 parent
217319eabc
commit
9eaed2883f
5 files changed
+478
-73
No files matched your search
+3
-13
@@ -52,19 +52,9 @@ THINK_INTERVAL_SEC=300
|
|||||||
THINK_SILENCE_TIMEOUT_SEC=120
|
THINK_SILENCE_TIMEOUT_SEC=120
|
||||||
|
|
||||||
# ---- 平台桥接 ----
|
# ---- 平台桥接 ----
|
||||||
# QQ (OBv11 协议)
|
# 桥接配置不在 .env 中管理。
|
||||||
# QQ_OBV11_WS_URL=ws://localhost:3001
|
# 通过 Admin 面板 → 平台桥接 页面进行配置。
|
||||||
# QQ_ACCESS_TOKEN=
|
# 配置将保存在 configs/bridge.json,支持热重载。
|
||||||
# QQ_BOT_QQ=
|
|
||||||
|
|
||||||
# Telegram
|
|
||||||
# TG_BOT_TOKEN=
|
|
||||||
|
|
||||||
# Discord
|
|
||||||
# DC_BOT_TOKEN=
|
|
||||||
|
|
||||||
# Webhook
|
|
||||||
# WEBHOOK_LISTEN_ADDR=:9000
|
|
||||||
|
|
||||||
# ---- 搜索 (SearXNG) ----
|
# ---- 搜索 (SearXNG) ----
|
||||||
# SEARXNG_URL=http://localhost:8088
|
# SEARXNG_URL=http://localhost:8088
|
||||||
|
|||||||
+64
-40
@@ -185,49 +185,21 @@ func main() {
|
|||||||
log.Printf("[orchestrator] pipeline ready")
|
log.Printf("[orchestrator] pipeline ready")
|
||||||
|
|
||||||
// === 11.5 Platform Bridge ===
|
// === 11.5 Platform Bridge ===
|
||||||
|
bridgeCfgPath := "configs/bridge.json"
|
||||||
bridgeRouter := bridge.NewRouter()
|
bridgeRouter := bridge.NewRouter()
|
||||||
// QQ adapter — enabled when OBv11 WebSocket URL is configured
|
bridgeStore := bridge.NewConfigStore(bridgeCfgPath, func(cfg *bridge.BridgeConfig) {
|
||||||
if qqURL := os.Getenv("QQ_OBV11_WS_URL"); qqURL != "" {
|
bridgeRouter.StopAll()
|
||||||
qqAdapter := bridgeqq.New(bridgeqq.Config{
|
bridgeRouter = bridge.NewRouter()
|
||||||
Name: "qq-main", WSURL: qqURL,
|
loadAdaptersFromConfig(bridgeRouter, cfg, orch)
|
||||||
AccessToken: os.Getenv("QQ_ACCESS_TOKEN"),
|
bridgeRouter.StartAll()
|
||||||
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.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 ===
|
// === 12. Gateway ===
|
||||||
userStore := api.NewMemUserStore(cfg.AdminPassword)
|
userStore := api.NewMemUserStore(cfg.AdminPassword)
|
||||||
@@ -246,6 +218,7 @@ func main() {
|
|||||||
TraceLog: &traceAdapter{Logger: traceLogger},
|
TraceLog: &traceAdapter{Logger: traceLogger},
|
||||||
PerfMon: &perfAdapter{Monitor: perfMon},
|
PerfMon: &perfAdapter{Monitor: perfMon},
|
||||||
ModelConfig: api.NewModelConfigStore(),
|
ModelConfig: api.NewModelConfigStore(),
|
||||||
|
BridgeCfg: &bridgeConfigAdapter{ConfigStore: bridgeStore},
|
||||||
PluginList: pluginNamesToMap(reg.ListNames()),
|
PluginList: pluginNamesToMap(reg.ListNames()),
|
||||||
}
|
}
|
||||||
if memSvc != nil {
|
if memSvc != nil {
|
||||||
@@ -340,3 +313,54 @@ func (a *perfAdapter) History() []map[string]any {
|
|||||||
}
|
}
|
||||||
return result
|
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})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -77,6 +77,13 @@ type PerfMonitor interface {
|
|||||||
History() []map[string]any
|
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.
|
// Dependencies holds all injectable services.
|
||||||
type Dependencies struct {
|
type Dependencies struct {
|
||||||
AuthMgr AuthManager
|
AuthMgr AuthManager
|
||||||
@@ -87,6 +94,7 @@ type Dependencies struct {
|
|||||||
LLMLogger LLMCallLogger
|
LLMLogger LLMCallLogger
|
||||||
TraceLog TraceLogger
|
TraceLog TraceLogger
|
||||||
PerfMon PerfMonitor
|
PerfMon PerfMonitor
|
||||||
|
BridgeCfg BridgeConfigManager
|
||||||
ModelConfig *ModelConfigStore
|
ModelConfig *ModelConfigStore
|
||||||
PluginList []map[string]string
|
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/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/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/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.
|
// 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 ---
|
// --- Helpers ---
|
||||||
|
|
||||||
type M = map[string]any
|
type M = map[string]any
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<string,string>; created_at:string }
|
||||||
|
|
||||||
export default function BridgePage() {
|
export default function BridgePage() {
|
||||||
const adapters = [
|
const [adapters, setAdapters] = useState<Adapter[]>([])
|
||||||
{ name: 'qq-main', platform: 'QQ (OBv11)', status: 'ready', desc: '等待环境变量 QQ_OBV11_WS_URL 配置' },
|
const [editing, setEditing] = useState(false)
|
||||||
{ name: 'tg-main', platform: 'Telegram', status: 'ready', desc: '等待环境变量 TG_BOT_TOKEN 配置' },
|
const [name, setName] = useState('')
|
||||||
{ name: 'dc-main', platform: 'Discord', status: 'ready', desc: '等待环境变量 DC_BOT_TOKEN 配置' },
|
const [adType, setAdType] = useState('qq-obv11')
|
||||||
{ name: 'webhook-main', platform: 'Webhook', status: 'ready', desc: '等待环境变量 WEBHOOK_LISTEN_ADDR 配置' },
|
const [settings, setSettings] = useState<Record<string,string>>({})
|
||||||
]
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<h2 className="text-lg font-semibold">平台桥接</h2>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-[var(--mx-text-dim)]">多平台消息桥接 — QQ (OBv11) / Telegram / Discord / Webhook。通过环境变量配置启用。</p>
|
<h2 className="text-lg font-semibold">平台桥接配置</h2>
|
||||||
|
<button onClick={() => startEdit()}
|
||||||
|
className="px-4 py-2 rounded-full text-sm font-medium text-white transition-colors"
|
||||||
|
style={{ background: 'var(--mx-primary)' }}
|
||||||
|
>+ 添加适配器</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--mx-text-dim)' }}>
|
||||||
|
配置文件: configs/bridge.json · 支持热重载 · 仅在有适配器时创建
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Adapter list */}
|
||||||
|
{adapters.length === 0 && !editing && (
|
||||||
|
<div className="rounded-[18px] border p-8 text-center text-sm" style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text-dim)' }}>
|
||||||
|
暂无平台适配器。点击「添加适配器」开始配置。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{adapters.map(a => (
|
{adapters.map(a => (
|
||||||
<div key={a.name} className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3">
|
<div key={a.name}
|
||||||
<div className="flex items-center justify-between mb-1">
|
className="rounded-[18px] border px-4 py-3 flex items-center justify-between"
|
||||||
<div className="flex items-center gap-3">
|
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-[#F2F2F2]/30" />
|
>
|
||||||
<span className="text-sm text-[var(--mx-text)]">{a.name}</span>
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-2.5 h-2.5 rounded-full ${a.enabled ? '' : ''}`}
|
||||||
|
style={{ background: a.enabled ? 'var(--mx-tertiary-text)' : 'var(--mx-outline)' }} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--mx-text)' }}>{a.name}</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--mx-text-dim)' }}>
|
||||||
|
{platformTypes.find(p=>p.value===a.type)?.label || a.type}
|
||||||
|
{a.created_at && ` · ${a.created_at.slice(0,10)}`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--mx-badge-blue)]/50 text-[var(--mx-tertiary-text)]">{a.platform}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[var(--mx-text-dim)] pl-7">{a.desc}</p>
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => toggle(a)}
|
||||||
|
className={`w-11 h-6 rounded-full relative transition-colors`}
|
||||||
|
style={{ background: a.enabled ? 'var(--mx-primary)' : 'var(--mx-outline)' }}
|
||||||
|
>
|
||||||
|
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform ${a.enabled ? 'translate-x-5.5' : 'translate-x-0.5'}`} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => startEdit(a)} className="text-xs px-2 py-1 rounded-full" style={{ color: 'var(--mx-text-dim)' }}>编辑</button>
|
||||||
|
<button onClick={() => remove(a.name)} className="text-xs px-2 py-1 rounded-full" style={{ color: 'var(--mx-error)' }}>删除</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] p-4 mt-4">
|
{/* Edit panel */}
|
||||||
<h3 className="text-sm font-medium text-[var(--mx-text)] mb-2">群聊行为规则</h3>
|
{editing && (
|
||||||
<ul className="text-xs text-[var(--mx-text-dim)] space-y-1 list-disc list-inside">
|
<div className="rounded-[18px] border p-4 space-y-3" style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}>
|
||||||
|
<h3 className="text-sm font-medium" style={{ color: 'var(--mx-text)' }}>{name ? '编辑适配器' : '新建适配器'}</h3>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input value={name} onChange={e => 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)' }} />
|
||||||
|
<select value={adType} onChange={e => setAdType(e.target.value)}
|
||||||
|
className="rounded-[14px] border px-3 py-2 text-sm outline-none"
|
||||||
|
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text)' }}>
|
||||||
|
{platformTypes.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.map(f => (
|
||||||
|
<input key={f} value={settings[f] || ''} onChange={e => 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)' }} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={save} className="px-4 py-2 rounded-full text-sm font-medium text-white" style={{ background: 'var(--mx-primary)' }}>保存</button>
|
||||||
|
<button onClick={() => setEditing(false)} className="px-4 py-2 rounded-full text-sm" style={{ border: '1px solid var(--mx-outline)', color: 'var(--mx-text-dim)' }}>取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Group chat rules */}
|
||||||
|
<div className="rounded-[18px] border p-4 mt-4" style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}>
|
||||||
|
<h3 className="text-sm font-medium mb-2" style={{ color: 'var(--mx-text)' }}>群聊行为规则</h3>
|
||||||
|
<ul className="text-xs space-y-1 list-disc list-inside" style={{ color: 'var(--mx-text-dim)' }}>
|
||||||
<li>消息最大 500 字,超出截断</li>
|
<li>消息最大 500 字,超出截断</li>
|
||||||
<li>最短发送间隔 3 秒</li>
|
<li>最短发送间隔 3 秒</li>
|
||||||
<li>每轮最多 2 条消息</li>
|
<li>每轮最多 2 条消息</li>
|
||||||
<li>非 @ 消息走审查模式(LLM 自主判断是否回复)</li>
|
<li>非 @ 消息走审查模式</li>
|
||||||
<li>闭嘴关键词:闭嘴 / 别说话 / 别说了 / 安静</li>
|
<li>闭嘴关键词:闭嘴 / 别说话 / 安静</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user