make gateway aware of config.json change

This commit is contained in:
Cytown 2026-03-05 12:47:41 +08:00
parent 41bb78f593
commit 96bedf467e
5 changed files with 186 additions and 21 deletions

View file

@ -3,12 +3,14 @@ package gateway
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"sync"
"time" "time"
"github.com/fsnotify/fsnotify"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
@ -45,6 +47,7 @@ func gatewayCmd(debug bool) error {
fmt.Println("🔍 Debug mode enabled") fmt.Println("🔍 Debug mode enabled")
} }
configPath := internal.GetConfigPath()
cfg, err := internal.LoadConfig() cfg, err := internal.LoadConfig()
if err != nil { if err != nil {
return fmt.Errorf("error loading config: %w", err) return fmt.Errorf("error loading config: %w", err)
@ -190,31 +193,167 @@ func gatewayCmd(debug bool) error {
go agentLoop.Run(ctx) go agentLoop.Run(ctx)
// Setup config file watcher for hot reload
configWatcher, configReloadChan, watchErr := setupConfigWatcher(configPath, debug)
if watchErr != nil {
logger.Errorf("⚠ Warning: Could not start config file watcher: %v", watchErr)
logger.Warn(" Config changes will require manual restart")
} else {
logger.Info("✓ Config file watcher started (auto-reload on change)")
defer configWatcher.Close()
}
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt) signal.Notify(sigChan, os.Interrupt)
<-sigChan
fmt.Println("\nShutting down...") // Main event loop - wait for signals or config changes
if cp, ok := provider.(providers.StatefulProvider); ok { for {
cp.Close() select {
case <-sigChan:
logger.Info("Shutting down...")
if cp, ok := provider.(providers.StatefulProvider); ok {
cp.Close()
}
cancel()
msgBus.Close()
// Use a fresh context with timeout for graceful shutdown,
// since the original ctx is already canceled.
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
channelManager.StopAll(shutdownCtx)
deviceService.Stop()
heartbeatService.Stop()
cronService.Stop()
mediaStore.Stop()
agentLoop.Stop()
logger.Info("✓ Gateway stopped")
return nil
case newCfg := <-configReloadChan:
logger.Info("🔄 Config file changed, reloading...")
newModel := newCfg.Agents.Defaults.ModelName
if newModel == "" {
newModel = newCfg.Agents.Defaults.Model
}
logger.Infof(" New model is '%s', recreating provider...", newModel)
if cp, ok := provider.(providers.StatefulProvider); ok {
cp.Close()
}
// Create new provider from updated config
// This will use the correct API key and settings from newCfg.ModelList
newProvider, newModelID, err := providers.CreateProvider(newCfg)
if err != nil {
logger.Errorf(" ⚠ Error creating new provider: %v", err)
logger.Warn(" Continuing with old provider")
continue
}
provider = newProvider
if newModelID != "" {
newCfg.Agents.Defaults.ModelName = newModelID
}
// Update agent loop provider and models
agentLoop.SetProvider(provider, newCfg)
logger.Info(" ✓ Provider and agents updated successfully")
// Update the config reference for other operations
// Note: Some changes (like channel configs) may require restart to take full effect
cfg = newCfg
logger.Info(" ✓ Configuration reloaded successfully")
}
} }
cancel() }
msgBus.Close()
// Use a fresh context with timeout for graceful shutdown, // setupConfigWatcher sets up a file watcher for the config file
// since the original ctx is already canceled. // Returns the watcher, a channel for config updates, and any error
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) func setupConfigWatcher(configPath string, debug bool) (*fsnotify.Watcher, chan *config.Config, error) {
defer shutdownCancel() watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, nil, err
}
channelManager.StopAll(shutdownCtx) configDir := filepath.Dir(configPath)
deviceService.Stop() if err := watcher.Add(configDir); err != nil {
heartbeatService.Stop() watcher.Close()
cronService.Stop() return nil, nil, err
mediaStore.Stop() }
agentLoop.Stop()
fmt.Println("✓ Gateway stopped")
return nil configChan := make(chan *config.Config, 1)
var mu sync.Mutex
var debounceTimer *time.Timer
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
// Only process config.json changes
if event.Name != configPath {
continue
}
// Debounce rapid file changes (some editors write multiple times)
mu.Lock()
if debounceTimer != nil {
debounceTimer.Stop()
}
debounceTimer = time.AfterFunc(500*time.Millisecond, func() {
mu.Unlock()
if debug {
logger.DebugSF(" 🔍 Config file event: %v", event)
}
// Validate and load new config
newCfg, err := config.LoadConfig(configPath)
if err != nil {
logger.Errorf(" ⚠ Error loading new config: %v", err)
logger.Warn(" Using previous valid config")
return
}
// Validate the new config
if err := newCfg.ValidateModelList(); err != nil {
logger.Errorf(" ⚠ New config validation failed: %v", err)
logger.Warn(" Using previous valid config")
return
}
logger.Info(" ✓ Config file validated and loaded")
// Send new config to main loop (non-blocking)
select {
case configChan <- newCfg:
default:
// Channel full, skip this update
logger.Warn(" ⚠ Previous config reload still in progress, skipping")
}
})
mu.Lock() // Keep lock until timer is set
mu.Unlock()
case err, ok := <-watcher.Errors:
if !ok {
return
}
logger.Errorf(" ⚠ Config watcher error: %v", err)
}
}
}()
return watcher, configChan, nil
} }
func setupCronTool( func setupCronTool(
@ -233,7 +372,7 @@ func setupCronTool(
// Create and register CronTool // Create and register CronTool
cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
if err != nil { if err != nil {
log.Fatalf("Critical error during CronTool initialization: %v", err) logger.Fatalf("Critical error during CronTool initialization: %v", err)
} }
agentLoop.RegisterTool(cronTool) agentLoop.RegisterTool(cronTool)

3
go.mod
View file

@ -8,9 +8,11 @@ require (
github.com/bwmarrin/discordgo v0.29.0 github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/chzyer/readline v1.5.1 github.com/chzyer/readline v1.5.1
github.com/fsnotify/fsnotify v1.4.9
github.com/gdamore/tcell/v2 v2.13.8 github.com/gdamore/tcell/v2 v2.13.8
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.1 github.com/mdp/qrterminal/v3 v3.2.1
github.com/modelcontextprotocol/go-sdk v1.3.0 github.com/modelcontextprotocol/go-sdk v1.3.0
@ -37,7 +39,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect
github.com/h2non/filetype v1.1.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect

1
go.sum
View file

@ -49,6 +49,7 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=

View file

@ -337,6 +337,14 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
al.channelManager = cm al.channelManager = cm
} }
// SetProvider updates the LLM provider for all agents in the registry
// and updates their model configurations.
func (al *AgentLoop) SetProvider(provider providers.LLMProvider, cfg *config.Config) {
al.cfg = cfg
registry := NewAgentRegistry(cfg, provider)
al.registry = registry
}
// SetMediaStore injects a MediaStore for media lifecycle management. // SetMediaStore injects a MediaStore for media lifecycle management.
func (al *AgentLoop) SetMediaStore(s media.MediaStore) { func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
al.mediaStore = s al.mediaStore = s

View file

@ -168,6 +168,10 @@ func DebugC(component string, message string) {
logMessage(DEBUG, component, message, nil) logMessage(DEBUG, component, message, nil)
} }
func DebugSF(message string, ss ...any) {
logMessage(DEBUG, "", fmt.Sprintf(message, ss...), nil)
}
func DebugF(message string, fields map[string]any) { func DebugF(message string, fields map[string]any) {
logMessage(DEBUG, "", message, fields) logMessage(DEBUG, "", message, fields)
} }
@ -188,6 +192,10 @@ func InfoF(message string, fields map[string]any) {
logMessage(INFO, "", message, fields) logMessage(INFO, "", message, fields)
} }
func Infof(message string, ss ...any) {
logMessage(INFO, "", fmt.Sprintf(message, ss...), nil)
}
func InfoCF(component string, message string, fields map[string]any) { func InfoCF(component string, message string, fields map[string]any) {
logMessage(INFO, component, message, fields) logMessage(INFO, component, message, fields)
} }
@ -216,6 +224,10 @@ func ErrorC(component string, message string) {
logMessage(ERROR, component, message, nil) logMessage(ERROR, component, message, nil)
} }
func Errorf(message string, ss ...any) {
logMessage(ERROR, "", fmt.Sprintf(message, ss...), nil)
}
func ErrorF(message string, fields map[string]any) { func ErrorF(message string, fields map[string]any) {
logMessage(ERROR, "", message, fields) logMessage(ERROR, "", message, fields)
} }
@ -232,6 +244,10 @@ func FatalC(component string, message string) {
logMessage(FATAL, component, message, nil) logMessage(FATAL, component, message, nil)
} }
func Fatalf(message string, ss ...any) {
logMessage(FATAL, "", fmt.Sprintf(message, ss...), nil)
}
func FatalF(message string, fields map[string]any) { func FatalF(message string, fields map[string]any) {
logMessage(FATAL, "", message, fields) logMessage(FATAL, "", message, fields)
} }