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
This commit is contained in:
viettranx committed 2026-04-16 21:26:45 +07:00
1 parent 01014954bd
commit 284c9cb2cc
16 files changed
+655 -52

No files matched your search

+18 -7
View File
@@ -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())
}
+11
View File
@@ -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)
+4
View File
@@ -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 }
@@ -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" }
+5
View File
@@ -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
+62
View File
@@ -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
}
+24
View File
@@ -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 }
+40 -4
View File
@@ -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
+8 -5
View File
@@ -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.
+19 -2
View File
@@ -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)
}
+11 -5
View File
@@ -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",
},
+4 -1
View File
@@ -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,
@@ -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
}
+100 -20
View File
@@ -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
}
+323
View File
@@ -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
}
}
+21 -8
View File
@@ -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<string, unknown> }>(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.