From 284c9cb2cc7c677102a5901e1f6919d87df32ed4 Mon Sep 17 00:00:00 2001 From: viettranx Date: Thu, 16 Apr 2026 21:26:45 +0700 Subject: [PATCH] feat(tts): per-tenant TTS config and per-agent voice override - Add per-tenant TTS configuration endpoints (GET/POST /v1/tts/config) with RoleAdmin auth instead of master scope - Implement TenantTTSResolver for channels to use tenant-specific TTS providers and auto mode settings - Add per-agent voice override in channel TTS auto-apply: - Extend OutboundMessage with AgentID and AgentOtherConfig - Inject AgentAudioSnapshot in dispatch.go from outbound message - MaybeApply reads tts_voice_id/tts_model_id from agent context - Fix events.go to use RunContext.TenantID directly (H3) - Fix dispatch.go error notification to use sendCtx (H1) - Update UI to use new /v1/tts/config endpoints --- cmd/gateway_consumer_normal.go | 25 +- cmd/gateway_http_wiring.go | 11 + internal/agent/loop_tracing.go | 4 + .../agent/router_cache_canonicalize_test.go | 3 + internal/agent/types.go | 5 + internal/audio/auto_apply_helper.go | 62 ++++ internal/audio/manager.go | 24 ++ internal/audio/manager_auto.go | 44 ++- internal/bus/types.go | 13 +- internal/channels/dispatch.go | 21 +- internal/channels/events.go | 16 +- internal/channels/runs.go | 5 +- .../methods/agent_resolve_cached_test.go | 2 + internal/http/tts.go | 120 +++++-- internal/http/tts_config.go | 323 ++++++++++++++++++ ui/web/src/pages/tts/hooks/use-tts-config.ts | 29 +- 16 files changed, 655 insertions(+), 52 deletions(-) create mode 100644 internal/audio/auto_apply_helper.go create mode 100644 internal/http/tts_config.go diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go index 422d2904..6b93c55e 100644 --- a/cmd/gateway_consumer_normal.go +++ b/cmd/gateway_consumer_normal.go @@ -233,7 +233,7 @@ func processNormalMessage( blockReply := deps.ChannelMgr != nil && deps.ChannelMgr.ResolveBlockReply(msg.Channel, deps.Cfg.Gateway.BlockReply) toolStatus := deps.Cfg.Gateway.ToolStatus == nil || *deps.Cfg.Gateway.ToolStatus // default true if deps.ChannelMgr != nil { - deps.ChannelMgr.RegisterRun(runID, msg.Channel, chatIDForRun, messageID, outMeta, enableStream, blockReply, toolStatus) + deps.ChannelMgr.RegisterRun(runID, msg.Channel, chatIDForRun, messageID, outMeta, msg.TenantID, enableStream, blockReply, toolStatus) } // Group-aware system prompt: help the LLM adapt tone and behavior for group chats. @@ -396,7 +396,7 @@ func processNormalMessage( }) // Handle result asynchronously to not block the flush callback. - go func(agentKey, channel, chatID, session, rID, peerKind, inboundContent string, meta map[string]string, blockReplyEnabled bool, ptd *tools.PendingTeamDispatch) { + go func(agentKey, channel, chatID, session, rID, peerKind, inboundContent string, meta map[string]string, blockReplyEnabled bool, ptd *tools.PendingTeamDispatch, tenantID, agentUUID uuid.UUID, agentOtherConfig []byte) { outcome := <-outCh // Release team create lock — tasks already visible in DB, other goroutines can list. @@ -426,6 +426,8 @@ func processNormalMessage( ChatID: chatID, Content: "", Metadata: meta, + TenantID: tenantID, + AgentID: agentUUID, }) return } @@ -444,6 +446,8 @@ func processNormalMessage( ChatID: chatID, Content: errContent, Metadata: meta, + TenantID: tenantID, + AgentID: agentUUID, }) return } @@ -461,6 +465,8 @@ func processNormalMessage( ChatID: chatID, Content: "", Metadata: meta, + TenantID: tenantID, + AgentID: agentUUID, }) return } @@ -476,6 +482,8 @@ func processNormalMessage( ChatID: chatID, Content: "", Metadata: meta, + TenantID: tenantID, + AgentID: agentUUID, }) return } @@ -491,10 +499,13 @@ func processNormalMessage( // Publish response back to the channel outMsg := bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: replyContent, - Metadata: meta, + Channel: channel, + ChatID: chatID, + Content: replyContent, + Metadata: meta, + TenantID: tenantID, + AgentID: agentUUID, + AgentOtherConfig: agentOtherConfig, } appendMediaToOutbound(&outMsg, outcome.Result.Media) @@ -505,5 +516,5 @@ func processNormalMessage( if deps.TeamStore != nil && channel != tools.ChannelSystem && channel != tools.ChannelTeammate && channel != tools.ChannelDashboard { go autoSetFollowup(ctx, deps.TeamStore, deps.AgentStore, agentKey, channel, chatID, replyContent) } - }(agentID, msg.Channel, msg.ChatID, sessionKey, runID, peerKind, msg.Content, outMeta, blockReply, ptd) + }(agentID, msg.Channel, msg.ChatID, sessionKey, runID, peerKind, msg.Content, outMeta, blockReply, ptd, msg.TenantID, agentLoop.UUID(), agentLoop.OtherConfig()) } diff --git a/cmd/gateway_http_wiring.go b/cmd/gateway_http_wiring.go index bab60b1d..8ab1d117 100644 --- a/cmd/gateway_http_wiring.go +++ b/cmd/gateway_http_wiring.go @@ -254,10 +254,21 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer( if rl := d.server.RateLimiter(); rl != nil && rl.Enabled() { ttsH.SetRateLimiter(rl.Allow) } + // Wire stores for per-tenant TTS config lookup at synthesis time. + if d.pgStores.SystemConfigs != nil && d.pgStores.ConfigSecrets != nil { + ttsH.SetStores(d.pgStores.SystemConfigs, d.pgStores.ConfigSecrets) + // Wire tenant resolver for channels TTS auto-apply + d.audioMgr.SetTenantResolver(httpapi.NewTenantTTSResolver(d.pgStores.SystemConfigs, d.pgStores.ConfigSecrets)) + } d.server.SetTTSHandler(ttsH) d.ttsHandler = ttsH // store for hot-reload } + // Per-tenant TTS config endpoint — allows tenant admins to configure TTS. + if d.pgStores.SystemConfigs != nil && d.pgStores.ConfigSecrets != nil { + d.server.SetTTSConfigHandler(httpapi.NewTTSConfigHandler(d.pgStores.SystemConfigs, d.pgStores.ConfigSecrets)) + } + // Seed + apply builtin tool disables if d.pgStores.BuiltinTools != nil { seedBuiltinTools(context.Background(), d.pgStores.BuiltinTools) diff --git a/internal/agent/loop_tracing.go b/internal/agent/loop_tracing.go index cd5f5cc6..b21c1817 100644 --- a/internal/agent/loop_tracing.go +++ b/internal/agent/loop_tracing.go @@ -32,6 +32,10 @@ func (l *Loop) ID() string { return l.id } // See docs/agent-identity-conventions.md. func (l *Loop) UUID() uuid.UUID { return l.agentUUID } +// OtherConfig returns the agent's other_config JSONB (extensibility bag). +// Used for per-agent TTS voice override (tts_voice_id, tts_model_id). +func (l *Loop) OtherConfig() json.RawMessage { return l.agentOtherConfig } + // Model returns the model identifier for this agent loop. func (l *Loop) Model() string { return l.model } diff --git a/internal/agent/router_cache_canonicalize_test.go b/internal/agent/router_cache_canonicalize_test.go index ed26c431..2e8e96f7 100644 --- a/internal/agent/router_cache_canonicalize_test.go +++ b/internal/agent/router_cache_canonicalize_test.go @@ -2,6 +2,7 @@ package agent import ( "context" + "encoding/json" "strings" "sync/atomic" "testing" @@ -21,6 +22,8 @@ type stubAgent struct { } func (s *stubAgent) ID() string { return s.id } +func (s *stubAgent) UUID() uuid.UUID { return uuid.Nil } +func (s *stubAgent) OtherConfig() json.RawMessage { return nil } func (s *stubAgent) Run(context.Context, RunRequest) (*RunResult, error) { return nil, nil } func (s *stubAgent) IsRunning() bool { return s.running } func (s *stubAgent) Model() string { return "test-model" } diff --git a/internal/agent/types.go b/internal/agent/types.go index e39ca186..c46d99ac 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -2,6 +2,9 @@ package agent import ( "context" + "encoding/json" + + "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/providers" ) @@ -10,6 +13,8 @@ import ( // Implemented by *Loop; extracted as an interface for testability and composability. type Agent interface { ID() string + UUID() uuid.UUID + OtherConfig() json.RawMessage Run(ctx context.Context, req RunRequest) (*RunResult, error) IsRunning() bool Model() string diff --git a/internal/audio/auto_apply_helper.go b/internal/audio/auto_apply_helper.go new file mode 100644 index 00000000..a5dd48b6 --- /dev/null +++ b/internal/audio/auto_apply_helper.go @@ -0,0 +1,62 @@ +package audio + +import ( + "context" + "os" + "path/filepath" + "time" +) + +// AutoApplyResult holds the result of TTS auto-apply processing. +type AutoApplyResult struct { + // Text is the message content with [[tts]] directives stripped. + Text string + // AudioPath is the path to generated audio file (empty if no TTS applied). + AudioPath string + // AudioMime is the MIME type of generated audio (e.g. "audio/ogg"). + AudioMime string +} + +// AutoApplyToText checks if TTS should be auto-applied to the message content. +// Returns modified text (directives stripped) and audio path if TTS was applied. +// channel: "telegram", "discord", etc. - used for format selection (opus for telegram). +// isVoiceInbound: true if user sent voice message (for "inbound" auto mode). +// workspace: directory to save generated audio files. +func (m *Manager) AutoApplyToText( + ctx context.Context, + content string, + channel string, + isVoiceInbound bool, + workspace string, +) (*AutoApplyResult, error) { + if m == nil || content == "" { + return &AutoApplyResult{Text: content}, nil + } + + // Check if TTS should be applied (respects auto mode: off/always/inbound/tagged) + result, ok := m.MaybeApply(ctx, content, channel, isVoiceInbound, "final") + if !ok || result == nil { + return &AutoApplyResult{Text: StripTTSDirectives(content)}, nil + } + + // Write audio to workspace/tts/ directory + ttsDir := workspace + if ttsDir == "" { + ttsDir = os.TempDir() + } + ttsDir = filepath.Join(ttsDir, "tts") + if err := os.MkdirAll(ttsDir, 0755); err != nil { + return &AutoApplyResult{Text: StripTTSDirectives(content)}, err + } + + audioPath := filepath.Join(ttsDir, "auto-"+time.Now().Format("20060102-150405")+"."+result.Extension) + if err := os.WriteFile(audioPath, result.Audio, 0644); err != nil { + return &AutoApplyResult{Text: StripTTSDirectives(content)}, err + } + + return &AutoApplyResult{ + Text: StripTTSDirectives(content), + AudioPath: audioPath, + AudioMime: result.MimeType, + }, nil +} diff --git a/internal/audio/manager.go b/internal/audio/manager.go index e5c1ac3f..48f69327 100644 --- a/internal/audio/manager.go +++ b/internal/audio/manager.go @@ -23,6 +23,11 @@ func channelFromCtx(ctx context.Context) string { return v } +// TenantTTSResolver resolves per-tenant TTS provider and config. +// Returns (provider, providerName, autoMode, error). If error is non-nil, +// caller should fall back to global manager config. +type TenantTTSResolver func(ctx context.Context) (TTSProvider, string, AutoMode, error) + // Manager orchestrates audio providers across TTS, STT, Music, and SFX // operations. Each op has its own provider map + primary/fallback chain. // @@ -43,6 +48,8 @@ type Manager struct { mode Mode maxLength int // max text length before truncation (default 1500) timeoutMs int // provider timeout (default 30000) + + tenantResolver TenantTTSResolver // per-tenant TTS config resolver (nil = use global) } // ManagerConfig configures the audio manager. Preserved from legacy TTS @@ -126,6 +133,23 @@ func (m *Manager) GetProvider(name string) (TTSProvider, bool) { // PrimaryProvider returns the primary TTS provider name. func (m *Manager) PrimaryProvider() string { return m.primary } +// SetTenantResolver sets the per-tenant TTS config resolver. +// Channels use this to resolve tenant-specific TTS providers. +func (m *Manager) SetTenantResolver(r TenantTTSResolver) { m.tenantResolver = r } + +// ResolveTenantProvider attempts to get tenant-specific TTS provider. +// Returns (provider, name, autoMode, ok). If ok=false, caller uses global config. +func (m *Manager) ResolveTenantProvider(ctx context.Context) (TTSProvider, string, AutoMode, bool) { + if m.tenantResolver == nil { + return nil, "", "", false + } + p, name, auto, err := m.tenantResolver(ctx) + if err != nil { + return nil, "", "", false + } + return p, name, auto, true +} + // AutoMode returns the current auto-apply mode. func (m *Manager) AutoMode() AutoMode { return m.auto } diff --git a/internal/audio/manager_auto.go b/internal/audio/manager_auto.go index 19d6ed7b..81f80e2d 100644 --- a/internal/audio/manager_auto.go +++ b/internal/audio/manager_auto.go @@ -2,8 +2,11 @@ package audio import ( "context" + "encoding/json" "log/slog" "strings" + + "github.com/nextlevelbuilder/goclaw/internal/store" ) // MaybeApply inspects auto-mode and conditionally applies TTS to a reply. @@ -16,7 +19,15 @@ import ( // - isVoiceInbound: whether the user's inbound message was voice // - kind: "tool", "block", or "final" func (m *Manager) MaybeApply(ctx context.Context, text, channel string, isVoiceInbound bool, kind string) (*SynthResult, bool) { - if m.auto == AutoOff { + // Try tenant-specific TTS config first + tenantProvider, _, tenantAuto, hasTenant := m.ResolveTenantProvider(ctx) + + auto := m.auto + if hasTenant && tenantAuto != "" { + auto = tenantAuto + } + + if auto == AutoOff { return nil, false } @@ -25,7 +36,7 @@ func (m *Manager) MaybeApply(ctx context.Context, text, channel string, isVoiceI return nil, false } - switch m.auto { + switch auto { case AutoInbound: if !isVoiceInbound { return nil, false @@ -42,7 +53,7 @@ func (m *Manager) MaybeApply(ctx context.Context, text, channel string, isVoiceI // Content validation (matches legacy TTS behavior). cleanText := stripMarkdown(text) - cleanText = stripTtsDirectives(cleanText) + cleanText = StripTTSDirectives(cleanText) cleanText = strings.TrimSpace(cleanText) if len(cleanText) < 10 { @@ -61,7 +72,32 @@ func (m *Manager) MaybeApply(ctx context.Context, text, channel string, isVoiceI opts.Format = "opus" // Telegram voice bubbles need opus } - result, err := m.SynthesizeWithFallback(ctx, cleanText, opts) + // Apply per-agent voice/model override from context (set by dispatch.go from OutboundMessage) + if snap, ok := store.AgentAudioFromCtx(ctx); ok && len(snap.OtherConfig) > 0 { + var agentCfg struct { + TTSVoiceID string `json:"tts_voice_id,omitempty"` + TTSModelID string `json:"tts_model_id,omitempty"` + } + if err := json.Unmarshal(snap.OtherConfig, &agentCfg); err == nil { + if agentCfg.TTSVoiceID != "" { + opts.Voice = agentCfg.TTSVoiceID + } + if agentCfg.TTSModelID != "" { + opts.Model = agentCfg.TTSModelID + } + } + } + + var result *SynthResult + var err error + + // Use tenant provider if available, otherwise fall back to global + if hasTenant && tenantProvider != nil { + result, err = tenantProvider.Synthesize(ctx, cleanText, opts) + } else { + result, err = m.SynthesizeWithFallback(ctx, cleanText, opts) + } + if err != nil { slog.Warn("tts auto-apply failed", "error", err) return nil, false diff --git a/internal/bus/types.go b/internal/bus/types.go index b6a820bf..4eec4dd4 100644 --- a/internal/bus/types.go +++ b/internal/bus/types.go @@ -35,11 +35,14 @@ type InboundMessage struct { // OutboundMessage represents a message to be sent to a channel. type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - Media []MediaAttachment `json:"media,omitempty"` // optional media attachments - Metadata map[string]string `json:"metadata,omitempty"` // channel-specific metadata + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + Media []MediaAttachment `json:"media,omitempty"` // optional media attachments + Metadata map[string]string `json:"metadata,omitempty"` // channel-specific metadata + TenantID uuid.UUID `json:"tenant_id,omitempty"` // tenant scope for per-tenant TTS + AgentID uuid.UUID `json:"agent_id,omitempty"` // agent scope for per-agent TTS voice override + AgentOtherConfig []byte `json:"agent_other_config,omitempty"` // agent's other_config for TTS voice/model } // MediaAttachment represents a media file to be sent with a message. diff --git a/internal/channels/dispatch.go b/internal/channels/dispatch.go index 73d6c1f6..bba84abe 100644 --- a/internal/channels/dispatch.go +++ b/internal/channels/dispatch.go @@ -9,7 +9,9 @@ import ( "regexp" "strings" + "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // WebhookRoute holds a path and handler pair for mounting on the main gateway mux. @@ -68,7 +70,21 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { } } - if err := channel.Send(ctx, msg); err != nil { + // Add tenant context for per-tenant TTS auto-apply + sendCtx := ctx + if msg.TenantID != uuid.Nil { + sendCtx = store.WithTenantID(ctx, msg.TenantID) + } + + // Add agent audio context for per-agent TTS voice override + if msg.AgentID != uuid.Nil && len(msg.AgentOtherConfig) > 0 { + sendCtx = store.WithAgentAudio(sendCtx, store.AgentAudioSnapshot{ + AgentID: msg.AgentID, + OtherConfig: msg.AgentOtherConfig, + }) + } + + if err := channel.Send(sendCtx, msg); err != nil { slog.Error("error sending message to channel", "channel", msg.Channel, "chat_id", msg.ChatID, @@ -85,8 +101,9 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { ChatID: msg.ChatID, Content: formatChannelSendError(err), Metadata: sendErrorMeta(msg.Metadata), + TenantID: msg.TenantID, } - if err2 := channel.Send(ctx, notifyMsg); err2 != nil { + if err2 := channel.Send(sendCtx, notifyMsg); err2 != nil { slog.Warn("failed to send error notification", "channel", msg.Channel, "error", err2) } diff --git a/internal/channels/events.go b/internal/channels/events.go index dac8bab1..5f5fe005 100644 --- a/internal/channels/events.go +++ b/internal/channels/events.go @@ -31,8 +31,11 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) { } ctx := context.Background() - if ta, ok := ch.(interface{ TenantID() uuid.UUID }); ok { - ctx = store.WithTenantID(ctx, ta.TenantID()) + // Use RunContext's TenantID directly (set at RegisterRun time from channel instance) + // rather than querying the channel interface - more direct and future-proof for + // channels that might serve multiple tenants. + if rc.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, rc.TenantID) } // Forward to StreamingChannel (only when streaming is enabled for this run). @@ -111,6 +114,7 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) { ChatID: rc.ChatID, Content: statusText, Metadata: outMeta, + TenantID: rc.TenantID, }) } case protocol.ChatEventChunk: @@ -283,6 +287,7 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) { ChatID: rc.ChatID, Content: content, Metadata: outMeta, + TenantID: rc.TenantID, }) return } @@ -293,9 +298,10 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) { maxAttempts := extractPayloadString(payload, "maxAttempts") retryMsg := fmt.Sprintf("Provider busy, retrying... (%s/%s)", attempt, maxAttempts) m.bus.PublishOutbound(bus.OutboundMessage{ - Channel: rc.ChannelName, - ChatID: rc.ChatID, - Content: retryMsg, + Channel: rc.ChannelName, + ChatID: rc.ChatID, + Content: retryMsg, + TenantID: rc.TenantID, Metadata: map[string]string{ "placeholder_update": "true", }, diff --git a/internal/channels/runs.go b/internal/channels/runs.go index 855b6938..3c5a0163 100644 --- a/internal/channels/runs.go +++ b/internal/channels/runs.go @@ -1,15 +1,18 @@ package channels +import "github.com/google/uuid" + // --- Run tracking for streaming/reaction event forwarding --- // RegisterRun associates a run ID with a channel context so agent events // (chunks, tool calls, completion) can be forwarded to the originating channel. -func (m *Manager) RegisterRun(runID, channelName, chatID, messageID string, metadata map[string]string, streaming, blockReply, toolStatus bool) { +func (m *Manager) RegisterRun(runID, channelName, chatID, messageID string, metadata map[string]string, tenantID uuid.UUID, streaming, blockReply, toolStatus bool) { m.runs.Store(runID, &RunContext{ ChannelName: channelName, ChatID: chatID, MessageID: messageID, Metadata: metadata, + TenantID: tenantID, Streaming: streaming, BlockReplyEnabled: blockReply, ToolStatusEnabled: toolStatus, diff --git a/internal/gateway/methods/agent_resolve_cached_test.go b/internal/gateway/methods/agent_resolve_cached_test.go index 70a92c3c..d6490059 100644 --- a/internal/gateway/methods/agent_resolve_cached_test.go +++ b/internal/gateway/methods/agent_resolve_cached_test.go @@ -2,6 +2,7 @@ package methods import ( "context" + "encoding/json" "errors" "testing" @@ -86,6 +87,7 @@ type cacheHitStubAgent struct { func (s *cacheHitStubAgent) ID() string { return s.id } func (s *cacheHitStubAgent) UUID() uuid.UUID { return s.uid } +func (s *cacheHitStubAgent) OtherConfig() json.RawMessage { return nil } func (s *cacheHitStubAgent) Run(context.Context, agent.RunRequest) (*agent.RunResult, error) { return nil, nil } diff --git a/internal/http/tts.go b/internal/http/tts.go index 75b874df..e97e4c11 100644 --- a/internal/http/tts.go +++ b/internal/http/tts.go @@ -21,9 +21,11 @@ import ( // TTSHandler handles POST /v1/tts/synthesize — converts text to audio via a // configured TTS provider and returns raw audio bytes with the appropriate MIME type. type TTSHandler struct { - mu sync.RWMutex - manager *audio.Manager - rateLimiter func(string) bool // per-IP/token rate limit check (nil = no limit) + mu sync.RWMutex + manager *audio.Manager + rateLimiter func(string) bool // per-IP/token rate limit check (nil = no limit) + systemConfigs store.SystemConfigStore // per-tenant TTS settings + configSecrets store.ConfigSecretsStore // per-tenant TTS secrets } // NewTTSHandler creates a TTSHandler backed by the given audio.Manager. @@ -34,6 +36,12 @@ func NewTTSHandler(mgr *audio.Manager) *TTSHandler { // SetRateLimiter injects the rate limiter function (reused from the server's global limiter). func (h *TTSHandler) SetRateLimiter(fn func(string) bool) { h.rateLimiter = fn } +// SetStores injects stores for per-tenant TTS config lookup. +func (h *TTSHandler) SetStores(sc store.SystemConfigStore, cs store.ConfigSecretsStore) { + h.systemConfigs = sc + h.configSecrets = cs +} + // UpdateManager swaps the underlying manager (hot-reload safe). func (h *TTSHandler) UpdateManager(mgr *audio.Manager) { if mgr == nil { @@ -103,24 +111,28 @@ func (h *TTSHandler) handleSynthesize(w http.ResponseWriter, r *http.Request) { return } - // Resolve provider — explicit name or fall back to manager's primary. - // Copy manager reference under read lock to allow hot-reload. - h.mu.RLock() - mgr := h.manager - h.mu.RUnlock() + // Try per-tenant TTS config first, fall back to global manager. + p, name, err := h.resolveTenantProvider(ctx, req.Provider) + if err != nil { + // Tenant config lookup failed, try global manager + h.mu.RLock() + mgr := h.manager + h.mu.RUnlock() - name := req.Provider - if name == "" { - name = mgr.PrimaryProvider() - } - if name == "" { - http.Error(w, `{"error":"no tts provider configured"}`, http.StatusNotFound) - return - } - p, ok := mgr.GetProvider(name) - if !ok { - http.Error(w, fmt.Sprintf(`{"error":%q}`, "provider not found: "+name), http.StatusNotFound) - return + name = req.Provider + if name == "" { + name = mgr.PrimaryProvider() + } + if name == "" { + http.Error(w, `{"error":"no tts provider configured"}`, http.StatusNotFound) + return + } + var ok bool + p, ok = mgr.GetProvider(name) + if !ok { + http.Error(w, fmt.Sprintf(`{"error":%q}`, "provider not found: "+name), http.StatusNotFound) + return + } } // ElevenLabs model validation — rejects unknown model IDs with allowlist error. @@ -157,3 +169,71 @@ func (h *TTSHandler) handleSynthesize(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write(result.Audio) } + +// resolveTenantProvider attempts to create a TTS provider from tenant-specific config. +// Returns (nil, "", error) if tenant has no TTS config — caller should fall back to global manager. +func (h *TTSHandler) resolveTenantProvider(ctx context.Context, explicitProvider string) (audio.TTSProvider, string, error) { + if h.systemConfigs == nil || h.configSecrets == nil { + return nil, "", fmt.Errorf("stores not configured") + } + + // Get tenant's configured provider + providerName := explicitProvider + if providerName == "" { + var err error + providerName, err = h.systemConfigs.Get(ctx, "tts.provider") + if err != nil || providerName == "" { + return nil, "", fmt.Errorf("no tenant tts provider") + } + } + + // Build ephemeral provider from tenant config + req := testConnectionRequest{Provider: providerName} + + switch providerName { + case "openai": + if key, _ := h.configSecrets.Get(ctx, "tts.openai.api_key"); key != "" { + req.APIKey = key + } else { + return nil, "", fmt.Errorf("no api key") + } + req.APIBase, _ = h.systemConfigs.Get(ctx, "tts.openai.api_base") + req.VoiceID, _ = h.systemConfigs.Get(ctx, "tts.openai.voice") + req.ModelID, _ = h.systemConfigs.Get(ctx, "tts.openai.model") + + case "elevenlabs": + if key, _ := h.configSecrets.Get(ctx, "tts.elevenlabs.api_key"); key != "" { + req.APIKey = key + } else { + return nil, "", fmt.Errorf("no api key") + } + req.APIBase, _ = h.systemConfigs.Get(ctx, "tts.elevenlabs.api_base") + req.VoiceID, _ = h.systemConfigs.Get(ctx, "tts.elevenlabs.voice") + req.ModelID, _ = h.systemConfigs.Get(ctx, "tts.elevenlabs.model") + + case "minimax": + if key, _ := h.configSecrets.Get(ctx, "tts.minimax.api_key"); key != "" { + req.APIKey = key + } else { + return nil, "", fmt.Errorf("no api key") + } + req.GroupID, _ = h.configSecrets.Get(ctx, "tts.minimax.group_id") + req.APIBase, _ = h.systemConfigs.Get(ctx, "tts.minimax.api_base") + req.VoiceID, _ = h.systemConfigs.Get(ctx, "tts.minimax.voice") + req.ModelID, _ = h.systemConfigs.Get(ctx, "tts.minimax.model") + + case "edge": + req.VoiceID, _ = h.systemConfigs.Get(ctx, "tts.edge.voice") + + default: + return nil, "", fmt.Errorf("unsupported provider: %s", providerName) + } + + provider, err := createEphemeralTTSProvider(req) + if err != nil { + return nil, "", err + } + + slog.Debug("tts: using tenant provider", "provider", providerName, "tenant", store.TenantIDFromContext(ctx)) + return provider, providerName, nil +} diff --git a/internal/http/tts_config.go b/internal/http/tts_config.go new file mode 100644 index 00000000..3ff77f47 --- /dev/null +++ b/internal/http/tts_config.go @@ -0,0 +1,323 @@ +package http + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/audio" + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// TTSConfigHandler handles per-tenant TTS configuration. +// Unlike config.patch (master-scope), this allows tenant admins to configure TTS. +type TTSConfigHandler struct { + systemConfigs store.SystemConfigStore + configSecrets store.ConfigSecretsStore +} + +// NewTTSConfigHandler creates a handler for per-tenant TTS config. +func NewTTSConfigHandler(sc store.SystemConfigStore, cs store.ConfigSecretsStore) *TTSConfigHandler { + return &TTSConfigHandler{systemConfigs: sc, configSecrets: cs} +} + +// RegisterRoutes wires TTS config endpoints onto mux with RoleAdmin auth. +func (h *TTSConfigHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/tts/config", requireAuth(permissions.RoleAdmin, h.handleGet)) + mux.HandleFunc("POST /v1/tts/config", requireAuth(permissions.RoleAdmin, h.handleSave)) +} + +// ttsConfigResponse is the response for GET /v1/tts/config. +type ttsConfigResponse struct { + Provider string `json:"provider"` + Auto string `json:"auto"` + Mode string `json:"mode"` + MaxLength int `json:"max_length"` + OpenAI ttsProviderConfigResponse `json:"openai"` + ElevenLabs ttsProviderConfigResponse `json:"elevenlabs"` + Edge ttsProviderConfigResponse `json:"edge"` + MiniMax ttsProviderConfigResponse `json:"minimax"` +} + +type ttsProviderConfigResponse struct { + APIKey string `json:"api_key,omitempty"` // masked + APIBase string `json:"api_base,omitempty"` + Voice string `json:"voice,omitempty"` + Model string `json:"model,omitempty"` + GroupID string `json:"group_id,omitempty"` +} + +// handleGet returns TTS config for the current tenant. +func (h *TTSConfigHandler) handleGet(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + http.Error(w, `{"error":"tenant context required"}`, http.StatusBadRequest) + return + } + + resp := ttsConfigResponse{ + Auto: "off", + Mode: "final", + MaxLength: 1500, + } + + // Load from system_configs + if h.systemConfigs != nil { + if v, _ := h.systemConfigs.Get(ctx, "tts.provider"); v != "" { + resp.Provider = v + } + if v, _ := h.systemConfigs.Get(ctx, "tts.auto"); v != "" { + resp.Auto = v + } + if v, _ := h.systemConfigs.Get(ctx, "tts.mode"); v != "" { + resp.Mode = v + } + if v, _ := h.systemConfigs.Get(ctx, "tts.max_length"); v != "" { + var ml int + if json.Unmarshal([]byte(v), &ml) == nil && ml > 0 { + resp.MaxLength = ml + } + } + // Provider-specific non-secrets + if v, _ := h.systemConfigs.Get(ctx, "tts.openai.api_base"); v != "" { + resp.OpenAI.APIBase = v + } + if v, _ := h.systemConfigs.Get(ctx, "tts.openai.voice"); v != "" { + resp.OpenAI.Voice = v + } + if v, _ := h.systemConfigs.Get(ctx, "tts.openai.model"); v != "" { + resp.OpenAI.Model = v + } + if v, _ := h.systemConfigs.Get(ctx, "tts.elevenlabs.api_base"); v != "" { + resp.ElevenLabs.APIBase = v + } + if v, _ := h.systemConfigs.Get(ctx, "tts.elevenlabs.voice"); v != "" { + resp.ElevenLabs.Voice = v + } + if v, _ := h.systemConfigs.Get(ctx, "tts.elevenlabs.model"); v != "" { + resp.ElevenLabs.Model = v + } + if v, _ := h.systemConfigs.Get(ctx, "tts.minimax.api_base"); v != "" { + resp.MiniMax.APIBase = v + } + if v, _ := h.systemConfigs.Get(ctx, "tts.minimax.voice"); v != "" { + resp.MiniMax.Voice = v + } + if v, _ := h.systemConfigs.Get(ctx, "tts.minimax.model"); v != "" { + resp.MiniMax.Model = v + } + } + + // Load secrets (masked) + if h.configSecrets != nil { + if v, _ := h.configSecrets.Get(ctx, "tts.openai.api_key"); v != "" { + resp.OpenAI.APIKey = "***" + } + if v, _ := h.configSecrets.Get(ctx, "tts.elevenlabs.api_key"); v != "" { + resp.ElevenLabs.APIKey = "***" + } + if v, _ := h.configSecrets.Get(ctx, "tts.minimax.api_key"); v != "" { + resp.MiniMax.APIKey = "***" + } + if v, _ := h.configSecrets.Get(ctx, "tts.minimax.group_id"); v != "" { + resp.MiniMax.GroupID = v // not a secret, but stored with secrets for grouping + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// ttsConfigSaveRequest is the request body for POST /v1/tts/config. +type ttsConfigSaveRequest struct { + Provider string `json:"provider"` + Auto string `json:"auto"` + Mode string `json:"mode"` + MaxLength int `json:"max_length"` + OpenAI *ttsProviderSaveRequest `json:"openai,omitempty"` + ElevenLabs *ttsProviderSaveRequest `json:"elevenlabs,omitempty"` + MiniMax *ttsProviderSaveRequest `json:"minimax,omitempty"` +} + +type ttsProviderSaveRequest struct { + APIKey string `json:"api_key,omitempty"` + APIBase string `json:"api_base,omitempty"` + Voice string `json:"voice,omitempty"` + Model string `json:"model,omitempty"` + GroupID string `json:"group_id,omitempty"` +} + +// handleSave saves TTS config for the current tenant. +func (h *TTSConfigHandler) handleSave(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + http.Error(w, `{"error":"tenant context required"}`, http.StatusBadRequest) + return + } + + var req ttsConfigSaveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf(`{"error":"invalid json: %s"}`, err.Error()), http.StatusBadRequest) + return + } + + // Save to system_configs (non-secrets) + if h.systemConfigs != nil { + if req.Provider != "" { + h.systemConfigs.Set(ctx, "tts.provider", req.Provider) + } + if req.Auto != "" { + h.systemConfigs.Set(ctx, "tts.auto", req.Auto) + } + if req.Mode != "" { + h.systemConfigs.Set(ctx, "tts.mode", req.Mode) + } + if req.MaxLength > 0 { + h.systemConfigs.Set(ctx, "tts.max_length", fmt.Sprintf("%d", req.MaxLength)) + } + + // Provider-specific non-secrets + if req.OpenAI != nil { + if req.OpenAI.APIBase != "" { + h.systemConfigs.Set(ctx, "tts.openai.api_base", req.OpenAI.APIBase) + } + if req.OpenAI.Voice != "" { + h.systemConfigs.Set(ctx, "tts.openai.voice", req.OpenAI.Voice) + } + if req.OpenAI.Model != "" { + h.systemConfigs.Set(ctx, "tts.openai.model", req.OpenAI.Model) + } + } + if req.ElevenLabs != nil { + if req.ElevenLabs.APIBase != "" { + h.systemConfigs.Set(ctx, "tts.elevenlabs.api_base", req.ElevenLabs.APIBase) + } + if req.ElevenLabs.Voice != "" { + h.systemConfigs.Set(ctx, "tts.elevenlabs.voice", req.ElevenLabs.Voice) + } + if req.ElevenLabs.Model != "" { + h.systemConfigs.Set(ctx, "tts.elevenlabs.model", req.ElevenLabs.Model) + } + } + if req.MiniMax != nil { + if req.MiniMax.APIBase != "" { + h.systemConfigs.Set(ctx, "tts.minimax.api_base", req.MiniMax.APIBase) + } + if req.MiniMax.Voice != "" { + h.systemConfigs.Set(ctx, "tts.minimax.voice", req.MiniMax.Voice) + } + if req.MiniMax.Model != "" { + h.systemConfigs.Set(ctx, "tts.minimax.model", req.MiniMax.Model) + } + } + } + + // Save secrets (only if not masked) + if h.configSecrets != nil { + if req.OpenAI != nil && req.OpenAI.APIKey != "" && req.OpenAI.APIKey != "***" { + if err := h.configSecrets.Set(ctx, "tts.openai.api_key", req.OpenAI.APIKey); err != nil { + slog.Warn("tts.config: failed to save openai api_key", "error", err) + } + } + if req.ElevenLabs != nil && req.ElevenLabs.APIKey != "" && req.ElevenLabs.APIKey != "***" { + if err := h.configSecrets.Set(ctx, "tts.elevenlabs.api_key", req.ElevenLabs.APIKey); err != nil { + slog.Warn("tts.config: failed to save elevenlabs api_key", "error", err) + } + } + if req.MiniMax != nil { + if req.MiniMax.APIKey != "" && req.MiniMax.APIKey != "***" { + if err := h.configSecrets.Set(ctx, "tts.minimax.api_key", req.MiniMax.APIKey); err != nil { + slog.Warn("tts.config: failed to save minimax api_key", "error", err) + } + } + if req.MiniMax.GroupID != "" { + if err := h.configSecrets.Set(ctx, "tts.minimax.group_id", req.MiniMax.GroupID); err != nil { + slog.Warn("tts.config: failed to save minimax group_id", "error", err) + } + } + } + } + + slog.Info("tts.config: saved", "tenant", tid, "provider", req.Provider) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"ok": true}) +} + +// NewTenantTTSResolver creates a resolver for per-tenant TTS providers. +// Used by audio.Manager for channels TTS auto-apply. +func NewTenantTTSResolver(sc store.SystemConfigStore, cs store.ConfigSecretsStore) audio.TenantTTSResolver { + return func(ctx context.Context) (audio.TTSProvider, string, audio.AutoMode, error) { + if sc == nil || cs == nil { + return nil, "", "", fmt.Errorf("stores not configured") + } + + // Get tenant's configured provider + providerName, err := sc.Get(ctx, "tts.provider") + if err != nil || providerName == "" { + return nil, "", "", fmt.Errorf("no tenant tts provider") + } + + // Get auto mode + autoStr, _ := sc.Get(ctx, "tts.auto") + auto := audio.AutoMode(autoStr) + if auto == "" { + auto = audio.AutoOff + } + + // Build ephemeral provider from tenant config + req := testConnectionRequest{Provider: providerName} + + switch providerName { + case "openai": + if key, _ := cs.Get(ctx, "tts.openai.api_key"); key != "" { + req.APIKey = key + } else { + return nil, "", "", fmt.Errorf("no api key") + } + req.APIBase, _ = sc.Get(ctx, "tts.openai.api_base") + req.VoiceID, _ = sc.Get(ctx, "tts.openai.voice") + req.ModelID, _ = sc.Get(ctx, "tts.openai.model") + + case "elevenlabs": + if key, _ := cs.Get(ctx, "tts.elevenlabs.api_key"); key != "" { + req.APIKey = key + } else { + return nil, "", "", fmt.Errorf("no api key") + } + req.APIBase, _ = sc.Get(ctx, "tts.elevenlabs.api_base") + req.VoiceID, _ = sc.Get(ctx, "tts.elevenlabs.voice") + req.ModelID, _ = sc.Get(ctx, "tts.elevenlabs.model") + + case "minimax": + if key, _ := cs.Get(ctx, "tts.minimax.api_key"); key != "" { + req.APIKey = key + } else { + return nil, "", "", fmt.Errorf("no api key") + } + req.GroupID, _ = cs.Get(ctx, "tts.minimax.group_id") + req.APIBase, _ = sc.Get(ctx, "tts.minimax.api_base") + req.VoiceID, _ = sc.Get(ctx, "tts.minimax.voice") + req.ModelID, _ = sc.Get(ctx, "tts.minimax.model") + + case "edge": + req.VoiceID, _ = sc.Get(ctx, "tts.edge.voice") + + default: + return nil, "", "", fmt.Errorf("unsupported provider: %s", providerName) + } + + provider, err := createEphemeralTTSProvider(req) + if err != nil { + return nil, "", "", err + } + + return provider, providerName, auto, nil + } +} 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 1bb4d53b..db68baa5 100644 --- a/ui/web/src/pages/tts/hooks/use-tts-config.ts +++ b/ui/web/src/pages/tts/hooks/use-tts-config.ts @@ -1,8 +1,7 @@ import { useState, useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useWs, useHttp } from "@/hooks/use-ws"; +import { useHttp } from "@/hooks/use-ws"; import { useAuthStore } from "@/stores/use-auth-store"; -import { Methods } from "@/api/protocol"; import { queryKeys } from "@/lib/query-keys"; import { toast } from "@/stores/use-toast-store"; import i18next from "i18next"; @@ -69,7 +68,6 @@ export interface TestConnectionResult { } export function useTtsConfig() { - const ws = useWs(); const http = useHttp(); const connected = useAuthStore((s) => s.connected); const queryClient = useQueryClient(); @@ -79,9 +77,15 @@ export function useTtsConfig() { const { data: tts = DEFAULT_TTS, isPending: loading } = useQuery({ queryKey: queryKeys.tts.all, queryFn: async () => { - const res = await ws.call<{ config: Record }>(Methods.CONFIG_GET); - const ttsConfig = (res.config?.tts as TtsConfig) ?? DEFAULT_TTS; - return { ...DEFAULT_TTS, ...ttsConfig }; + // Use per-tenant TTS config endpoint instead of global config.get + const res = await fetch("/v1/tts/config", { + headers: http.getAuthHeaders(), + }); + if (!res.ok) { + throw new Error(`Failed to load TTS config (${res.status})`); + } + const data = await res.json(); + return { ...DEFAULT_TTS, ...data } as TtsConfig; }, staleTime: 60_000, enabled: connected, @@ -97,7 +101,16 @@ export function useTtsConfig() { setSaving(true); setError(null); try { - await ws.call(Methods.CONFIG_PATCH, { raw: JSON.stringify({ tts: updates }) }); + // Use per-tenant TTS config endpoint instead of config.patch (master-scope) + const res = await fetch("/v1/tts/config", { + method: "POST", + headers: { "Content-Type": "application/json", ...http.getAuthHeaders() }, + body: JSON.stringify(updates), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `Failed to save TTS config (${res.status})`); + } await invalidate(); toast.success(i18next.t("config:toast.saved")); } catch (err) { @@ -109,7 +122,7 @@ export function useTtsConfig() { setSaving(false); } }, - [ws, invalidate], + [http, invalidate], ); // POST→Blob not in HttpClient; use fetch + getAuthHeaders() for tenant/user header parity.