mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 18:18:37 +00:00
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:
1 parent
01014954bd
commit
284c9cb2cc
16 files changed
+655
-52
No files matched your search
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user