From 2cc9d68cdcaf255a84d166f90d536bab9e23748d Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Thu, 19 Mar 2026 08:21:06 +0700 Subject: [PATCH] fix(tts): config save, Edge provider, media dispatch + dark mode chat (#265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tts): config save + Edge provider registration + dark mode chat bubbles - Wrap TTS config payload in `raw` field for config.patch RPC (#229) - Always register Edge TTS provider (free, no API key) instead of gating on `enabled` flag - Fix low-contrast user message bubbles in dark mode chat * fix(tts): skip duplicate media dispatch when temp file already delivered When both the agent loop and the message tool dispatch the same TTS temp file, the first dispatch succeeds and cleanup deletes it. Filter out missing temp media files before sending to prevent "file not found" errors and spurious error notifications on Telegram/Slack/Discord. * feat(tts): include edge-tts in Docker image when Python enabled Edge TTS is free (no API key) and serves as a universal TTS fallback. Install it alongside Python in both ENABLE_PYTHON and ENABLE_FULL_SKILLS builds. * chore(docker): expose build args from .env for compose builds Pass ENABLE_OTEL, ENABLE_PYTHON, ENABLE_FULL_SKILLS as env-driven build args so .env can control Docker build features without editing docker-compose.yml directly. * fix(tts): hot-reload TTS config on settings change via pub/sub TTS providers were only registered at startup, so changing provider/API key via the Web UI had no effect until container restart. Add a tts-config-reload bus subscriber that rebuilds the TTS manager on config changes, matching the pattern used by quota, cron, and web_fetch. Always create a TtsTool at startup (even without providers) so the reload subscriber can populate it when settings are first configured. * fix(tts): protect TtsTool.UpdateManager with RWMutex to prevent data race UpdateManager() can be called from the config reload goroutine while Execute() reads t.manager concurrently from agent goroutines. Add sync.RWMutex following the same pattern as WebFetchTool.UpdatePolicy(). Also update setupTTS doc comment which incorrectly stated it could return nil — Edge TTS is now always registered. --------- Co-authored-by: viettranx --- Dockerfile | 1 + cmd/gateway.go | 19 +++++++++++++++++- cmd/gateway_agents.go | 16 +++++++-------- cmd/gateway_setup.go | 16 ++++++++++----- docker-compose.yml | 5 +++-- internal/channels/dispatch.go | 20 +++++++++++++++++++ internal/tools/tts.go | 18 +++++++++++++++-- ui/web/src/components/chat/message-bubble.tsx | 2 +- ui/web/src/pages/tts/hooks/use-tts-config.ts | 2 +- 9 files changed, 79 insertions(+), 20 deletions(-) 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) {
diff --git a/ui/web/src/pages/tts/hooks/use-tts-config.ts b/ui/web/src/pages/tts/hooks/use-tts-config.ts index 6c532ea4..66020793 100644 --- a/ui/web/src/pages/tts/hooks/use-tts-config.ts +++ b/ui/web/src/pages/tts/hooks/use-tts-config.ts @@ -69,7 +69,7 @@ export function useTtsConfig() { setSaving(true); setError(null); try { - await ws.call(Methods.CONFIG_PATCH, { tts: updates }); + await ws.call(Methods.CONFIG_PATCH, { raw: JSON.stringify({ tts: updates }) }); await invalidate(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to save TTS config";