diff --git a/Dockerfile b/Dockerfile index 563d0d3c..c3b991a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,6 +60,7 @@ RUN set -eux; \ else \ if [ "$ENABLE_PYTHON" = "true" ]; then \ apk add --no-cache python3 py3-pip; \ + pip3 install --no-cache-dir --break-system-packages edge-tts; \ fi; \ if [ "$ENABLE_NODE" = "true" ]; then \ apk add --no-cache nodejs npm; \ diff --git a/cmd/gateway.go b/cmd/gateway.go index b7c4ffbf..87f3f231 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -79,7 +79,7 @@ func runGateway() { // Detect server IPs for output scrubbing (prevents IP leaks via web_fetch, exec, etc.) tools.DetectServerIPs(context.Background()) - toolsReg, execApprovalMgr, mcpMgr, sandboxMgr, browserMgr, webFetchTool, permPE, toolPE, dataDir, agentCfg := setupToolRegistry(cfg, workspace, providerRegistry) + toolsReg, execApprovalMgr, mcpMgr, sandboxMgr, browserMgr, webFetchTool, ttsTool, permPE, toolPE, dataDir, agentCfg := setupToolRegistry(cfg, workspace, providerRegistry) if browserMgr != nil { defer browserMgr.Close() } @@ -854,6 +854,23 @@ func runGateway() { webFetchTool.UpdatePolicy(updatedCfg.Tools.WebFetch.Policy, updatedCfg.Tools.WebFetch.AllowedDomains, updatedCfg.Tools.WebFetch.BlockedDomains) }) + // Reload TTS providers on config changes via pub/sub. + msgBus.Subscribe("tts-config-reload", func(evt bus.Event) { + if evt.Name != bus.TopicConfigChanged { + return + } + updatedCfg, ok := evt.Payload.(*config.Config) + if !ok { + return + } + newMgr := setupTTS(updatedCfg) + if newMgr == nil { + return + } + ttsTool.UpdateManager(newMgr) + slog.Info("tts config reloaded", "provider", newMgr.PrimaryProvider(), "auto", string(newMgr.AutoMode())) + }) + // Contact collector: auto-collect user info from channels with in-memory dedup cache. var contactCollector *store.ContactCollector if pgStores.Contacts != nil { diff --git a/cmd/gateway_agents.go b/cmd/gateway_agents.go index 6e518c55..05654f72 100644 --- a/cmd/gateway_agents.go +++ b/cmd/gateway_agents.go @@ -161,7 +161,8 @@ func setupSubagents(providerReg *providers.Registry, cfg *config.Config, msgBus } // setupTTS creates the TTS manager from config and registers providers. -// Returns nil if no TTS provider has an API key configured. +// Edge TTS is always registered (free, no API key required). +// Always returns a non-nil manager with at least one provider. func setupTTS(cfg *config.Config) *tts.Manager { ttsCfg := cfg.Tts @@ -194,13 +195,12 @@ func setupTTS(cfg *config.Config) *tts.Manager { })) } - if ttsCfg.Edge.Enabled { - mgr.RegisterProvider(tts.NewEdgeProvider(tts.EdgeConfig{ - Voice: ttsCfg.Edge.Voice, - Rate: ttsCfg.Edge.Rate, - TimeoutMs: ttsCfg.TimeoutMs, - })) - } + // Edge TTS is free (no API key) — always register so it's available as primary or fallback. + mgr.RegisterProvider(tts.NewEdgeProvider(tts.EdgeConfig{ + Voice: ttsCfg.Edge.Voice, + Rate: ttsCfg.Edge.Rate, + TimeoutMs: ttsCfg.TimeoutMs, + })) if key := ttsCfg.MiniMax.APIKey; key != "" { mgr.RegisterProvider(tts.NewMiniMaxProvider(tts.MiniMaxConfig{ diff --git a/cmd/gateway_setup.go b/cmd/gateway_setup.go index 76b270e0..d6a32bcc 100644 --- a/cmd/gateway_setup.go +++ b/cmd/gateway_setup.go @@ -21,14 +21,15 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store/pg" "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/internal/tracing" + "github.com/nextlevelbuilder/goclaw/internal/tts" "github.com/nextlevelbuilder/goclaw/pkg/browser" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // setupToolRegistry creates the tool registry and registers all tools. // Returns the registry, exec approval manager, MCP manager, sandbox manager, -// browser manager (caller must defer Close), web fetch tool, permission policy engine, -// tool policy engine, data directory, and resolved agent defaults. +// browser manager (caller must defer Close), web fetch tool, TTS tool, +// permission policy engine, tool policy engine, data directory, and resolved agent defaults. func setupToolRegistry( cfg *config.Config, workspace string, @@ -40,6 +41,7 @@ func setupToolRegistry( sandboxMgr sandbox.Manager, browserMgr *browser.Manager, webFetchTool *tools.WebFetchTool, + ttsTool *tools.TtsTool, permPE *permissions.PolicyEngine, toolPE *tools.PolicyEngine, dataDir string, @@ -124,10 +126,14 @@ func setupToolRegistry( toolsReg.Register(tools.NewCreateAudioTool(providerRegistry, cfg.Tts.ElevenLabs.APIKey, cfg.Tts.ElevenLabs.BaseURL)) - // TTS (text-to-speech) system + // TTS (text-to-speech) system — always create TtsTool so config reload can populate it later ttsMgr := setupTTS(cfg) - if ttsMgr != nil { - toolsReg.Register(tools.NewTtsTool(ttsMgr)) + if ttsMgr == nil { + ttsMgr = tts.NewManager(tts.ManagerConfig{}) + } + ttsTool = tools.NewTtsTool(ttsMgr) + toolsReg.Register(ttsTool) + if ttsMgr.HasProviders() { slog.Info("tts enabled", "provider", ttsMgr.PrimaryProvider(), "auto", string(ttsMgr.AutoMode())) } diff --git a/docker-compose.yml b/docker-compose.yml index 1d11eafe..daa58c1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,8 +19,9 @@ services: context: . dockerfile: Dockerfile args: - ENABLE_OTEL: "false" - ENABLE_PYTHON: "true" + ENABLE_OTEL: "${ENABLE_OTEL:-false}" + ENABLE_PYTHON: "${ENABLE_PYTHON:-true}" + ENABLE_FULL_SKILLS: "${ENABLE_FULL_SKILLS:-false}" ports: - "${GOCLAW_PORT:-18790}:18790" env_file: diff --git a/internal/channels/dispatch.go b/internal/channels/dispatch.go index 2e8af931..4d2030bb 100644 --- a/internal/channels/dispatch.go +++ b/internal/channels/dispatch.go @@ -48,6 +48,26 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { continue } + // Filter out temp media files that no longer exist (already sent by another dispatch). + if len(msg.Media) > 0 { + tmpDir := os.TempDir() + filtered := msg.Media[:0] + for _, media := range msg.Media { + if media.URL != "" && strings.HasPrefix(media.URL, tmpDir) { + if _, err := os.Stat(media.URL); err != nil { + slog.Debug("skipping already-delivered temp media", "path", media.URL) + continue + } + } + filtered = append(filtered, media) + } + msg.Media = filtered + // If only media was in this message and all files are gone, skip entirely. + if len(msg.Media) == 0 && msg.Content == "" { + continue + } + } + if err := channel.Send(ctx, msg); err != nil { slog.Error("error sending message to channel", "channel", msg.Channel, diff --git a/internal/tools/tts.go b/internal/tools/tts.go index 44bbcd67..8669c903 100644 --- a/internal/tools/tts.go +++ b/internal/tools/tts.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "sync" "time" "github.com/nextlevelbuilder/goclaw/internal/tts" @@ -15,6 +16,7 @@ import ( // Implements Tool + ContextualTool interfaces. // Per-call channel is read from ctx for thread-safety. type TtsTool struct { + mu sync.RWMutex manager *tts.Manager } @@ -23,6 +25,13 @@ func NewTtsTool(mgr *tts.Manager) *TtsTool { return &TtsTool{manager: mgr} } +// UpdateManager swaps the underlying TTS manager (used on config reload). +func (t *TtsTool) UpdateManager(mgr *tts.Manager) { + t.mu.Lock() + defer t.mu.Unlock() + t.manager = mgr +} + func (t *TtsTool) Name() string { return "tts" } func (t *TtsTool) Description() string { @@ -62,6 +71,11 @@ func (t *TtsTool) Execute(ctx context.Context, args map[string]any) *Result { voice, _ := args["voice"].(string) providerName, _ := args["provider"].(string) + // Snapshot manager pointer under read lock so config reloads don't race. + t.mu.RLock() + mgr := t.manager + t.mu.RUnlock() + // Determine format based on channel (read from ctx — thread-safe) channel := ToolChannelFromCtx(ctx) opts := tts.Options{Voice: voice} @@ -74,13 +88,13 @@ func (t *TtsTool) Execute(ctx context.Context, args map[string]any) *Result { if providerName != "" { // Use specific provider - p, ok := t.manager.GetProvider(providerName) + p, ok := mgr.GetProvider(providerName) if !ok { return &Result{ForLLM: fmt.Sprintf("error: tts provider not found: %s", providerName), IsError: true} } result, err = p.Synthesize(ctx, text, opts) } else { - result, err = t.manager.SynthesizeWithFallback(ctx, text, opts) + result, err = mgr.SynthesizeWithFallback(ctx, text, opts) } if err != nil { diff --git a/ui/web/src/components/chat/message-bubble.tsx b/ui/web/src/components/chat/message-bubble.tsx index 3632ab4b..c22a3d98 100644 --- a/ui/web/src/components/chat/message-bubble.tsx +++ b/ui/web/src/components/chat/message-bubble.tsx @@ -40,7 +40,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {