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:
Aska committed 2026-08-12 17:55:15 +08:00
1 parent 217319eabc
commit 9eaed2883f
5 files changed
+478 -73

No files matched your search

+64 -40
View File
@@ -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})
}
})
}
+62
View File
@@ -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
+209
View File
@@ -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
}