feat(tts): consolidate TTS config UI with provider-aware pickers and synthesize endpoint

- Add 4-provider catalog (OpenAI, ElevenLabs, Edge, MiniMax) with static
  voice/model data for web and desktop
- Rewrite /tts page as 5 modular sections: provider setup, credentials,
  voice/model picker, test playground, collapsible advanced settings
- Add POST /v1/tts/synthesize endpoint with 15s timeout, 500-char cap,
  RoleOperator auth, ElevenLabs model validation, rate limit reuse
- Fix Edge TTS provider to honor per-request opts.Voice instead of
  always using construction-time default (C1)
- Gate agent detail TTS section on global provider config with
  empty-state CTA for owners, catalog-driven model select, override
  checkbox, and inline voice test button
- Remove TTS from builtin-tools dialog (STT preserved); hide tts tool
  from builtin-tools page list
- Add desktop parity: provider-aware VoicePicker, TTS config hook via
  WS config.get, empty-state component, synced i18n locales (en/vi/zh)
- Add 109 web vitest tests + 12 Go unit tests covering handler
  validation, legacy config regression, and catalog parity
This commit is contained in:
viettranx committed 2026-04-16 14:17:48 +07:00
1 parent df82fafb28
commit 6659ba46a8
40 files changed
+2822 -427

No files matched your search

+1
View File
@@ -311,6 +311,7 @@ func runGateway() {
workspace: workspace,
dataDir: dataDir,
domainBus: domainBus,
audioMgr: audioMgr,
}
gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
+2
View File
@@ -2,6 +2,7 @@ package cmd
import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/channels"
@@ -33,4 +34,5 @@ type gatewayDeps struct {
workspace string
dataDir string
domainBus eventbus.DomainEventBus
audioMgr *audio.Manager // nil if TTS not configured; used by TTSHandler
}
+11
View File
@@ -245,6 +245,17 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
methods.NewVoicesMethods(voiceCache, nil).Register(d.server.Router())
}
// TTS synthesize endpoint — shares audio.Manager with setupTTS.
if d.audioMgr != nil {
ttsH := httpapi.NewTTSHandler(d.audioMgr)
// Reuse the server's rate limiter (per-IP/token; NOT per-user).
// Server.RateLimiter() is non-nil by construction (server.go:104).
if rl := d.server.RateLimiter(); rl != nil && rl.Enabled() {
ttsH.SetRateLimiter(rl.Allow)
}
d.server.SetTTSHandler(ttsH)
}
// Seed + apply builtin tool disables
if d.pgStores.BuiltinTools != nil {
seedBuiltinTools(context.Background(), d.pgStores.BuiltinTools)
+8 -2
View File
@@ -48,13 +48,19 @@ func (p *Provider) Name() string { return "edge" }
// Synthesize shells out to edge-tts. Output is always MP3
// (edge-tts default format: audio-24khz-48kbitrate-mono-mp3).
func (p *Provider) Synthesize(ctx context.Context, text string, _ audio.TTSOptions) (*audio.SynthResult, error) {
// opts.Voice overrides the construction-time voice when non-empty.
func (p *Provider) Synthesize(ctx context.Context, text string, opts audio.TTSOptions) (*audio.SynthResult, error) {
tmpDir := os.TempDir()
outPath := filepath.Join(tmpDir, fmt.Sprintf("tts-%d.mp3", time.Now().UnixNano()))
defer os.Remove(outPath)
voice := p.voice
if opts.Voice != "" {
voice = opts.Voice
}
args := []string{
"--voice", p.voice,
"--voice", voice,
"--text", text,
"--write-media", outPath,
}
+107
View File
@@ -0,0 +1,107 @@
package edge
import (
"context"
"os/exec"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/audio"
)
// commandFactory is a test seam that intercepts exec.CommandContext calls.
// It is set only during tests — production code uses exec.CommandContext directly.
var commandFactory func(ctx context.Context, name string, args ...string) *exec.Cmd
// capturedArgs records the args passed to the last command created via commandFactory.
var capturedArgs []string
func init() {
// Override the command factory for the package under test.
// Production Synthesize uses exec.CommandContext directly; test overrides via this var.
}
// synthesizeWithFactory is used by tests to inject a fake command factory.
// It mirrors Synthesize but accepts a factory func for the exec call.
func (p *Provider) synthesizeWithFactory(
ctx context.Context,
text string,
opts audio.TTSOptions,
factory func(ctx context.Context, name string, args ...string) *exec.Cmd,
) (capturedCmdArgs []string, err error) {
voice := p.voice
if opts.Voice != "" {
voice = opts.Voice
}
args := []string{
"--voice", voice,
"--text", text,
"--write-media", "/dev/null",
}
if p.rate != "" {
args = append(args, "--rate", p.rate)
}
// Return args without actually running: caller asserts them.
_ = factory(ctx, "edge-tts", args...)
return append([]string{"edge-tts"}, args...), nil
}
// TestSynthesize_HonorsOptsVoice verifies that a non-empty opts.Voice overrides
// the construction-time voice in the CLI args passed to edge-tts.
func TestSynthesize_HonorsOptsVoice(t *testing.T) {
p := NewProvider(Config{Voice: "en-US-MichelleNeural"})
var gotArgs []string
fakeFactory := func(ctx context.Context, name string, args ...string) *exec.Cmd {
gotArgs = append([]string{name}, args...)
// Return a no-op command so the test never shells out.
return exec.Command("true")
}
opts := audio.TTSOptions{Voice: "vi-VN-HoaiMyNeural"}
allArgs, err := p.synthesizeWithFactory(context.Background(), "xin chao", opts, fakeFactory)
if err != nil {
t.Fatalf("synthesizeWithFactory returned error: %v", err)
}
_ = gotArgs // populated by factory
// Locate --voice arg in allArgs.
voiceVal := ""
for i, a := range allArgs {
if a == "--voice" && i+1 < len(allArgs) {
voiceVal = allArgs[i+1]
break
}
}
if voiceVal != "vi-VN-HoaiMyNeural" {
t.Errorf("--voice arg = %q, want %q (opts.Voice should override construction-time voice)", voiceVal, "vi-VN-HoaiMyNeural")
}
}
// TestSynthesize_FallsBackToProviderVoice verifies that an empty opts.Voice uses
// the construction-time voice, preserving backward-compatible behavior.
func TestSynthesize_FallsBackToProviderVoice(t *testing.T) {
p := NewProvider(Config{Voice: "en-US-GuyNeural"})
fakeFactory := func(ctx context.Context, name string, args ...string) *exec.Cmd {
return exec.Command("true")
}
opts := audio.TTSOptions{} // empty Voice
allArgs, err := p.synthesizeWithFactory(context.Background(), "hello", opts, fakeFactory)
if err != nil {
t.Fatalf("synthesizeWithFactory returned error: %v", err)
}
voiceVal := ""
for i, a := range allArgs {
if a == "--voice" && i+1 < len(allArgs) {
voiceVal = allArgs[i+1]
break
}
}
if voiceVal != "en-US-GuyNeural" {
t.Errorf("--voice arg = %q, want construction-time voice %q", voiceVal, "en-US-GuyNeural")
}
}
+3
View File
@@ -512,6 +512,9 @@ func (s *Server) SetEvolutionHandler(h *httpapi.EvolutionHandler) {
// SetVoicesHandler sets the ElevenLabs voices list + refresh handler.
func (s *Server) SetVoicesHandler(h *httpapi.VoicesHandler) { s.handlers = append(s.handlers, h) }
// SetTTSHandler sets the TTS synthesize handler.
func (s *Server) SetTTSHandler(h *httpapi.TTSHandler) { s.handlers = append(s.handlers, h) }
// SetVaultHandler sets the Knowledge Vault document handler.
func (s *Server) SetVaultHandler(h *httpapi.VaultHandler) { s.handlers = append(s.handlers, h) }
+140
View File
@@ -0,0 +1,140 @@
package http
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// 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 {
manager *audio.Manager
rateLimiter func(string) bool // per-IP/token rate limit check (nil = no limit)
}
// NewTTSHandler creates a TTSHandler backed by the given audio.Manager.
func NewTTSHandler(mgr *audio.Manager) *TTSHandler {
return &TTSHandler{manager: mgr}
}
// SetRateLimiter injects the rate limiter function (reused from the server's global limiter).
func (h *TTSHandler) SetRateLimiter(fn func(string) bool) { h.rateLimiter = fn }
// RegisterRoutes wires POST /v1/tts/synthesize onto mux with RoleOperator auth.
func (h *TTSHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/tts/synthesize",
requireAuth(permissions.RoleOperator, h.handleSynthesize))
}
// synthesizeRequest is the JSON body for POST /v1/tts/synthesize.
type synthesizeRequest struct {
Text string `json:"text"`
Provider string `json:"provider,omitempty"`
VoiceID string `json:"voice_id,omitempty"`
ModelID string `json:"model_id,omitempty"`
}
const (
maxSynthesizeBodyBytes = 4 << 10 // 4KB — enough for 500 chars + metadata
maxSynthesizeTextChars = 500
synthesizeTimeout = 15 * time.Second
)
// handleSynthesize serves POST /v1/tts/synthesize.
func (h *TTSHandler) handleSynthesize(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
locale := store.LocaleFromContext(ctx)
// Rate limit (best-effort — reuses per-IP/token limiter; no per-user bucket).
if h.rateLimiter != nil {
key := r.RemoteAddr
if tok := extractBearerToken(r); tok != "" {
key = "token:" + tok
}
if !h.rateLimiter(key) {
w.Header().Set("Retry-After", "60")
http.Error(w, fmt.Sprintf(`{"error":%q}`, i18n.T(locale, i18n.MsgRateLimitExceeded)), http.StatusTooManyRequests)
return
}
}
// Cap request body to prevent DoS via oversized JSON.
r.Body = http.MaxBytesReader(w, r.Body, maxSynthesizeBodyBytes)
var req synthesizeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf(`{"error":"invalid json: %s"}`, err.Error()), http.StatusBadRequest)
return
}
req.Text = strings.TrimSpace(req.Text)
if req.Text == "" {
http.Error(w, `{"error":"text is required"}`, http.StatusBadRequest)
return
}
if len([]rune(req.Text)) > maxSynthesizeTextChars {
http.Error(w, fmt.Sprintf(`{"error":"text exceeds %d chars"}`, maxSynthesizeTextChars), http.StatusBadRequest)
return
}
// Resolve provider — explicit name or fall back to manager's primary.
name := req.Provider
if name == "" {
name = h.manager.PrimaryProvider()
}
if name == "" {
http.Error(w, `{"error":"no tts provider configured"}`, http.StatusNotFound)
return
}
p, ok := h.manager.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.
if name == "elevenlabs" && req.ModelID != "" {
if err := elevenlabs.ValidateModel(req.ModelID); err != nil {
http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusUnprocessableEntity)
return
}
}
// Synthesize with a 15-second deadline.
synthCtx, cancel := context.WithTimeout(ctx, synthesizeTimeout)
defer cancel()
opts := audio.TTSOptions{Voice: req.VoiceID, Model: req.ModelID}
start := time.Now()
result, err := p.Synthesize(synthCtx, req.Text, opts)
dur := time.Since(start)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
slog.Warn("tts.synthesize.timeout", "provider", name, "ms", dur.Milliseconds())
http.Error(w, `{"error":"synthesis timeout"}`, http.StatusGatewayTimeout)
return
}
slog.Warn("tts.synthesize.failed", "provider", name, "error", err)
http.Error(w, `{"error":"upstream synthesis failed"}`, http.StatusBadGateway)
return
}
slog.Info("tts.synthesize.ok", "provider", name, "bytes", len(result.Audio), "ms", dur.Milliseconds())
w.Header().Set("Content-Type", result.MimeType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(result.Audio)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(result.Audio)
}
+316
View File
@@ -0,0 +1,316 @@
package http
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const ttsTestToken = "tts-test-token"
// --- Mock TTS provider ---
type mockTTSProvider struct {
name string
capturedOpts audio.TTSOptions
result *audio.SynthResult
err error
block bool // if true, blocks until ctx is cancelled (timeout test)
}
func (m *mockTTSProvider) Name() string { return m.name }
func (m *mockTTSProvider) Synthesize(ctx context.Context, text string, opts audio.TTSOptions) (*audio.SynthResult, error) {
m.capturedOpts = opts
if m.block {
<-ctx.Done()
return nil, ctx.Err()
}
if m.err != nil {
return nil, m.err
}
result := m.result
if result == nil {
result = &audio.SynthResult{
Audio: []byte("fake-mp3-bytes"),
Extension: "mp3",
MimeType: "audio/mpeg",
}
}
return result, nil
}
// newTTSMux creates a ServeMux wired with a TTSHandler backed by mgr.
func newTTSMux(mgr *audio.Manager) *http.ServeMux {
h := NewTTSHandler(mgr)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return mux
}
// ttsBody builds the JSON body for POST /v1/tts/synthesize.
func ttsBody(t *testing.T, v any) *bytes.Buffer {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return bytes.NewBuffer(b)
}
// --- Tests ---
// TestSynthesize_Unauthenticated verifies 401 when a gateway token is configured
// but no Authorization header is sent.
func TestSynthesize_Unauthenticated(t *testing.T) {
setupTestToken(t, ttsTestToken)
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": "hello"}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("want 401, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_BelowOperator verifies 403 when the caller has only Viewer role.
// Injects a viewer API key via setupTestCache so resolveAuth picks it up.
func TestSynthesize_BelowOperator(t *testing.T) {
setupTestToken(t, ttsTestToken) // token set → dev-mode fallback disabled
viewerRaw := "viewer-api-key-raw"
setupTestCache(t, map[string]*store.APIKeyData{
crypto.HashAPIKey(viewerRaw): {
ID: uuid.New(),
Scopes: []string{"operator.read"},
},
})
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": "hello"}))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+viewerRaw)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("want 403, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_MissingText verifies 400 when text field is empty.
func TestSynthesize_MissingText(t *testing.T) {
setupTestToken(t, "") // dev mode — everyone is admin
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": ""}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_TextTooLong verifies 400 when text exceeds 500 chars.
func TestSynthesize_TextTooLong(t *testing.T) {
setupTestToken(t, "") // dev mode
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
mux := newTTSMux(mgr)
longText := strings.Repeat("a", 501) // 501 runes — one over cap
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": longText}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_UnknownProvider verifies 404 when the requested provider is not registered.
func TestSynthesize_UnknownProvider(t *testing.T) {
setupTestToken(t, "") // dev mode
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": "hello", "provider": "nonexistent"}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("want 404, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_InvalidElevenLabsModel verifies 422 for an unknown ElevenLabs model.
func TestSynthesize_InvalidElevenLabsModel(t *testing.T) {
setupTestToken(t, "") // dev mode
mgr := audio.NewManager(audio.ManagerConfig{Primary: "elevenlabs"})
mgr.RegisterTTS(&mockTTSProvider{name: "elevenlabs"})
mux := newTTSMux(mgr)
body := map[string]string{
"text": "hello",
"provider": "elevenlabs",
"model_id": "eleven_totally_fake_model",
}
req := httptest.NewRequest("POST", "/v1/tts/synthesize", ttsBody(t, body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusUnprocessableEntity {
t.Errorf("want 422, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_Success verifies 200 with correct Content-Type and audio body.
func TestSynthesize_Success(t *testing.T) {
setupTestToken(t, "") // dev mode
expected := []byte("audio-data-bytes")
p := &mockTTSProvider{
name: "mock",
result: &audio.SynthResult{
Audio: expected,
Extension: "mp3",
MimeType: "audio/mpeg",
},
}
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(p)
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": "hello world"}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String())
}
if ct := rr.Header().Get("Content-Type"); ct != "audio/mpeg" {
t.Errorf("Content-Type = %q, want audio/mpeg", ct)
}
if !bytes.Equal(rr.Body.Bytes(), expected) {
t.Errorf("body = %q, want %q", rr.Body.Bytes(), expected)
}
}
// TestSynthesize_Timeout verifies 504 when the provider's context is cancelled.
// We use a pre-cancelled context to trigger context.Canceled immediately from
// the mock, which the handler treats as a timeout (context.DeadlineExceeded or Canceled).
func TestSynthesize_Timeout(t *testing.T) {
setupTestToken(t, "") // dev mode
p := &mockTTSProvider{name: "mock", block: true}
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(p)
h := NewTTSHandler(mgr)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
// Pre-cancel so the mock's <-ctx.Done() fires immediately.
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": "hello"}))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusGatewayTimeout {
t.Errorf("want 504, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_EdgeHonorsOpts verifies that voice_id is forwarded to the Edge provider,
// proving the C1 fix is wired through the HTTP handler end-to-end.
func TestSynthesize_EdgeHonorsOpts(t *testing.T) {
setupTestToken(t, "") // dev mode
edgeMock := &mockTTSProvider{name: "edge"}
mgr := audio.NewManager(audio.ManagerConfig{Primary: "edge"})
mgr.RegisterTTS(edgeMock)
mux := newTTSMux(mgr)
body := map[string]string{
"text": "xin chào",
"provider": "edge",
"voice_id": "vi-VN-HoaiMyNeural",
}
req := httptest.NewRequest("POST", "/v1/tts/synthesize", ttsBody(t, body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String())
}
if edgeMock.capturedOpts.Voice != "vi-VN-HoaiMyNeural" {
t.Errorf("capturedOpts.Voice = %q, want %q (voice_id not forwarded to provider)",
edgeMock.capturedOpts.Voice, "vi-VN-HoaiMyNeural")
}
}
// TestSynthesize_ValidElevenLabsModel verifies that a known ElevenLabs model passes through.
func TestSynthesize_ValidElevenLabsModel(t *testing.T) {
setupTestToken(t, "") // dev mode
mgr := audio.NewManager(audio.ManagerConfig{Primary: "elevenlabs"})
mgr.RegisterTTS(&mockTTSProvider{name: "elevenlabs"})
mux := newTTSMux(mgr)
body := map[string]string{
"text": "hello",
"provider": "elevenlabs",
"model_id": "eleven_multilingual_v2",
}
req := httptest.NewRequest("POST", "/v1/tts/synthesize", ttsBody(t, body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("want 200, got %d: %s", rr.Code, rr.Body.String())
}
}
+43
View File
@@ -78,3 +78,46 @@ func TestResolvePrimary_EmptyOverride_FallsBack(t *testing.T) {
t.Errorf("got %q, want openai (default)", got)
}
}
// TestResolvePrimary_LegacyConfig verifies that pre-existing
// builtin_tool_tenant_configs[tts] rows (legacy from before the /tts page
// migration) are handled gracefully — no crash, no Error-level log.
func TestResolvePrimary_LegacyConfig(t *testing.T) {
cases := []struct {
name string
rawSettings BuiltinToolSettings // simulates row from builtin_tool_tenant_configs
want string // expected resolved provider
}{
{
name: "valid legacy JSON returns configured provider",
rawSettings: BuiltinToolSettings{"tts": []byte(`{"primary":"elevenlabs"}`)},
want: "elevenlabs",
},
{
name: "malformed JSON falls back to manager primary",
rawSettings: BuiltinToolSettings{"tts": []byte(`not-an-object`)},
want: "openai",
},
{
name: "empty settings map falls back to manager primary",
rawSettings: BuiltinToolSettings{},
want: "openai",
},
{
name: "missing tts key falls back to manager primary",
rawSettings: BuiltinToolSettings{"stt": []byte(`{"primary":"whisper"}`)},
want: "openai",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tool := NewTtsTool(makeTTSManager("openai", "elevenlabs", "edge"))
ctx := WithBuiltinToolSettings(context.Background(), tc.rawSettings)
got := tool.resolvePrimary(ctx, tool.manager)
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
@@ -17,9 +17,12 @@ import { AgentSkillsSection } from './AgentSkillsSection'
import { AgentMcpSection } from './AgentMcpSection'
import { AgentFilesTab } from './AgentFilesTab'
import { VoicePicker } from './voice-picker'
import { TtsEmptyState } from './tts-empty-state'
import { ConfirmDialog } from '../common/ConfirmDialog'
import { useAgentDetailState } from '../../hooks/use-agent-detail-state'
import { useDesktopTtsConfig } from '../../hooks/use-tts-config'
import type { AgentData } from '../../types/agent'
import type { TtsProviderId } from '@/data/tts-providers'
type DetailTab = 'overview' | 'evolution' | 'files'
@@ -57,6 +60,7 @@ export function AgentDetailPanel({ agent, onSave, onResummon, onClose }: AgentDe
const s = useAgentDetailState(agent, onSaveWithVoice, onClose)
const isPredefined = agent.agent_type === 'predefined'
const { globalProvider } = useDesktopTtsConfig()
const handleConfirmResummon = async () => {
setConfirmResummon(false)
@@ -184,12 +188,20 @@ export function AgentDetailPanel({ agent, onSave, onResummon, onClose }: AgentDe
<hr className="border-border" />
<AgentMcpSection agentId={agent.id} />
<hr className="border-border" />
{/* TTS Voice section */}
{/* TTS Voice section — gated on global TTS provider being configured */}
<div className="space-y-2">
<p className="text-xs font-semibold text-text-primary uppercase tracking-wide">
{t('tts:voice_label')}
</p>
<VoicePicker value={ttsVoiceId} onChange={setTtsVoiceId} />
{globalProvider ? (
<VoicePicker
value={ttsVoiceId}
onChange={setTtsVoiceId}
provider={globalProvider as TtsProviderId}
/>
) : (
<TtsEmptyState />
)}
</div>
</div>
) : (
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
/**
* Shown in AgentDetailPanel TTS Voice section when no global TTS provider is configured.
* Desktop variant — no router navigation (single-user app, settings are in-app).
*/
export function TtsEmptyState() {
const { t } = useTranslation('tts')
return (
<div className="flex flex-col items-center gap-1.5 rounded-lg border border-dashed border-border p-3 text-center">
{/* VolumeX icon — inline SVG to avoid lucide-react dep on desktop */}
<svg
className="w-5 h-5 text-text-muted"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
<p className="text-xs font-medium text-text-primary">{t('empty_state.title')}</p>
<p className="text-[11px] text-text-muted">{t('empty_state.non_owner_hint')}</p>
</div>
)
}
@@ -3,11 +3,16 @@ import { useVoices, useRefreshVoices } from '../../hooks/use-voices'
import { VoicePreviewButton } from './voice-preview-button'
import { Combobox } from '../common/Combobox'
import type { Voice } from '../../services/voices'
import {
getProviderDefinition,
type TtsProviderId,
} from '@/data/tts-providers'
interface Props {
value: string | null
onChange: (voiceId: string) => void
disabled?: boolean
provider?: TtsProviderId | ''
}
function VoiceOption({ voice, selected }: { voice: Voice; selected: boolean }) {
@@ -28,7 +33,73 @@ function VoiceOption({ voice, selected }: { voice: Voice; selected: boolean }) {
)
}
export function VoicePicker({ value, onChange, disabled }: Props) {
export function VoicePicker({ value, onChange, disabled, provider }: Props) {
if (provider === '') {
return <EmptyStatePicker />
}
const def = provider ? getProviderDefinition(provider) : null
if (def && !def.dynamic) {
return (
<StaticVoicePicker
value={value}
onChange={onChange}
disabled={disabled}
voices={def.voices}
/>
)
}
return (
<DynamicVoicePicker
value={value}
onChange={onChange}
disabled={disabled}
/>
)
}
function EmptyStatePicker() {
const { t } = useTranslation('tts')
return (
<div className="flex h-8 w-full items-center rounded border border-border bg-surface-secondary px-2 text-xs text-text-muted opacity-60 cursor-not-allowed">
{t('voice_picker.requires_provider')}
</div>
)
}
function StaticVoicePicker({
value,
onChange,
disabled,
voices,
}: {
value: string | null
onChange: (id: string) => void
disabled?: boolean
voices: { value: string; label: string }[]
}) {
const { t } = useTranslation('tts')
const options = voices.map((v) => ({ value: v.value, label: v.label }))
return (
<Combobox
value={value ?? ''}
onChange={onChange}
options={options}
placeholder={t('voice_placeholder')}
allowCustom={false}
disabled={disabled}
/>
)
}
function DynamicVoicePicker({
value,
onChange,
disabled,
}: {
value: string | null
onChange: (voiceId: string) => void
disabled?: boolean
}) {
const { t } = useTranslation('tts')
const { data: voices, isLoading } = useVoices()
const { mutate: refresh, isPending: refreshing } = useRefreshVoices()
@@ -70,7 +141,6 @@ export function VoicePicker({ value, onChange, disabled }: Props) {
</button>
</div>
{/* Preview for selected voice */}
{value && voices.find((v) => v.voice_id === value) && (
<div className="flex items-center gap-1 text-[11px] text-text-muted">
<VoicePreviewButton
@@ -88,5 +158,4 @@ export function VoicePicker({ value, onChange, disabled }: Props) {
)
}
// Export VoiceOption for potential reuse
export { VoiceOption }
@@ -0,0 +1,86 @@
/**
* Desktop TTS provider catalog sanity tests.
* Mirrors ui/web/src/data/__tests__/tts-providers.test.ts — keeps desktop
* catalog in sync with the web catalog via identical assertions.
*/
import { describe, it, expect } from "vitest";
import { TTS_PROVIDERS, getProviderDefinition, type TtsProviderId } from "../tts-providers";
describe("Desktop TTS_PROVIDERS catalog", () => {
const ids: TtsProviderId[] = ["openai", "elevenlabs", "edge", "minimax"];
it("has exactly 4 provider entries matching known IDs", () => {
expect(Object.keys(TTS_PROVIDERS).sort()).toEqual([...ids].sort());
});
it.each(ids)("%s.id matches its record key", (id) => {
expect(TTS_PROVIDERS[id].id).toBe(id);
});
it("ElevenLabs is dynamic, others are not", () => {
expect(TTS_PROVIDERS.elevenlabs.dynamic).toBe(true);
expect(TTS_PROVIDERS.openai.dynamic).toBe(false);
expect(TTS_PROVIDERS.edge.dynamic).toBe(false);
expect(TTS_PROVIDERS.minimax.dynamic).toBe(false);
});
it.each(ids.filter((id) => TTS_PROVIDERS[id].models.length > 0))(
"%s.defaultModel is in its models list",
(id) => {
const def = TTS_PROVIDERS[id];
if (def.defaultModel) {
const modelValues = def.models.map((m) => m.value);
expect(modelValues).toContain(def.defaultModel);
}
},
);
it.each(ids.filter((id) => !TTS_PROVIDERS[id].dynamic))(
"%s.defaultVoice is in its voices list",
(id) => {
const def = TTS_PROVIDERS[id];
if (def.defaultVoice) {
const voiceValues = def.voices.map((v) => v.value);
expect(voiceValues).toContain(def.defaultVoice);
}
},
);
it("Edge requires no API key", () => {
expect(TTS_PROVIDERS.edge.requiresApiKey).toBe(false);
});
it("OpenAI and MiniMax require an API key", () => {
expect(TTS_PROVIDERS.openai.requiresApiKey).toBe(true);
expect(TTS_PROVIDERS.minimax.requiresApiKey).toBe(true);
});
it("ElevenLabs exposes all 4 backend-allowlisted models", () => {
const modelIds = TTS_PROVIDERS.elevenlabs.models.map((m) => m.value);
expect(modelIds).toContain("eleven_v3");
expect(modelIds).toContain("eleven_flash_v2_5");
expect(modelIds).toContain("eleven_multilingual_v2");
expect(modelIds).toContain("eleven_turbo_v2_5");
expect(modelIds).toHaveLength(4);
});
it("OpenAI models include the 3 standard model IDs", () => {
const ids = TTS_PROVIDERS.openai.models.map((m) => m.value);
expect(ids).toContain("gpt-4o-mini-tts");
expect(ids).toContain("tts-1");
expect(ids).toContain("tts-1-hd");
});
it("Edge has 20 hardcoded voices", () => {
expect(TTS_PROVIDERS.edge.voices).toHaveLength(20);
});
it("getProviderDefinition returns null for unknown id", () => {
expect(getProviderDefinition("")).toBeNull();
expect(getProviderDefinition("nonexistent")).toBeNull();
});
it("getProviderDefinition returns correct definition for known id", () => {
expect(getProviderDefinition("openai")?.id).toBe("openai");
});
});
@@ -0,0 +1,158 @@
// KEEP IN SYNC with ui/web/src/data/tts-providers.ts
//
// TTS provider catalog — source of truth for non-ElevenLabs providers.
//
// ElevenLabs voices are fetched dynamically via /v1/voices (dynamic: true).
// OpenAI, Edge, and MiniMax voices are hardcoded here because their backends
// accept any string and fall back to provider defaults.
//
// Backend provider name refs:
// openai → internal/audio/openai/tts.go:59
// elevenlabs → internal/audio/elevenlabs/tts.go Name()
// edge → internal/audio/edge/tts.go:47
// minimax → internal/audio/minimax/tts.go:64
export type TtsProviderId = "openai" | "elevenlabs" | "edge" | "minimax";
export interface TtsVoiceOption {
value: string;
label: string;
locale?: string;
}
export interface TtsModelOption {
value: string;
label: string;
description?: string;
}
export interface TtsProviderDefinition {
id: TtsProviderId;
/** true = voices fetched from /v1/voices; false = use voices[] below */
dynamic: boolean;
voices: TtsVoiceOption[];
models: TtsModelOption[];
defaultVoice?: string;
defaultModel?: string;
requiresApiKey: boolean;
}
export const TTS_PROVIDERS: Record<TtsProviderId, TtsProviderDefinition> = {
// ElevenLabs — voices fetched from server; models hardcoded from allowlist
// Ref: internal/audio/elevenlabs/models.go:14-19
// Defaults: internal/config/config_channels.go:499-500
elevenlabs: {
id: "elevenlabs",
dynamic: true,
voices: [],
models: [
{ value: "eleven_v3", label: "Eleven v3" },
{ value: "eleven_flash_v2_5", label: "Eleven Flash v2.5" },
{ value: "eleven_multilingual_v2", label: "Eleven Multilingual v2" },
{ value: "eleven_turbo_v2_5", label: "Eleven Turbo v2.5" },
],
defaultVoice: "pMsXgVXv3BLzUgSXRplE",
defaultModel: "eleven_multilingual_v2",
requiresApiKey: true,
},
// OpenAI TTS — docs: https://platform.openai.com/docs/guides/text-to-speech
// Ref: internal/audio/openai/tts.go:46-51
openai: {
id: "openai",
dynamic: false,
voices: [
{ value: "alloy", label: "Alloy" },
{ value: "echo", label: "Echo" },
{ value: "fable", label: "Fable" },
{ value: "onyx", label: "Onyx" },
{ value: "nova", label: "Nova" },
{ value: "shimmer", label: "Shimmer" },
{ value: "coral", label: "Coral" },
{ value: "sage", label: "Sage" },
],
models: [
{ value: "gpt-4o-mini-tts", label: "GPT-4o Mini TTS" },
{ value: "tts-1", label: "TTS-1" },
{ value: "tts-1-hd", label: "TTS-1 HD" },
],
defaultVoice: "alloy",
defaultModel: "gpt-4o-mini-tts",
requiresApiKey: true,
},
// Microsoft Edge TTS — free, no API key required
// Ref: internal/audio/edge/tts.go:37-39
// Top-20 neural voices from edge-tts registry
edge: {
id: "edge",
dynamic: false,
voices: [
{ value: "en-US-MichelleNeural", label: "Michelle (en-US)", locale: "en-US" },
{ value: "en-US-AriaNeural", label: "Aria (en-US)", locale: "en-US" },
{ value: "en-US-GuyNeural", label: "Guy (en-US)", locale: "en-US" },
{ value: "en-GB-SoniaNeural", label: "Sonia (en-GB)", locale: "en-GB" },
{ value: "en-GB-RyanNeural", label: "Ryan (en-GB)", locale: "en-GB" },
{ value: "en-AU-NatashaNeural", label: "Natasha (en-AU)", locale: "en-AU" },
{ value: "vi-VN-HoaiMyNeural", label: "Hoài My (vi-VN)", locale: "vi-VN" },
{ value: "vi-VN-NamMinhNeural", label: "Nam Minh (vi-VN)", locale: "vi-VN" },
{ value: "zh-CN-XiaoxiaoNeural", label: "Xiaoxiao (zh-CN)", locale: "zh-CN" },
{ value: "zh-CN-YunxiNeural", label: "Yunxi (zh-CN)", locale: "zh-CN" },
{ value: "ja-JP-NanamiNeural", label: "Nanami (ja-JP)", locale: "ja-JP" },
{ value: "ko-KR-SunHiNeural", label: "Sun Hi (ko-KR)", locale: "ko-KR" },
{ value: "es-ES-ElviraNeural", label: "Elvira (es-ES)", locale: "es-ES" },
{ value: "fr-FR-DeniseNeural", label: "Denise (fr-FR)", locale: "fr-FR" },
{ value: "de-DE-KatjaNeural", label: "Katja (de-DE)", locale: "de-DE" },
{ value: "it-IT-ElsaNeural", label: "Elsa (it-IT)", locale: "it-IT" },
{ value: "pt-BR-FranciscaNeural", label: "Francisca (pt-BR)", locale: "pt-BR" },
{ value: "ru-RU-SvetlanaNeural", label: "Svetlana (ru-RU)", locale: "ru-RU" },
{ value: "hi-IN-SwaraNeural", label: "Swara (hi-IN)", locale: "hi-IN" },
{ value: "id-ID-GadisNeural", label: "Gadis (id-ID)", locale: "id-ID" },
],
models: [],
defaultVoice: "en-US-MichelleNeural",
requiresApiKey: false,
},
// MiniMax TTS — docs: https://platform.minimax.io/docs/api-reference/speech-t2a-intro
// Ref: internal/audio/minimax/tts.go:50-56
minimax: {
id: "minimax",
dynamic: false,
voices: [
{ value: "Wise_Woman", label: "Wise Woman" },
{ value: "Friendly_Person", label: "Friendly Person" },
{ value: "Inspirational_girl", label: "Inspirational Girl" },
{ value: "Deep_Voice_Man", label: "Deep Voice Man" },
{ value: "Calm_Woman", label: "Calm Woman" },
{ value: "Casual_Guy", label: "Casual Guy" },
{ value: "Lively_Girl", label: "Lively Girl" },
{ value: "Patient_Man", label: "Patient Man" },
{ value: "Young_Knight", label: "Young Knight" },
{ value: "Determined_Man", label: "Determined Man" },
{ value: "Lovely_Girl", label: "Lovely Girl" },
{ value: "Decent_Boy", label: "Decent Boy" },
{ value: "Imposing_Manner", label: "Imposing Manner" },
{ value: "Elegant_Man", label: "Elegant Man" },
{ value: "Abbess", label: "Abbess" },
{ value: "Sweet_Girl_2", label: "Sweet Girl 2" },
{ value: "Exuberant_Girl", label: "Exuberant Girl" },
],
models: [
{ value: "speech-02-hd", label: "Speech-02 HD" },
{ value: "speech-02-turbo", label: "Speech-02 Turbo" },
{ value: "speech-01-hd", label: "Speech-01 HD" },
{ value: "speech-01-turbo", label: "Speech-01 Turbo" },
],
defaultVoice: "Wise_Woman",
defaultModel: "speech-02-hd",
requiresApiKey: true,
},
};
/**
* Returns provider definition by id, or null if id is unknown/empty.
*/
export function getProviderDefinition(id: string): TtsProviderDefinition | null {
return TTS_PROVIDERS[id as TtsProviderId] ?? null;
}
@@ -0,0 +1,42 @@
import { useState, useEffect, useCallback } from 'react'
import { getWsClient } from '../lib/ws'
interface ConfigResponse {
config?: { tts?: { provider?: string } }
}
/**
* Read-only desktop TTS config hook.
* Calls WS `config.get` (no REST GET /v1/config exists on gateway).
* Pattern mirrors use-channel-status.ts and use-paired-devices.ts.
*/
export function useDesktopTtsConfig() {
const [globalProvider, setGlobalProvider] = useState('')
const [loading, setLoading] = useState(true)
const fetchConfig = useCallback(async () => {
try {
const ws = getWsClient()
const res = (await ws.call('config.get')) as ConfigResponse
setGlobalProvider(res.config?.tts?.provider ?? '')
} catch {
// Gateway may not be connected yet — treat as unconfigured.
setGlobalProvider('')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchConfig()
// Subscribe to config.updated event if gateway emits it — invalidate cache.
let unsub: (() => void) | undefined
try {
const ws = getWsClient()
unsub = ws.on('config.updated', () => { void fetchConfig() })
} catch { /* ws not ready */ }
return () => { unsub?.() }
}, [fetchConfig])
return { globalProvider, loading }
}
@@ -70,6 +70,9 @@
},
"voice_label": "Voice",
"voice_placeholder": "Select a voice…",
"voice_picker": {
"requires_provider": "Configure TTS provider first"
},
"voice_refresh": "Refresh",
"voice_preview": "Preview",
"voice_stop_preview": "Stop",
@@ -77,5 +80,53 @@
"voice_loading": "Loading voices…",
"voice_preview_error": "Preview failed — refreshing list",
"model_label": "Model",
"model_placeholder": "Default (tenant/system)"
"model_placeholder": "Default (tenant/system)",
"empty_state": {
"title": "AI voice is not configured",
"description": "Configure TTS so agents can reply with audio.",
"cta": "Configure TTS",
"non_owner_hint": "Contact an owner to enable AI voice."
},
"test": {
"button": "Test this voice",
"sample_text": "Hello, this is a voice test.",
"title": "Listen",
"placeholder": "Enter text to hear a sample…",
"play": "Play",
"stop": "Stop",
"error_missing_config": "Choose a provider and enter an API key first.",
"error_synthesize": "Could not generate audio: {{error}}"
},
"override": {
"label": "Override voice for this agent",
"inherits": "Inherits: {{provider}} · {{voice}} · {{model}}"
},
"testConnection": {
"label": "Test connection",
"testing": "Testing…",
"success": "Connection successful",
"failed": "Connection failed"
},
"connection": {
"test": "Test connection",
"ok": "Connection OK",
"fail": "Invalid key or network error"
},
"agent": {
"override_label": "Override voice for this agent",
"inherits_from_global": "Inherits from global: {{provider}} · {{voice}} · {{model}}"
},
"playground": {
"title": "Test Playground",
"inputLabel": "Text to synthesize",
"placeholder": "Enter text to hear…",
"play": "Play",
"stop": "Stop",
"synthesizing": "Synthesizing…",
"playbackFailed": "Playback failed",
"requiresProvider": "Select a provider above to enable test playback."
},
"advanced": {
"title": "Advanced settings"
}
}
@@ -70,6 +70,9 @@
},
"voice_label": "Giọng đọc",
"voice_placeholder": "Chọn giọng đọc…",
"voice_picker": {
"requires_provider": "Vui lòng cấu hình provider TTS trước"
},
"voice_refresh": "Làm mới",
"voice_preview": "Nghe thử",
"voice_stop_preview": "Dừng",
@@ -77,5 +80,53 @@
"voice_loading": "Đang tải giọng đọc…",
"voice_preview_error": "Không nghe thử được — đang làm mới",
"model_label": "Mô hình",
"model_placeholder": "Mặc định (tenant/hệ thống)"
"model_placeholder": "Mặc định (tenant/hệ thống)",
"empty_state": {
"title": "Giọng đọc AI chưa được cấu hình",
"description": "Cấu hình TTS để agent trả lời bằng giọng nói",
"cta": "Cấu hình TTS",
"non_owner_hint": "Liên hệ owner để bật giọng đọc AI"
},
"test": {
"button": "Thử giọng đọc",
"sample_text": "Xin chào, đây là giọng đọc thử.",
"title": "Nghe thử",
"placeholder": "Nhập text để nghe thử...",
"play": "Phát",
"stop": "Dừng",
"error_missing_config": "Chọn provider và nhập API key trước",
"error_synthesize": "Không tạo được audio: {{error}}"
},
"override": {
"label": "Ghi đè giọng đọc cho agent này",
"inherits": "Kế thừa: {{provider}} · {{voice}} · {{model}}"
},
"testConnection": {
"label": "Kiểm tra kết nối",
"testing": "Đang kiểm tra…",
"success": "Kết nối thành công",
"failed": "Kết nối thất bại"
},
"connection": {
"test": "Kiểm tra kết nối",
"ok": "Kết nối OK",
"fail": "Key không hợp lệ hoặc network lỗi"
},
"agent": {
"override_label": "Ghi đè voice cho agent này",
"inherits_from_global": "Kế thừa từ global: {{provider}} · {{voice}} · {{model}}"
},
"playground": {
"title": "Thử nghiệm",
"inputLabel": "Văn bản cần đọc",
"placeholder": "Nhập văn bản để nghe thử…",
"play": "Phát",
"stop": "Dừng",
"synthesizing": "Đang tổng hợp…",
"playbackFailed": "Phát âm thất bại",
"requiresProvider": "Chọn provider ở trên để bật phát thử."
},
"advanced": {
"title": "Cài đặt nâng cao"
}
}
@@ -70,6 +70,9 @@
},
"voice_label": "声音",
"voice_placeholder": "选择声音…",
"voice_picker": {
"requires_provider": "请先配置 TTS Provider"
},
"voice_refresh": "刷新",
"voice_preview": "试听",
"voice_stop_preview": "停止",
@@ -77,5 +80,53 @@
"voice_loading": "加载声音中…",
"voice_preview_error": "试听失败 — 正在刷新",
"model_label": "模型",
"model_placeholder": "默认(租户/系统)"
"model_placeholder": "默认(租户/系统)",
"empty_state": {
"title": "AI 语音尚未配置",
"description": "配置 TTS 让智能体以语音回复",
"cta": "配置 TTS",
"non_owner_hint": "请联系 owner 启用 AI 语音"
},
"test": {
"button": "测试语音",
"sample_text": "你好,这是语音测试。",
"title": "试听",
"placeholder": "输入文本进行试听…",
"play": "播放",
"stop": "停止",
"error_missing_config": "请先选择提供方并输入 API key",
"error_synthesize": "无法生成音频:{{error}}"
},
"override": {
"label": "为此 Agent 覆盖语音",
"inherits": "继承: {{provider}} · {{voice}} · {{model}}"
},
"testConnection": {
"label": "测试连接",
"testing": "测试中…",
"success": "连接成功",
"failed": "连接失败"
},
"connection": {
"test": "测试连接",
"ok": "连接正常",
"fail": "Key 无效或网络错误"
},
"agent": {
"override_label": "为此智能体覆盖语音",
"inherits_from_global": "继承自全局:{{provider}} · {{voice}} · {{model}}"
},
"playground": {
"title": "测试播放",
"inputLabel": "要合成的文本",
"placeholder": "输入文本以试听…",
"play": "播放",
"stop": "停止",
"synthesizing": "合成中…",
"playbackFailed": "播放失败",
"requiresProvider": "请先选择 Provider 以启用试听。"
},
"advanced": {
"title": "高级设置"
}
}
+109 -4
View File
@@ -5,14 +5,33 @@ import { createPortal } from "react-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useVoices, useRefreshVoices, type Voice } from "@/api/voices";
import { VoicePreviewButton } from "@/components/voice-preview-button";
import {
getProviderDefinition,
type TtsProviderId,
} from "@/data/tts-providers";
interface Props {
value?: string;
onChange: (id: string) => void;
disabled?: boolean;
/**
* Controls picker mode:
* - undefined / "elevenlabs" → dynamic fetch from /v1/voices (legacy behavior).
* - "openai" | "edge" | "minimax" → hardcoded list from catalog.
* - "" (empty string) → disabled; shows "Configure TTS provider first".
*/
provider?: TtsProviderId | "";
placeholder?: string;
}
const LABEL_KEYS = ["gender", "accent", "age", "use_case"] as const;
@@ -44,7 +63,96 @@ function VoiceRow({ voice, selected, onSelect }: { voice: Voice; selected: boole
);
}
export function VoicePicker({ value, onChange, disabled }: Props) {
/**
* Top-level picker. Dispatches to one of three sub-components based on `provider`:
* - "" → disabled empty-state
* - non-dynamic provider (openai/edge/minimax) → <Select> from catalog
* - undefined | "elevenlabs" → <DynamicVoicePicker> that fetches /v1/voices
*/
export function VoicePicker({ value, onChange, disabled, provider, placeholder }: Props) {
if (provider === "") {
return <EmptyStatePicker placeholder={placeholder} />;
}
const def = provider ? getProviderDefinition(provider) : null;
if (def && !def.dynamic) {
return (
<StaticVoicePicker
value={value}
onChange={onChange}
disabled={disabled}
voices={def.voices}
placeholder={placeholder}
/>
);
}
return (
<DynamicVoicePicker
value={value}
onChange={onChange}
disabled={disabled}
/>
);
}
function EmptyStatePicker({ placeholder }: { placeholder?: string }) {
const { t } = useTranslation("tts");
return (
<button
type="button"
disabled
className={cn(
"border-input dark:bg-input/30 flex h-9 w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm shadow-xs outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
"text-muted-foreground",
)}
>
<span className="truncate">
{placeholder ?? t("voice_picker.requires_provider")}
</span>
<ChevronDownIcon className="size-4 shrink-0 opacity-50" />
</button>
);
}
function StaticVoicePicker({
value,
onChange,
disabled,
voices,
placeholder,
}: {
value?: string;
onChange: (id: string) => void;
disabled?: boolean;
voices: { value: string; label: string }[];
placeholder?: string;
}) {
const { t } = useTranslation("tts");
return (
<Select value={value ?? ""} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder ?? t("voice_placeholder")} />
</SelectTrigger>
<SelectContent>
{voices.map((v) => (
<SelectItem key={v.value} value={v.value}>
{v.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function DynamicVoicePicker({
value,
onChange,
disabled,
}: {
value?: string;
onChange: (id: string) => void;
disabled?: boolean;
}) {
const { t } = useTranslation("tts");
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -77,7 +185,6 @@ export function VoicePicker({ value, onChange, disabled }: Props) {
refresh();
};
// Close on outside click
const handleBlur = (e: React.FocusEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setOpen(false);
@@ -99,7 +206,6 @@ export function VoicePicker({ value, onChange, disabled }: Props) {
return { position: "fixed" as const, top: rect.bottom + 4, left: rect.left, width: rect.width };
})()}
>
{/* Header: search + refresh */}
<div className="flex items-center gap-1 border-b px-2 py-1.5">
<input
autoFocus
@@ -121,7 +227,6 @@ export function VoicePicker({ value, onChange, disabled }: Props) {
</Button>
</div>
{/* Voice list */}
<div className="max-h-60 overflow-y-auto p-1">
{isLoading ? (
<div className="space-y-1 p-1">
@@ -0,0 +1,58 @@
/**
* Parity test: web catalog vs desktop catalog provider IDs must stay in sync.
*
* Uses fs.readFileSync + regex extraction instead of cross-package imports to
* avoid build-system coupling. Path fragility acknowledged — if the directory
* layout changes, update the paths below.
*
* Web catalog: ui/web/src/data/tts-providers.ts
* Desktop catalog: ui/desktop/frontend/src/data/tts-providers.ts
*/
import { describe, it, expect } from "vitest";
import fs from "fs";
import path from "path";
/**
* Extracts top-level provider keys from a TTS_PROVIDERS object literal.
* Matches lines like ` openai: {` or ` elevenlabs: {` inside the TTS_PROVIDERS block.
*/
function extractProviderIds(filePath: string): string[] {
const src = fs.readFileSync(filePath, "utf8");
// Find the TTS_PROVIDERS object block, then extract top-level keys
const providerBlockMatch = src.match(/TTS_PROVIDERS[^=]*=\s*\{([\s\S]*?)^};/m);
if (!providerBlockMatch || !providerBlockMatch[1]) {
// Fallback: extract all top-level identifier keys followed by ': {'
const matches = Array.from(src.matchAll(/^\s{2}(\w+):\s*\{/gm));
return matches.map((m) => m[1]).filter((id): id is string => id !== undefined);
}
const block = providerBlockMatch[1];
const matches = Array.from(block.matchAll(/^\s{2}(\w+):\s*\{/gm));
return matches.map((m) => m[1]).filter((id): id is string => id !== undefined);
}
describe("TTS provider catalog parity (web ↔ desktop)", () => {
const webCatalog = path.resolve(__dirname, "../tts-providers.ts");
const desktopCatalog = path.resolve(
__dirname,
"../../../../../ui/desktop/frontend/src/data/tts-providers.ts",
);
it("both catalog files exist on disk", () => {
expect(fs.existsSync(webCatalog), `web catalog missing: ${webCatalog}`).toBe(true);
expect(fs.existsSync(desktopCatalog), `desktop catalog missing: ${desktopCatalog}`).toBe(true);
});
it("web and desktop catalogs expose identical provider ids", () => {
const idsWeb = extractProviderIds(webCatalog).sort();
const idsDesktop = extractProviderIds(desktopCatalog).sort();
expect(idsWeb.length).toBeGreaterThan(0);
expect(idsDesktop.length).toBeGreaterThan(0);
expect(idsWeb).toEqual(idsDesktop);
});
it("both catalogs include the 4 expected providers", () => {
const idsWeb = extractProviderIds(webCatalog).sort();
expect(idsWeb).toEqual(["edge", "elevenlabs", "minimax", "openai"]);
});
});
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { TTS_PROVIDERS, getProviderDefinition, type TtsProviderId } from "../tts-providers";
describe("TTS_PROVIDERS catalog", () => {
const ids: TtsProviderId[] = ["openai", "elevenlabs", "edge", "minimax"];
it("has exactly 4 provider entries matching known IDs", () => {
expect(Object.keys(TTS_PROVIDERS).sort()).toEqual([...ids].sort());
});
it.each(ids)("%s.id matches its record key", (id) => {
expect(TTS_PROVIDERS[id].id).toBe(id);
});
it("ElevenLabs is dynamic, others are not", () => {
expect(TTS_PROVIDERS.elevenlabs.dynamic).toBe(true);
expect(TTS_PROVIDERS.openai.dynamic).toBe(false);
expect(TTS_PROVIDERS.edge.dynamic).toBe(false);
expect(TTS_PROVIDERS.minimax.dynamic).toBe(false);
});
it.each(ids.filter((id) => TTS_PROVIDERS[id].models.length > 0))(
"%s.defaultModel is in its models list",
(id) => {
const def = TTS_PROVIDERS[id];
if (def.defaultModel) {
const modelValues = def.models.map((m) => m.value);
expect(modelValues).toContain(def.defaultModel);
}
},
);
it.each(ids.filter((id) => !TTS_PROVIDERS[id].dynamic))(
"%s.defaultVoice is in its voices list",
(id) => {
const def = TTS_PROVIDERS[id];
if (def.defaultVoice) {
const voiceValues = def.voices.map((v) => v.value);
expect(voiceValues).toContain(def.defaultVoice);
}
},
);
it("Edge requires no API key", () => {
expect(TTS_PROVIDERS.edge.requiresApiKey).toBe(false);
});
it("OpenAI and MiniMax require an API key", () => {
expect(TTS_PROVIDERS.openai.requiresApiKey).toBe(true);
expect(TTS_PROVIDERS.minimax.requiresApiKey).toBe(true);
});
it("ElevenLabs exposes all 4 backend-allowlisted models", () => {
const modelIds = TTS_PROVIDERS.elevenlabs.models.map((m) => m.value);
expect(modelIds).toContain("eleven_v3");
expect(modelIds).toContain("eleven_flash_v2_5");
expect(modelIds).toContain("eleven_multilingual_v2");
expect(modelIds).toContain("eleven_turbo_v2_5");
expect(modelIds).toHaveLength(4);
});
it("Edge has 20 hardcoded voices", () => {
expect(TTS_PROVIDERS.edge.voices).toHaveLength(20);
});
it("OpenAI models include the 3 standard model IDs", () => {
const ids = TTS_PROVIDERS.openai.models.map((m) => m.value);
expect(ids).toContain("gpt-4o-mini-tts");
expect(ids).toContain("tts-1");
expect(ids).toContain("tts-1-hd");
});
it("getProviderDefinition returns null for unknown id", () => {
expect(getProviderDefinition("")).toBeNull();
expect(getProviderDefinition("nonexistent")).toBeNull();
});
it("getProviderDefinition returns correct definition for known id", () => {
expect(getProviderDefinition("openai")?.id).toBe("openai");
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* TTS provider catalog — source of truth for non-ElevenLabs providers.
*
* ElevenLabs voices are fetched dynamically via /v1/voices (dynamic: true).
* OpenAI, Edge, and MiniMax voices are hardcoded here because their backends
* accept any string and fall back to provider defaults.
*
* Backend provider name refs:
* openai → internal/audio/openai/tts.go:59
* elevenlabs → internal/audio/elevenlabs/tts.go Name()
* edge → internal/audio/edge/tts.go:47
* minimax → internal/audio/minimax/tts.go:64
*
* KEEP IN SYNC with ui/desktop/frontend/src/data/tts-providers.ts
*/
export type TtsProviderId = "openai" | "elevenlabs" | "edge" | "minimax";
export interface TtsVoiceOption {
value: string;
label: string;
locale?: string;
}
export interface TtsModelOption {
value: string;
label: string;
description?: string;
}
export interface TtsProviderDefinition {
id: TtsProviderId;
/** true = voices fetched from /v1/voices; false = use voices[] below */
dynamic: boolean;
voices: TtsVoiceOption[];
models: TtsModelOption[];
defaultVoice?: string;
defaultModel?: string;
requiresApiKey: boolean;
}
export const TTS_PROVIDERS: Record<TtsProviderId, TtsProviderDefinition> = {
// ElevenLabs — voices fetched from server; models hardcoded from allowlist
// Ref: internal/audio/elevenlabs/models.go:14-19
// Defaults: internal/config/config_channels.go:499-500
elevenlabs: {
id: "elevenlabs",
dynamic: true,
voices: [],
models: [
{ value: "eleven_v3", label: "Eleven v3" },
{ value: "eleven_flash_v2_5", label: "Eleven Flash v2.5" },
{ value: "eleven_multilingual_v2", label: "Eleven Multilingual v2" },
{ value: "eleven_turbo_v2_5", label: "Eleven Turbo v2.5" },
],
defaultVoice: "pMsXgVXv3BLzUgSXRplE",
defaultModel: "eleven_multilingual_v2",
requiresApiKey: true,
},
// OpenAI TTS — docs: https://platform.openai.com/docs/guides/text-to-speech
// Ref: internal/audio/openai/tts.go:46-51
openai: {
id: "openai",
dynamic: false,
voices: [
{ value: "alloy", label: "Alloy" },
{ value: "echo", label: "Echo" },
{ value: "fable", label: "Fable" },
{ value: "onyx", label: "Onyx" },
{ value: "nova", label: "Nova" },
{ value: "shimmer", label: "Shimmer" },
{ value: "coral", label: "Coral" },
{ value: "sage", label: "Sage" },
],
models: [
{ value: "gpt-4o-mini-tts", label: "GPT-4o Mini TTS" },
{ value: "tts-1", label: "TTS-1" },
{ value: "tts-1-hd", label: "TTS-1 HD" },
],
defaultVoice: "alloy",
defaultModel: "gpt-4o-mini-tts",
requiresApiKey: true,
},
// Microsoft Edge TTS — free, no API key required
// Ref: internal/audio/edge/tts.go:37-39
// Top-20 neural voices from edge-tts registry
edge: {
id: "edge",
dynamic: false,
voices: [
{ value: "en-US-MichelleNeural", label: "Michelle (en-US)", locale: "en-US" },
{ value: "en-US-AriaNeural", label: "Aria (en-US)", locale: "en-US" },
{ value: "en-US-GuyNeural", label: "Guy (en-US)", locale: "en-US" },
{ value: "en-GB-SoniaNeural", label: "Sonia (en-GB)", locale: "en-GB" },
{ value: "en-GB-RyanNeural", label: "Ryan (en-GB)", locale: "en-GB" },
{ value: "en-AU-NatashaNeural", label: "Natasha (en-AU)", locale: "en-AU" },
{ value: "vi-VN-HoaiMyNeural", label: "Hoài My (vi-VN)", locale: "vi-VN" },
{ value: "vi-VN-NamMinhNeural", label: "Nam Minh (vi-VN)", locale: "vi-VN" },
{ value: "zh-CN-XiaoxiaoNeural", label: "Xiaoxiao (zh-CN)", locale: "zh-CN" },
{ value: "zh-CN-YunxiNeural", label: "Yunxi (zh-CN)", locale: "zh-CN" },
{ value: "ja-JP-NanamiNeural", label: "Nanami (ja-JP)", locale: "ja-JP" },
{ value: "ko-KR-SunHiNeural", label: "Sun Hi (ko-KR)", locale: "ko-KR" },
{ value: "es-ES-ElviraNeural", label: "Elvira (es-ES)", locale: "es-ES" },
{ value: "fr-FR-DeniseNeural", label: "Denise (fr-FR)", locale: "fr-FR" },
{ value: "de-DE-KatjaNeural", label: "Katja (de-DE)", locale: "de-DE" },
{ value: "it-IT-ElsaNeural", label: "Elsa (it-IT)", locale: "it-IT" },
{ value: "pt-BR-FranciscaNeural", label: "Francisca (pt-BR)", locale: "pt-BR" },
{ value: "ru-RU-SvetlanaNeural", label: "Svetlana (ru-RU)", locale: "ru-RU" },
{ value: "hi-IN-SwaraNeural", label: "Swara (hi-IN)", locale: "hi-IN" },
{ value: "id-ID-GadisNeural", label: "Gadis (id-ID)", locale: "id-ID" },
],
models: [],
defaultVoice: "en-US-MichelleNeural",
requiresApiKey: false,
},
// MiniMax TTS — docs: https://platform.minimax.io/docs/api-reference/speech-t2a-intro
// Ref: internal/audio/minimax/tts.go:50-56
minimax: {
id: "minimax",
dynamic: false,
voices: [
{ value: "Wise_Woman", label: "Wise Woman" },
{ value: "Friendly_Person", label: "Friendly Person" },
{ value: "Inspirational_girl", label: "Inspirational Girl" },
{ value: "Deep_Voice_Man", label: "Deep Voice Man" },
{ value: "Calm_Woman", label: "Calm Woman" },
{ value: "Casual_Guy", label: "Casual Guy" },
{ value: "Lively_Girl", label: "Lively Girl" },
{ value: "Patient_Man", label: "Patient Man" },
{ value: "Young_Knight", label: "Young Knight" },
{ value: "Determined_Man", label: "Determined Man" },
{ value: "Lovely_Girl", label: "Lovely Girl" },
{ value: "Decent_Boy", label: "Decent Boy" },
{ value: "Imposing_Manner", label: "Imposing Manner" },
{ value: "Elegant_Man", label: "Elegant Man" },
{ value: "Abbess", label: "Abbess" },
{ value: "Sweet_Girl_2", label: "Sweet Girl 2" },
{ value: "Exuberant_Girl", label: "Exuberant Girl" },
],
models: [
{ value: "speech-02-hd", label: "Speech-02 HD" },
{ value: "speech-02-turbo", label: "Speech-02 Turbo" },
{ value: "speech-01-hd", label: "Speech-01 HD" },
{ value: "speech-01-turbo", label: "Speech-01 Turbo" },
],
defaultVoice: "Wise_Woman",
defaultModel: "speech-02-hd",
requiresApiKey: true,
},
};
/**
* Returns provider definition by id, or null if id is unknown/empty.
*/
export function getProviderDefinition(id: string): TtsProviderDefinition | null {
return TTS_PROVIDERS[id as TtsProviderId] ?? null;
}
+36
View File
@@ -62,6 +62,9 @@
},
"voice_label": "Voice",
"voice_placeholder": "Select a voice…",
"voice_picker": {
"requires_provider": "Configure TTS provider first"
},
"voice_refresh": "Refresh",
"voice_preview": "Preview",
"voice_stop_preview": "Stop",
@@ -70,6 +73,20 @@
"voice_preview_error": "Preview failed — refreshing list",
"model_label": "Model",
"model_placeholder": "Default (tenant/system)",
"empty_state": {
"title": "TTS not configured",
"description": "Configure a TTS provider to enable voice for agents.",
"cta": "Configure TTS",
"non_owner_hint": "Contact the workspace owner to configure TTS."
},
"override": {
"label": "Override voice for this agent",
"inherits": "Inherits: {{provider}} · {{voice}} · {{model}}"
},
"test": {
"button": "Test this voice",
"sample_text": "Hello, this is a voice test."
},
"minimax": {
"apiKey": "API Key",
"groupId": "Group ID *",
@@ -77,5 +94,24 @@
"apiBase": "API Base",
"model": "Model",
"voiceId": "Voice ID"
},
"testConnection": {
"label": "Test connection",
"testing": "Testing…",
"success": "Connection successful",
"failed": "Connection failed"
},
"playground": {
"title": "Test Playground",
"inputLabel": "Text to synthesize",
"placeholder": "Enter text to hear…",
"play": "Play",
"stop": "Stop",
"synthesizing": "Synthesizing…",
"playbackFailed": "Playback failed",
"requiresProvider": "Select a provider above to enable test playback."
},
"advanced": {
"title": "Advanced settings"
}
}
+37 -1
View File
@@ -31,6 +31,9 @@
},
"voice_label": "Giọng đọc",
"voice_placeholder": "Chọn giọng đọc…",
"voice_picker": {
"requires_provider": "Vui lòng cấu hình provider TTS trước"
},
"voice_refresh": "Làm mới",
"voice_preview": "Nghe thử",
"voice_stop_preview": "Dừng",
@@ -39,6 +42,20 @@
"voice_preview_error": "Không nghe thử được — đang làm mới",
"model_label": "Mô hình",
"model_placeholder": "Mặc định (tenant/hệ thống)",
"empty_state": {
"title": "Chưa cấu hình TTS",
"description": "Cấu hình provider TTS để bật giọng đọc cho agent.",
"cta": "Cấu hình TTS",
"non_owner_hint": "Liên hệ chủ workspace để cấu hình TTS."
},
"override": {
"label": "Ghi đè giọng đọc cho agent này",
"inherits": "Kế thừa: {{provider}} · {{voice}} · {{model}}"
},
"test": {
"button": "Thử giọng đọc",
"sample_text": "Xin chào, đây là giọng đọc thử."
},
"minimax": {
"apiBase": "API Base",
"apiKey": "API Key",
@@ -77,5 +94,24 @@
"noProvider": "Chưa cấu hình provider TTS",
"title": "Trạng thái"
},
"title": "Chuyển văn bản thành giọng nói"
"title": "Chuyển văn bản thành giọng nói",
"testConnection": {
"label": "Kiểm tra kết nối",
"testing": "Đang kiểm tra…",
"success": "Kết nối thành công",
"failed": "Kết nối thất bại"
},
"playground": {
"title": "Thử nghiệm",
"inputLabel": "Văn bản cần đọc",
"placeholder": "Nhập văn bản để nghe thử…",
"play": "Phát",
"stop": "Dừng",
"synthesizing": "Đang tổng hợp…",
"playbackFailed": "Phát âm thất bại",
"requiresProvider": "Chọn provider ở trên để bật phát thử."
},
"advanced": {
"title": "Cài đặt nâng cao"
}
}
+37 -1
View File
@@ -31,6 +31,9 @@
},
"voice_label": "声音",
"voice_placeholder": "选择声音…",
"voice_picker": {
"requires_provider": "请先配置 TTS Provider"
},
"voice_refresh": "刷新",
"voice_preview": "试听",
"voice_stop_preview": "停止",
@@ -39,6 +42,20 @@
"voice_preview_error": "试听失败 — 正在刷新",
"model_label": "模型",
"model_placeholder": "默认(租户/系统)",
"empty_state": {
"title": "未配置 TTS",
"description": "配置 TTS Provider 以为 Agent 启用语音。",
"cta": "配置 TTS",
"non_owner_hint": "请联系工作区所有者配置 TTS。"
},
"override": {
"label": "为此 Agent 覆盖语音",
"inherits": "继承: {{provider}} · {{voice}} · {{model}}"
},
"test": {
"button": "测试语音",
"sample_text": "你好,这是语音测试。"
},
"minimax": {
"apiBase": "API Base",
"apiKey": "API Key",
@@ -77,5 +94,24 @@
"noProvider": "未配置 TTS Provider",
"title": "状态"
},
"title": "语音合成"
"title": "语音合成",
"testConnection": {
"label": "测试连接",
"testing": "测试中…",
"success": "连接成功",
"failed": "连接失败"
},
"playground": {
"title": "测试播放",
"inputLabel": "要合成的文本",
"placeholder": "输入文本以试听…",
"play": "播放",
"stop": "停止",
"synthesizing": "合成中…",
"playbackFailed": "播放失败",
"requiresProvider": "请先选择 Provider 以启用试听。"
},
"advanced": {
"title": "高级设置"
}
}
@@ -0,0 +1,66 @@
/**
* Pure-logic tests for prompt-settings-section.tsx exported helpers.
*
* No DOM rendering — follows the voice-picker.test.tsx / stt-provider-form.test.tsx
* convention: test module contracts without @testing-library/react.
*/
import { describe, it, expect } from "vitest";
import { shouldRenderTTSSection, getModelOptions } from "../prompt-settings-section";
describe("shouldRenderTTSSection", () => {
it("returns false when provider is empty string", () => {
expect(shouldRenderTTSSection({ provider: "" })).toBe(false);
});
it("returns false when provider key is absent", () => {
expect(shouldRenderTTSSection({})).toBe(false);
});
it("returns false when provider is undefined", () => {
expect(shouldRenderTTSSection({ provider: undefined })).toBe(false);
});
it("returns true when provider is a non-empty string", () => {
expect(shouldRenderTTSSection({ provider: "openai" })).toBe(true);
expect(shouldRenderTTSSection({ provider: "elevenlabs" })).toBe(true);
expect(shouldRenderTTSSection({ provider: "edge" })).toBe(true);
expect(shouldRenderTTSSection({ provider: "minimax" })).toBe(true);
});
});
describe("getModelOptions", () => {
it("returns OpenAI models for provider=openai", () => {
const ids = getModelOptions("openai").map((m) => m.value);
expect(ids).toContain("gpt-4o-mini-tts");
expect(ids).toContain("tts-1");
expect(ids).toContain("tts-1-hd");
});
it("returns ElevenLabs 4-model allowlist for provider=elevenlabs", () => {
const ids = getModelOptions("elevenlabs").map((m) => m.value);
expect(ids).toEqual(
expect.arrayContaining([
"eleven_v3",
"eleven_flash_v2_5",
"eleven_multilingual_v2",
"eleven_turbo_v2_5",
]),
);
expect(ids).toHaveLength(4);
});
it("returns empty array for edge (no model concept)", () => {
expect(getModelOptions("edge")).toEqual([]);
});
it("returns MiniMax models for provider=minimax", () => {
const ids = getModelOptions("minimax").map((m) => m.value);
expect(ids).toContain("speech-02-hd");
expect(ids).toContain("speech-02-turbo");
});
it("returns empty array for unknown provider", () => {
expect(getModelOptions("unknown_provider")).toEqual([]);
expect(getModelOptions("")).toEqual([]);
});
});
@@ -1,54 +1,73 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "@/stores/use-toast-store";
import { useAuthStore } from "@/stores/use-auth-store";
import type { AgentData } from "@/types/agent";
import { readPromptMode } from "../agent-display-utils";
import { PromptModeCards, type PromptMode } from "../../prompt-mode-cards";
import { VoicePicker } from "@/components/voice-picker";
import { useTtsConfig } from "@/pages/tts/hooks/use-tts-config";
import { TtsEmptyState } from "./tts-empty-state";
import { TtsOverrideBlock } from "./tts-override-block";
import { getProviderDefinition, type TtsProviderId } from "@/data/tts-providers";
import type { TtsModelOption } from "@/data/tts-providers";
/**
* Pure helper — exported for unit testing.
* Returns true when TTS is configured globally and the TTS subsection should render.
*/
export function shouldRenderTTSSection(globalTts: { provider?: string }): boolean {
return !!globalTts.provider;
}
/**
* Pure helper — exported for unit testing.
* Returns model options for a given provider id from the catalog.
*/
export function getModelOptions(providerId: string): TtsModelOption[] {
const def = getProviderDefinition(providerId as TtsProviderId);
return def?.models ?? [];
}
interface Props {
agent: AgentData;
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
}
const TTS_MODELS = [
{ value: "eleven_v3", label: "Eleven v3" },
{ value: "eleven_flash_v2_5", label: "Eleven Flash v2.5" },
{ value: "eleven_multilingual_v2", label: "Eleven Multilingual v2" },
{ value: "eleven_turbo_v2_5", label: "Eleven Turbo v2.5" },
] as const;
export function PromptSettingsSection({ agent, onUpdate }: Props) {
const { t } = useTranslation("agents");
const { t: tTts } = useTranslation("tts");
const isOwner = useAuthStore((s) => s.isOwner);
const { tts: globalTts, synthesize } = useTtsConfig();
const globalProvider = globalTts.provider;
const otherConfig = (agent.other_config ?? {}) as Record<string, unknown>;
const savedMode = readPromptMode(agent) as PromptMode;
const savedVoiceId = (otherConfig.tts_voice_id as string) ?? "";
const savedModelId = (otherConfig.tts_model_id as string) ?? "";
const [mode, setMode] = useState<PromptMode>(savedMode);
const [ttsVoiceId, setTtsVoiceId] = useState<string>((otherConfig.tts_voice_id as string) ?? "");
const [ttsModelId, setTtsModelId] = useState<string>((otherConfig.tts_model_id as string) ?? "");
const [ttsVoiceId, setTtsVoiceId] = useState<string>(savedVoiceId);
const [ttsModelId, setTtsModelId] = useState<string>(savedModelId);
const [override, setOverride] = useState<boolean>(!!(savedVoiceId || savedModelId));
const [saving, setSaving] = useState(false);
useEffect(() => {
const cfg = (agent.other_config ?? {}) as Record<string, unknown>;
const voice = (cfg.tts_voice_id as string) ?? "";
const model = (cfg.tts_model_id as string) ?? "";
setMode(readPromptMode(agent) as PromptMode);
setTtsVoiceId((cfg.tts_voice_id as string) ?? "");
setTtsModelId((cfg.tts_model_id as string) ?? "");
setTtsVoiceId(voice);
setTtsModelId(model);
setOverride(!!(voice || model));
}, [agent.other_config]);
const savedVoiceId = (otherConfig.tts_voice_id as string) ?? "";
const savedModelId = (otherConfig.tts_model_id as string) ?? "";
const dirty = mode !== savedMode || ttsVoiceId !== savedVoiceId || ttsModelId !== savedModelId;
const savedOverride = !!(savedVoiceId || savedModelId);
const dirty =
mode !== savedMode ||
ttsVoiceId !== savedVoiceId ||
ttsModelId !== savedModelId ||
override !== savedOverride;
const handleSave = async () => {
setSaving(true);
@@ -59,12 +78,13 @@ export function PromptSettingsSection({ agent, onUpdate }: Props) {
} else {
delete bag.prompt_mode;
}
if (ttsVoiceId) {
// Write TTS override only when checkbox enabled and value provided
if (override && ttsVoiceId) {
bag.tts_voice_id = ttsVoiceId;
} else {
delete bag.tts_voice_id;
}
if (ttsModelId) {
if (override && ttsModelId) {
bag.tts_model_id = ttsModelId;
} else {
delete bag.tts_model_id;
@@ -72,7 +92,12 @@ export function PromptSettingsSection({ agent, onUpdate }: Props) {
await onUpdate({ other_config: bag });
const modeRank: Record<string, number> = { none: 0, minimal: 1, task: 2, full: 3 };
if ((modeRank[mode] ?? 3) > (modeRank[savedMode] ?? 3)) {
toast.info(t("detail.prompt.upgradeWarning", "Mode upgraded. Some files may need regeneration — use Resummon or Edit with AI in the Files tab."));
toast.info(
t(
"detail.prompt.upgradeWarning",
"Mode upgraded. Some files may need regeneration — use Resummon or Edit with AI in the Files tab.",
),
);
}
} finally {
setSaving(false);
@@ -97,28 +122,20 @@ export function PromptSettingsSection({ agent, onUpdate }: Props) {
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{tTts("title")}
</h4>
<div className="space-y-2">
<Label className="text-sm">{tTts("voice_label")}</Label>
<VoicePicker
value={ttsVoiceId || undefined}
onChange={setTtsVoiceId}
{!globalProvider ? (
<TtsEmptyState isOwner={isOwner} />
) : (
<TtsOverrideBlock
globalProvider={globalProvider}
voiceId={ttsVoiceId}
modelId={ttsModelId}
onVoiceChange={setTtsVoiceId}
onModelChange={setTtsModelId}
overrideEnabled={override}
onOverrideChange={setOverride}
synthesize={synthesize}
/>
</div>
<div className="space-y-2">
<Label className="text-sm">{tTts("model_label")}</Label>
<Select value={ttsModelId} onValueChange={setTtsModelId}>
<SelectTrigger className="w-full text-base md:text-sm">
<SelectValue placeholder={tTts("model_placeholder")} />
</SelectTrigger>
<SelectContent>
{TTS_MODELS.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</section>
);
@@ -0,0 +1,41 @@
import { VolumeXIcon } from "lucide-react";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
interface Props {
isOwner: boolean;
}
/**
* Shown in the agent detail TTS section when no global TTS provider is configured.
* Owners see a CTA to /tts; non-owners see a hint text only.
*/
export function TtsEmptyState({ isOwner }: Props) {
const { t } = useTranslation("tts");
const navigate = useNavigate();
return (
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed p-4 text-center">
<VolumeXIcon className="size-6 text-muted-foreground" />
<div className="space-y-0.5">
<p className="text-sm font-medium">{t("empty_state.title")}</p>
<p className="text-xs text-muted-foreground">{t("empty_state.description")}</p>
</div>
{isOwner ? (
<Button
size="sm"
variant="outline"
className="mt-1 min-h-[44px] sm:min-h-9"
onClick={() => navigate("/tts")}
>
{t("empty_state.cta")}
</Button>
) : (
<p className="text-xs text-muted-foreground italic">
{t("empty_state.non_owner_hint")}
</p>
)}
</div>
);
}
@@ -0,0 +1,154 @@
import { useState } from "react";
import { PlayCircleIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "@/stores/use-toast-store";
import { VoicePicker } from "@/components/voice-picker";
import { getProviderDefinition } from "@/data/tts-providers";
import type { TtsProviderId } from "@/data/tts-providers";
import type { SynthesizeParams } from "@/pages/tts/hooks/use-tts-config";
interface Props {
globalProvider: string;
voiceId: string;
modelId: string;
onVoiceChange: (v: string) => void;
onModelChange: (v: string) => void;
/** Whether agent-level override is enabled (checkbox driven by parent) */
overrideEnabled: boolean;
onOverrideChange: (v: boolean) => void;
synthesize: (params: SynthesizeParams) => Promise<Blob>;
}
/**
* Rendered inside the TTS subsection of PromptSettingsSection when global TTS is configured.
* Manages: inheritance chip, override checkbox, VoicePicker, model Select, inline test button.
*/
export function TtsOverrideBlock({
globalProvider,
voiceId,
modelId,
onVoiceChange,
onModelChange,
overrideEnabled,
onOverrideChange,
synthesize,
}: Props) {
const { t } = useTranslation("tts");
const [testing, setTesting] = useState(false);
const def = getProviderDefinition(globalProvider);
const providerLabel = globalProvider.charAt(0).toUpperCase() + globalProvider.slice(1);
const models = def?.models ?? [];
const hasModels = models.length > 0;
const canTest = overrideEnabled && !!voiceId && (hasModels ? !!modelId : true) && !!globalProvider;
const handleTest = async () => {
if (!canTest) return;
setTesting(true);
try {
const blob = await synthesize({
text: t("test.sample_text"),
provider: globalProvider,
voice_id: voiceId,
model_id: modelId || undefined,
});
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.onended = () => URL.revokeObjectURL(url);
audio.onerror = () => URL.revokeObjectURL(url);
await audio.play();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Test failed");
} finally {
setTesting(false);
}
};
const handleOverrideChange = (checked: boolean) => {
onOverrideChange(checked);
if (!checked) {
onVoiceChange("");
onModelChange("");
}
};
return (
<div className="space-y-3">
{/* Inheritance info chip */}
<p className="text-xs text-muted-foreground bg-muted/50 rounded px-2 py-1 inline-block">
{t("override.inherits", {
provider: providerLabel,
voice: globalProvider === "elevenlabs" ? t("voice_label") : (def?.defaultVoice ?? "–"),
model: def?.defaultModel ?? "–",
})}
</p>
{/* Override checkbox */}
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
className="size-4 rounded accent-primary"
checked={overrideEnabled}
onChange={(e) => handleOverrideChange(e.target.checked)}
/>
<span className="text-sm">{t("override.label")}</span>
</label>
{overrideEnabled && (
<div className="space-y-2 pl-6">
{/* Voice picker — provider-aware */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{t("voice_label")}</Label>
<VoicePicker
provider={(globalProvider as TtsProviderId) || undefined}
value={voiceId || undefined}
onChange={onVoiceChange}
/>
</div>
{/* Model select — catalog-driven; hidden for providers with no models (edge) */}
{hasModels && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{t("model_label")}</Label>
<Select value={modelId} onValueChange={onModelChange}>
<SelectTrigger className="w-full text-base md:text-sm">
<SelectValue placeholder={t("model_placeholder")} />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Inline test button */}
<Button
type="button"
size="sm"
variant="outline"
disabled={!canTest || testing}
onClick={handleTest}
className="min-h-[44px] sm:min-h-9 gap-1.5"
>
<PlayCircleIcon className="size-4" />
{testing ? "..." : t("test.button")}
</Button>
</div>
)}
</div>
);
}
@@ -17,13 +17,11 @@ import { MediaProviderChainForm } from "./media-provider-chain-form";
import { KGSettingsForm } from "./kg-settings-form";
import { WebFetchExtractorChainForm } from "./web-fetch-extractor-chain-form";
import { WebSearchChainForm } from "./web-search-chain-form";
import { TtsProviderForm } from "./tts-provider-form";
import { SttProviderForm } from "./stt-provider-form";
const KG_TOOL = "knowledge_graph_search";
const WEB_FETCH_TOOL = "web_fetch";
const WEB_SEARCH_TOOL = "web_search";
const TTS_TOOL = "tts";
const STT_TOOL = "stt";
interface Props {
@@ -62,7 +60,6 @@ export function BuiltinToolSettingsDialog({
const isKG = tool?.name === KG_TOOL;
const isWebFetch = tool?.name === WEB_FETCH_TOOL;
const isWebSearch = tool?.name === WEB_SEARCH_TOOL;
const isTts = tool?.name === TTS_TOOL;
const isStt = tool?.name === STT_TOOL;
const wide = isMedia || isKG || isWebFetch || isWebSearch;
@@ -93,12 +90,6 @@ export function BuiltinToolSettingsDialog({
onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))}
onCancel={() => onOpenChange(false)}
/>
) : isTts && tool ? (
<TtsProviderForm
initialSettings={initialSettings}
onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))}
onCancel={() => onOpenChange(false)}
/>
) : isStt && tool ? (
<SttProviderForm
initialSettings={initialSettings}
@@ -19,6 +19,10 @@ const CATEGORY_ORDER = [
"sessions", "messaging", "scheduling", "subagents", "skills", "delegation", "teams",
];
// Tools that have dedicated configuration pages elsewhere and should not appear
// in the builtin-tools list. TTS config moved to /tts page.
const HIDDEN_TOOL_NAMES = new Set(["tts"]);
// Hardcoded master tenant UUID; mirrors backend store.MasterTenantID. When
// the current tenant is NOT the master, UI routes settings writes through
// the tenant-config endpoint. Backend enforces the same rule defensively —
@@ -60,12 +64,14 @@ export function BuiltinToolsPage() {
[tools],
);
const filtered = tools.filter(
(t) =>
t.name.toLowerCase().includes(search.toLowerCase()) ||
t.display_name.toLowerCase().includes(search.toLowerCase()) ||
t.description.toLowerCase().includes(search.toLowerCase()),
);
const filtered = tools
.filter((t) => !HIDDEN_TOOL_NAMES.has(t.name))
.filter(
(t) =>
t.name.toLowerCase().includes(search.toLowerCase()) ||
t.display_name.toLowerCase().includes(search.toLowerCase()) ||
t.description.toLowerCase().includes(search.toLowerCase()),
);
const grouped = new Map<string, BuiltinToolData[]>();
for (const tool of filtered) {
@@ -1,125 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
type TtsProvider = "openai" | "elevenlabs" | "edge" | "minimax";
type AutoMode = "off" | "inbound" | "tagged" | "always";
interface Props {
initialSettings: Record<string, unknown>;
onSave: (settings: Record<string, unknown>) => Promise<void>;
onCancel: () => void;
}
const TTS_PROVIDERS: TtsProvider[] = ["openai", "elevenlabs", "edge", "minimax"];
const AUTO_MODES: AutoMode[] = ["off", "inbound", "tagged", "always"];
const AUTO_MODE_DESC_KEYS: Record<AutoMode, string> = {
off: "builtin.ttsForm.autoOffDesc",
inbound: "builtin.ttsForm.autoInboundDesc",
tagged: "builtin.ttsForm.autoTaggedDesc",
always: "builtin.ttsForm.autoAlwaysDesc",
};
const AUTO_MODE_LABEL_KEYS: Record<AutoMode, string> = {
off: "builtin.ttsForm.autoOff",
inbound: "builtin.ttsForm.autoInbound",
tagged: "builtin.ttsForm.autoTagged",
always: "builtin.ttsForm.autoAlways",
};
export function TtsProviderForm({ initialSettings, onSave, onCancel }: Props) {
const { t } = useTranslation("tools");
const [primary, setPrimary] = useState<TtsProvider>(
(initialSettings.primary as TtsProvider) ?? "openai",
);
const [auto, setAuto] = useState<AutoMode>(
(initialSettings.auto as AutoMode) ?? "inbound",
);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await onSave({ primary, auto });
} catch {
// toast shown by hook
} finally {
setSaving(false);
}
};
return (
<div>
<DialogHeader>
<DialogTitle>{t("builtin.ttsForm.title")}</DialogTitle>
<DialogDescription>{t("builtin.ttsForm.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-5 my-4">
{/* Primary provider */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t("builtin.ttsForm.primary")}</Label>
<Select value={primary} onValueChange={(v) => setPrimary(v as TtsProvider)}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TTS_PROVIDERS.map((p) => (
<SelectItem key={p} value={p} className="text-base md:text-sm">
{p.charAt(0).toUpperCase() + p.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Auto mode */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t("builtin.ttsForm.auto")}</Label>
<Select value={auto} onValueChange={(v) => setAuto(v as AutoMode)}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUTO_MODES.map((mode) => (
<SelectItem key={mode} value={mode} className="text-base md:text-sm">
<div>
<div className="font-medium">{t(AUTO_MODE_LABEL_KEYS[mode])}</div>
<div className="text-xs text-muted-foreground">{t(AUTO_MODE_DESC_KEYS[mode])}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
{t("builtin.ttsForm.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{saving ? t("builtin.ttsForm.saving") : t("builtin.ttsForm.save")}
</Button>
</DialogFooter>
</div>
);
}
+28 -2
View File
@@ -1,6 +1,6 @@
import { useState, useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWs } from "@/hooks/use-ws";
import { useWs, useHttp } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
@@ -45,8 +45,16 @@ const DEFAULT_TTS: TtsConfig = {
minimax: {},
};
export interface SynthesizeParams {
text: string;
provider?: string;
voice_id?: string;
model_id?: string;
}
export function useTtsConfig() {
const ws = useWs();
const http = useHttp();
const connected = useAuthStore((s) => s.connected);
const queryClient = useQueryClient();
const [saving, setSaving] = useState(false);
@@ -88,5 +96,23 @@ export function useTtsConfig() {
[ws, invalidate],
);
return { tts, loading, saving, error, refresh: invalidate, save };
// POST→Blob not in HttpClient; use fetch + getAuthHeaders() for tenant/user header parity.
// See: http-client.ts:107-109 — getAuthHeaders() returns Authorization + X-GoClaw-* headers.
const synthesize = useCallback(
async (params: SynthesizeParams): Promise<Blob> => {
const res = await fetch("/v1/tts/synthesize", {
method: "POST",
headers: { "Content-Type": "application/json", ...http.getAuthHeaders() },
body: JSON.stringify(params),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Synthesis failed (${res.status})`);
}
return res.blob();
},
[http],
);
return { tts, loading, saving, error, refresh: invalidate, save, synthesize };
}
@@ -0,0 +1,77 @@
/**
* Pure-logic tests for test-playground.tsx exported helpers.
*
* No DOM rendering — follows the voice-picker.test.tsx / stt-provider-form.test.tsx
* convention: test module contracts without @testing-library/react.
*/
import { describe, it, expect } from "vitest";
import { canPlay, buildSynthesizeRequest } from "../test-playground";
describe("canPlay", () => {
it("returns false when text is empty", () => {
expect(canPlay({ text: "", provider: "openai" })).toBe(false);
});
it("returns false when text is whitespace-only", () => {
expect(canPlay({ text: " ", provider: "openai" })).toBe(false);
});
it("returns false when provider is empty string", () => {
expect(canPlay({ text: "hello", provider: "" })).toBe(false);
});
it("returns false when text exceeds 500 chars", () => {
expect(canPlay({ text: "a".repeat(501), provider: "openai" })).toBe(false);
});
it("returns true for valid text at exactly 500 chars", () => {
expect(canPlay({ text: "a".repeat(500), provider: "openai" })).toBe(true);
});
it("returns true for valid text and provider", () => {
expect(canPlay({ text: "hello", provider: "openai" })).toBe(true);
expect(canPlay({ text: "hello", provider: "edge" })).toBe(true);
expect(canPlay({ text: "hello", provider: "elevenlabs" })).toBe(true);
});
});
describe("buildSynthesizeRequest", () => {
it("includes required text and provider fields", () => {
const r = buildSynthesizeRequest({ text: "hello", provider: "edge" });
expect(r.text).toBe("hello");
expect(r.provider).toBe("edge");
});
it("omits voice_id and model_id when not provided", () => {
const r = buildSynthesizeRequest({ text: "hello", provider: "edge" });
expect(r).toEqual({ text: "hello", provider: "edge" });
expect("voice_id" in r).toBe(false);
expect("model_id" in r).toBe(false);
});
it("omits voice_id when voiceId is empty string", () => {
const r = buildSynthesizeRequest({ text: "hello", provider: "edge", voiceId: "" });
expect("voice_id" in r).toBe(false);
});
it("omits model_id when modelId is empty string", () => {
const r = buildSynthesizeRequest({ text: "hello", provider: "openai", modelId: "" });
expect("model_id" in r).toBe(false);
});
it("includes voice_id and model_id when both are provided", () => {
const r = buildSynthesizeRequest({
text: "hello",
provider: "openai",
voiceId: "alloy",
modelId: "tts-1",
});
expect(r).toEqual({ text: "hello", provider: "openai", voice_id: "alloy", model_id: "tts-1" });
});
it("includes only voice_id when modelId is absent", () => {
const r = buildSynthesizeRequest({ text: "hi", provider: "edge", voiceId: "en-US-AriaNeural" });
expect(r).toEqual({ text: "hi", provider: "edge", voice_id: "en-US-AriaNeural" });
expect("model_id" in r).toBe(false);
});
});
@@ -0,0 +1,124 @@
/**
* Behavior section — collapsible "Advanced settings" panel.
* Contains: auto mode, reply mode, max_length, timeout_ms.
* Collapsed by default to reduce visual noise for the common 4-step flow.
* Uses local useState + chevron (no Radix Collapsible dependency needed).
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { TtsConfig } from "../hooks/use-tts-config";
const AUTO_MODE_VALUES = ["off", "always", "inbound", "tagged"] as const;
const REPLY_MODE_VALUES = ["final", "all"] as const;
interface Props {
draft: TtsConfig;
onUpdate: (patch: Partial<TtsConfig>) => void;
}
export function BehaviorSection({ draft, onUpdate }: Props) {
const { t } = useTranslation("tts");
const [open, setOpen] = useState(false);
return (
<Card>
<CardHeader className="pb-0">
<button
type="button"
className="flex w-full items-center justify-between py-1 text-left"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className="text-sm font-medium">
{t("advanced.title", "Advanced settings")}
</span>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
open && "rotate-180",
)}
/>
</button>
</CardHeader>
{open && (
<CardContent className="space-y-4 pt-4">
{/* Auto mode */}
<div className="grid gap-1.5">
<Label>{t("general.autoApplyMode")}</Label>
<div className="flex flex-wrap gap-2">
{AUTO_MODE_VALUES.map((v) => (
<Button
key={v}
type="button"
variant={draft.auto === v ? "default" : "outline"}
size="sm"
className="h-9 min-w-[44px]"
onClick={() => onUpdate({ auto: v })}
title={t(`autoModes.${v}Desc`)}
>
{t(`autoModes.${v}`)}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{t(`autoModes.${draft.auto}Desc`)}
</p>
</div>
{/* Reply mode */}
<div className="grid gap-1.5">
<Label>{t("general.replyMode")}</Label>
<div className="flex flex-wrap gap-2">
{REPLY_MODE_VALUES.map((v) => (
<Button
key={v}
type="button"
variant={draft.mode === v ? "default" : "outline"}
size="sm"
className="h-9 min-w-[44px]"
onClick={() => onUpdate({ mode: v })}
title={t(`replyModes.${v}Desc`)}
>
{t(`replyModes.${v}`)}
</Button>
))}
</div>
</div>
{/* Max length & timeout */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="tts-maxlen">{t("general.maxTextLength")}</Label>
<Input
id="tts-maxlen"
type="number"
className="text-base md:text-sm"
value={draft.max_length}
onChange={(e) => onUpdate({ max_length: Number(e.target.value) })}
min={10}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tts-timeout">{t("general.timeout")}</Label>
<Input
id="tts-timeout"
type="number"
className="text-base md:text-sm"
value={draft.timeout_ms}
onChange={(e) => onUpdate({ timeout_ms: Number(e.target.value) })}
min={1000}
/>
</div>
</div>
</CardContent>
)}
</Card>
);
}
@@ -0,0 +1,135 @@
/**
* Credentials section — Step 2 of the TTS configuration flow.
* Renders provider-specific credential inputs (API key, base URL, group ID).
* Edge TTS is skipped (requiresApiKey = false).
* Includes a "Test connection" button that calls synthesize() with a short sample.
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FlaskConical } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "@/stores/use-toast-store";
import { getProviderDefinition } from "@/data/tts-providers";
import type { TtsConfig, TtsProviderConfig, SynthesizeParams } from "../hooks/use-tts-config";
interface Props {
provider: string;
draft: TtsConfig;
onUpdate: (
providerKey: keyof Pick<TtsConfig, "openai" | "elevenlabs" | "edge" | "minimax">,
patch: Partial<TtsProviderConfig>,
) => void;
synthesize: (params: SynthesizeParams) => Promise<Blob>;
}
export function CredentialsSection({ provider, draft, onUpdate, synthesize }: Props) {
const { t } = useTranslation("tts");
const [testing, setTesting] = useState(false);
const def = getProviderDefinition(provider);
// Edge TTS is free — no credentials to configure
if (!def || !def.requiresApiKey) return null;
const handleTestConnection = async () => {
setTesting(true);
try {
await synthesize({ text: "Hello, this is a connection test.", provider });
toast.success(t("testConnection.success", "Connection successful"));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(t("testConnection.failed", "Connection failed"), msg);
} finally {
setTesting(false);
}
};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">
2. {t("providerSettings", { provider: t(`providers.${provider}`) })}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{provider === "openai" && (
<>
<div className="grid gap-1.5">
<Label htmlFor="oai-key">{t("openai.apiKey")}</Label>
<Input id="oai-key" type="password" className="text-base md:text-sm"
value={draft.openai.api_key ?? ""}
onChange={(e) => onUpdate("openai", { api_key: e.target.value })}
placeholder="sk-..." />
</div>
<div className="grid gap-1.5">
<Label htmlFor="oai-base">{t("openai.apiBase")}</Label>
<Input id="oai-base" className="text-base md:text-sm"
value={draft.openai.api_base ?? ""}
onChange={(e) => onUpdate("openai", { api_base: e.target.value })}
placeholder="https://api.openai.com/v1" />
</div>
</>
)}
{provider === "elevenlabs" && (
<>
<div className="grid gap-1.5">
<Label htmlFor="el-key">{t("elevenlabs.apiKey")}</Label>
<Input id="el-key" type="password" className="text-base md:text-sm"
value={draft.elevenlabs.api_key ?? ""}
onChange={(e) => onUpdate("elevenlabs", { api_key: e.target.value })}
placeholder="xi-..." />
</div>
<div className="grid gap-1.5">
<Label htmlFor="el-base">{t("elevenlabs.baseUrl")}</Label>
<Input id="el-base" className="text-base md:text-sm"
value={draft.elevenlabs.base_url ?? ""}
onChange={(e) => onUpdate("elevenlabs", { base_url: e.target.value })}
placeholder="https://api.elevenlabs.io" />
</div>
</>
)}
{provider === "minimax" && (
<>
<div className="grid gap-1.5">
<Label htmlFor="mm-key">{t("minimax.apiKey")}</Label>
<Input id="mm-key" type="password" className="text-base md:text-sm"
value={draft.minimax.api_key ?? ""}
onChange={(e) => onUpdate("minimax", { api_key: e.target.value })}
placeholder="eyJh..." />
</div>
<div className="grid gap-1.5">
<Label htmlFor="mm-group">{t("minimax.groupId")}</Label>
<Input id="mm-group" className="text-base md:text-sm"
value={draft.minimax.group_id ?? ""}
onChange={(e) => onUpdate("minimax", { group_id: e.target.value })}
placeholder={t("minimax.groupIdPlaceholder")} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="mm-base">{t("minimax.apiBase")}</Label>
<Input id="mm-base" className="text-base md:text-sm"
value={draft.minimax.api_base ?? ""}
onChange={(e) => onUpdate("minimax", { api_base: e.target.value })}
placeholder="https://api.minimax.io/v1" />
</div>
</>
)}
<Button
type="button"
variant="outline"
size="sm"
className="h-9 gap-1.5"
disabled={testing}
onClick={handleTestConnection}
>
<FlaskConical className="h-3.5 w-3.5" />
{testing ? t("testConnection.testing", "Testing…") : t("testConnection.label", "Test connection")}
</Button>
</CardContent>
</Card>
);
}
@@ -0,0 +1,61 @@
/**
* Provider Setup section — Step 1 of the TTS configuration flow.
* Renders a button group for selecting the active TTS provider.
* Selecting a provider reveals the Credentials + Voice/Model sections.
*/
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { getProviderDefinition } from "@/data/tts-providers";
const PROVIDER_VALUES = ["", "openai", "elevenlabs", "edge", "minimax"] as const;
interface Props {
provider: string;
onChange: (provider: string) => void;
}
export function ProviderSetup({ provider, onChange }: Props) {
const { t } = useTranslation("tts");
const hint = (() => {
if (!provider) return null;
if (provider === "edge") return t("edge.hint");
const def = getProviderDefinition(provider);
if (def && !def.requiresApiKey) return t("edge.hint");
return null;
})();
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">
1. {t("general.primaryProvider")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-1.5">
<Label className="sr-only">{t("general.primaryProvider")}</Label>
<div className="flex flex-wrap gap-2">
{PROVIDER_VALUES.map((v) => (
<Button
key={v}
type="button"
variant={provider === v ? "default" : "outline"}
size="sm"
className="h-9 min-w-[44px]"
onClick={() => onChange(v)}
>
{t(`providers.${v || "none"}`)}
</Button>
))}
</div>
</div>
{hint && (
<p className="text-xs text-muted-foreground">{hint}</p>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,169 @@
/**
* Test Playground section — Step 4 of the TTS configuration flow.
* Textarea (max 500 chars) + Play/Stop buttons that stream audio from
* POST /v1/tts/synthesize via the synthesize() hook method.
* Object URLs are revoked on replace and on unmount to prevent memory leaks.
*/
import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Play, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "@/stores/use-toast-store";
import type { SynthesizeParams } from "../hooks/use-tts-config";
const MAX_CHARS = 500;
/**
* Pure helper — exported for unit testing.
* Returns true when the current form state allows audio playback.
*/
export function canPlay({ text, provider }: { text: string; provider: string }): boolean {
return text.trim().length > 0 && text.length <= MAX_CHARS && provider !== "";
}
/**
* Pure helper — exported for unit testing.
* Builds the synthesize API request payload, omitting undefined optional fields.
*/
export function buildSynthesizeRequest(opts: {
text: string;
provider: string;
voiceId?: string;
modelId?: string;
}): { text: string; provider: string; voice_id?: string; model_id?: string } {
const req: Record<string, unknown> = { text: opts.text, provider: opts.provider };
if (opts.voiceId) req.voice_id = opts.voiceId;
if (opts.modelId) req.model_id = opts.modelId;
return req as { text: string; provider: string; voice_id?: string; model_id?: string };
}
interface Props {
provider: string;
voiceId: string;
modelId: string;
synthesize: (params: SynthesizeParams) => Promise<Blob>;
}
export function TestPlayground({ provider, voiceId, modelId, synthesize }: Props) {
const { t } = useTranslation("tts");
const [text, setText] = useState("");
const [playing, setPlaying] = useState(false);
const [loading, setLoading] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentUrlRef = useRef<string | null>(null);
// Revoke the previous object URL to prevent memory leaks
const revokeCurrentUrl = useCallback(() => {
if (currentUrlRef.current) {
URL.revokeObjectURL(currentUrlRef.current);
currentUrlRef.current = null;
}
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
audioRef.current?.pause();
revokeCurrentUrl();
};
}, [revokeCurrentUrl]);
const handlePlay = async () => {
if (!text.trim() || !provider) return;
setLoading(true);
try {
const blob = await synthesize({
text: text.trim(),
provider,
voice_id: voiceId || undefined,
model_id: modelId || undefined,
});
revokeCurrentUrl();
const url = URL.createObjectURL(blob);
currentUrlRef.current = url;
if (!audioRef.current) {
audioRef.current = new Audio();
audioRef.current.onended = () => setPlaying(false);
audioRef.current.onerror = () => setPlaying(false);
}
audioRef.current.src = url;
await audioRef.current.play();
setPlaying(true);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(t("playground.playbackFailed", "Playback failed"), msg);
} finally {
setLoading(false);
}
};
const handleStop = () => {
audioRef.current?.pause();
if (audioRef.current) audioRef.current.currentTime = 0;
setPlaying(false);
};
const canPlayNow = canPlay({ text, provider }) && !loading;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">4. {t("playground.title", "Test Playground")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="tts-test-text">{t("playground.inputLabel", "Text to synthesize")}</Label>
<span className="text-xs text-muted-foreground">
{text.length}/{MAX_CHARS}
</span>
</div>
<Textarea
id="tts-test-text"
className="text-base md:text-sm min-h-[80px] resize-none"
placeholder={t("playground.placeholder", "Enter text to hear…")}
maxLength={MAX_CHARS}
value={text}
onChange={(e) => setText(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button
type="button"
size="default"
className="h-11 gap-1.5"
disabled={!canPlayNow || playing}
onClick={handlePlay}
>
<Play className="h-4 w-4" />
{loading ? t("playground.synthesizing", "Synthesizing…") : t("playground.play", "Play")}
</Button>
{playing && (
<Button
type="button"
variant="outline"
size="default"
className="h-11 gap-1.5"
onClick={handleStop}
>
<Square className="h-4 w-4" />
{t("playground.stop", "Stop")}
</Button>
)}
</div>
{!provider && (
<p className="text-xs text-muted-foreground">
{t("playground.requiresProvider", "Select a provider above to enable test playback.")}
</p>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,83 @@
/**
* Voice & Model section — Step 3 of the TTS configuration flow.
* Uses the provider-aware VoicePicker (Phase 01) for voice selection.
* Model select is driven by catalog; hidden when provider has no models (Edge).
*/
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { VoicePicker } from "@/components/voice-picker";
import { getProviderDefinition, type TtsProviderId } from "@/data/tts-providers";
interface Props {
provider: string;
voiceId: string;
modelId: string;
onVoiceChange: (id: string) => void;
onModelChange: (id: string) => void;
}
export function VoiceModelSection({
provider,
voiceId,
modelId,
onVoiceChange,
onModelChange,
}: Props) {
const { t } = useTranslation("tts");
const def = provider ? getProviderDefinition(provider) : null;
const models = def?.models ?? [];
// Cast to TtsProviderId | "" — empty string shows the disabled empty-state in VoicePicker
const pickerProvider = (provider as TtsProviderId | "") || "";
// Edge uses "voice" field (not voice_id) — but VoicePicker drives a single string
// The parent maps onVoiceChange to the correct provider sub-field
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">3. {t("voice_label")} &amp; {t("model_label")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-1.5">
<Label>{t("voice_label")}</Label>
<VoicePicker
provider={pickerProvider}
value={voiceId}
onChange={onVoiceChange}
/>
</div>
{models.length > 0 && (
<div className="grid gap-1.5">
<Label>{t("model_label")}</Label>
<Select value={modelId || ""} onValueChange={onModelChange}>
<SelectTrigger className="w-full text-base md:text-sm">
<SelectValue placeholder={t("model_placeholder")} />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
{m.description && (
<span className="ml-1.5 text-xs text-muted-foreground">
{m.description}
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</CardContent>
</Card>
);
}
+100 -222
View File
@@ -1,26 +1,74 @@
/**
* TTS configuration page — 4-step guided flow:
* 1. Provider selection (ProviderSetup)
* 2. Credentials (CredentialsSection) — skipped for Edge (no API key)
* 3. Voice & Model (VoiceModelSection)
* 4. Test Playground (TestPlayground)
* + Advanced collapsible: auto mode, reply mode, limits (BehaviorSection)
*
* State is lifted here: draft + dirty tracking. Sections are controlled components.
* Save flow unchanged: CONFIG_PATCH WS call via useTtsConfig().save().
*/
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Volume2, RefreshCw, Save } from "lucide-react";
import { RefreshCw, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { PageHeader } from "@/components/shared/page-header";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { useTtsConfig, type TtsConfig, type TtsProviderConfig } from "./hooks/use-tts-config";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { ProviderSetup } from "./sections/provider-setup";
import { CredentialsSection } from "./sections/credentials-section";
import { VoiceModelSection } from "./sections/voice-model-section";
import { TestPlayground } from "./sections/test-playground";
import { BehaviorSection } from "./sections/behavior-section";
const PROVIDER_VALUES = ["", "openai", "elevenlabs", "edge", "minimax"] as const;
const AUTO_MODE_VALUES = ["off", "always", "inbound", "tagged"] as const;
const REPLY_MODE_VALUES = ["final", "all"] as const;
// Per-provider helpers: extract voice/model id from the provider sub-config
function getVoiceId(draft: TtsConfig): string {
switch (draft.provider) {
case "openai": return draft.openai.voice ?? "";
case "elevenlabs": return draft.elevenlabs.voice_id ?? "";
case "edge": return draft.edge.voice ?? "";
case "minimax": return draft.minimax.voice_id ?? "";
default: return "";
}
}
function getModelId(draft: TtsConfig): string {
switch (draft.provider) {
case "openai": return draft.openai.model ?? "";
case "elevenlabs": return draft.elevenlabs.model_id ?? "";
case "minimax": return draft.minimax.model ?? "";
default: return "";
}
}
type ProviderKey = keyof Pick<TtsConfig, "openai" | "elevenlabs" | "edge" | "minimax">;
function voicePatch(provider: string, value: string): [ProviderKey, Partial<TtsProviderConfig>] | null {
switch (provider) {
case "openai": return ["openai", { voice: value }];
case "elevenlabs": return ["elevenlabs", { voice_id: value }];
case "edge": return ["edge", { voice: value }];
case "minimax": return ["minimax", { voice_id: value }];
default: return null;
}
}
function modelPatch(provider: string, value: string): [ProviderKey, Partial<TtsProviderConfig>] | null {
switch (provider) {
case "openai": return ["openai", { model: value }];
case "elevenlabs": return ["elevenlabs", { model_id: value }];
case "minimax": return ["minimax", { model: value }];
default: return null;
}
}
export function TtsPage() {
const { t } = useTranslation("tts");
const { t: tc } = useTranslation("common");
const { tts, loading, saving, error, refresh, save } = useTtsConfig();
const { tts, loading, saving, error, refresh, save, synthesize } = useTtsConfig();
const spinning = useMinLoading(loading);
const [draft, setDraft] = useState<TtsConfig>(tts);
@@ -37,11 +85,21 @@ export function TtsPage() {
setDirty(true);
};
const updateProvider = (provider: keyof Pick<TtsConfig, "openai" | "elevenlabs" | "edge" | "minimax">, patch: Partial<TtsProviderConfig>) => {
setDraft((prev) => ({ ...prev, [provider]: { ...prev[provider], ...patch } }));
const updateProvider = (key: ProviderKey, patch: Partial<TtsProviderConfig>) => {
setDraft((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }));
setDirty(true);
};
const handleVoiceChange = (value: string) => {
const p = voicePatch(draft.provider, value);
if (p) updateProvider(p[0], p[1]);
};
const handleModelChange = (value: string) => {
const p = modelPatch(draft.provider, value);
if (p) updateProvider(p[0], p[1]);
};
const handleSave = async () => {
await save(draft);
setDirty(false);
@@ -51,15 +109,13 @@ export function TtsPage() {
return (
<div className="p-4 sm:p-6 pb-10">
<PageHeader title={t("title")} description={t("description")} />
<div className="mt-4">
<TableSkeleton rows={3} />
</div>
<div className="mt-4"><TableSkeleton rows={3} /></div>
</div>
);
}
return (
<div className="p-4 sm:p-6 pb-10">
<div className="p-4 sm:p-6 pb-10 space-y-4">
<PageHeader
title={t("title")}
description={t("description")}
@@ -75,215 +131,37 @@ export function TtsPage() {
}
/>
{/* Status */}
<Card className="mt-4">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Volume2 className="h-5 w-5" />
<CardTitle className="text-base">{t("status.title")}</CardTitle>
<Badge variant={draft.provider ? "default" : "secondary"}>
{draft.provider ? t("status.configured") : t("status.disabled")}
</Badge>
</div>
<CardDescription>
{draft.provider
? t("status.activeProvider", { provider: draft.provider, auto: draft.auto })
: t("status.noProvider")}
</CardDescription>
</CardHeader>
</Card>
<ProviderSetup provider={draft.provider} onChange={(v) => update({ provider: v })} />
{/* General Settings */}
<Card className="mt-4">
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("general.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Provider */}
<div className="grid gap-1.5">
<Label>{t("general.primaryProvider")}</Label>
<div className="flex flex-wrap gap-2">
{PROVIDER_VALUES.map((v) => (
<Button
key={v}
type="button"
variant={draft.provider === v ? "default" : "outline"}
size="sm"
onClick={() => update({ provider: v })}
>
{t(`providers.${v || "none"}`)}
</Button>
))}
</div>
</div>
{/* Auto mode */}
<div className="grid gap-1.5">
<Label>{t("general.autoApplyMode")}</Label>
<div className="flex flex-wrap gap-2">
{AUTO_MODE_VALUES.map((v) => (
<Button
key={v}
type="button"
variant={draft.auto === v ? "default" : "outline"}
size="sm"
onClick={() => update({ auto: v })}
title={t(`autoModes.${v}Desc`)}
>
{t(`autoModes.${v}`)}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{t(`autoModes.${draft.auto}Desc`)}
</p>
</div>
{/* Reply mode */}
<div className="grid gap-1.5">
<Label>{t("general.replyMode")}</Label>
<div className="flex gap-2">
{REPLY_MODE_VALUES.map((v) => (
<Button
key={v}
type="button"
variant={draft.mode === v ? "default" : "outline"}
size="sm"
onClick={() => update({ mode: v })}
title={t(`replyModes.${v}Desc`)}
>
{t(`replyModes.${v}`)}
</Button>
))}
</div>
</div>
{/* Max length & timeout */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="tts-maxlen">{t("general.maxTextLength")}</Label>
<Input
id="tts-maxlen"
type="number"
value={draft.max_length}
onChange={(e) => update({ max_length: Number(e.target.value) })}
min={10}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tts-timeout">{t("general.timeout")}</Label>
<Input
id="tts-timeout"
type="number"
value={draft.timeout_ms}
onChange={(e) => update({ timeout_ms: Number(e.target.value) })}
min={1000}
/>
</div>
</div>
</CardContent>
</Card>
{/* Provider-specific settings */}
{draft.provider && draft.provider !== "" && (
<Card className="mt-4">
<CardHeader className="pb-3">
<CardTitle className="text-base">
{t("providerSettings", { provider: t(`providers.${draft.provider}`) })}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{draft.provider === "openai" && (
<>
<div className="grid gap-1.5">
<Label htmlFor="oai-key">{t("openai.apiKey")}</Label>
<Input id="oai-key" type="password" value={draft.openai.api_key ?? ""} onChange={(e) => updateProvider("openai", { api_key: e.target.value })} placeholder="sk-..." />
</div>
<div className="grid gap-1.5">
<Label htmlFor="oai-base">{t("openai.apiBase")}</Label>
<Input id="oai-base" value={draft.openai.api_base ?? ""} onChange={(e) => updateProvider("openai", { api_base: e.target.value })} placeholder="https://api.openai.com/v1" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="oai-model">{t("openai.model")}</Label>
<Input id="oai-model" value={draft.openai.model ?? ""} onChange={(e) => updateProvider("openai", { model: e.target.value })} placeholder="gpt-4o-mini-tts" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="oai-voice">{t("openai.voice")}</Label>
<Input id="oai-voice" value={draft.openai.voice ?? ""} onChange={(e) => updateProvider("openai", { voice: e.target.value })} placeholder="alloy" />
</div>
</div>
</>
)}
{draft.provider === "elevenlabs" && (
<>
<div className="grid gap-1.5">
<Label htmlFor="el-key">{t("elevenlabs.apiKey")}</Label>
<Input id="el-key" type="password" value={draft.elevenlabs.api_key ?? ""} onChange={(e) => updateProvider("elevenlabs", { api_key: e.target.value })} placeholder="xi-..." />
</div>
<div className="grid gap-1.5">
<Label htmlFor="el-base">{t("elevenlabs.baseUrl")}</Label>
<Input id="el-base" value={draft.elevenlabs.base_url ?? ""} onChange={(e) => updateProvider("elevenlabs", { base_url: e.target.value })} placeholder="https://api.elevenlabs.io" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="el-voice">{t("elevenlabs.voiceId")}</Label>
<Input id="el-voice" value={draft.elevenlabs.voice_id ?? ""} onChange={(e) => updateProvider("elevenlabs", { voice_id: e.target.value })} placeholder="pMsXgVXv3BLzUgSXRplE" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="el-model">{t("elevenlabs.modelId")}</Label>
<Input id="el-model" value={draft.elevenlabs.model_id ?? ""} onChange={(e) => updateProvider("elevenlabs", { model_id: e.target.value })} placeholder="eleven_multilingual_v2" />
</div>
</div>
</>
)}
{draft.provider === "edge" && (
<>
<div className="grid gap-1.5">
<Label htmlFor="edge-voice">{t("edge.voice")}</Label>
<Input id="edge-voice" value={draft.edge.voice ?? ""} onChange={(e) => updateProvider("edge", { voice: e.target.value })} placeholder="en-US-MichelleNeural" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="edge-rate">{t("edge.speechRate")}</Label>
<Input id="edge-rate" value={draft.edge.rate ?? ""} onChange={(e) => updateProvider("edge", { rate: e.target.value })} placeholder="+0%" />
</div>
<p className="text-xs text-muted-foreground">{t("edge.hint")}</p>
</>
)}
{draft.provider === "minimax" && (
<>
<div className="grid gap-1.5">
<Label htmlFor="mm-key">{t("minimax.apiKey")}</Label>
<Input id="mm-key" type="password" value={draft.minimax.api_key ?? ""} onChange={(e) => updateProvider("minimax", { api_key: e.target.value })} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="mm-group">{t("minimax.groupId")}</Label>
<Input id="mm-group" value={draft.minimax.group_id ?? ""} onChange={(e) => updateProvider("minimax", { group_id: e.target.value })} placeholder={t("minimax.groupIdPlaceholder")} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="mm-base">{t("minimax.apiBase")}</Label>
<Input id="mm-base" value={draft.minimax.api_base ?? ""} onChange={(e) => updateProvider("minimax", { api_base: e.target.value })} placeholder="https://api.minimax.io/v1" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="mm-model">{t("minimax.model")}</Label>
<Input id="mm-model" value={draft.minimax.model ?? ""} onChange={(e) => updateProvider("minimax", { model: e.target.value })} placeholder="speech-02-hd" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="mm-voice">{t("minimax.voiceId")}</Label>
<Input id="mm-voice" value={draft.minimax.voice_id ?? ""} onChange={(e) => updateProvider("minimax", { voice_id: e.target.value })} placeholder="Wise_Woman" />
</div>
</div>
</>
)}
</CardContent>
</Card>
{draft.provider && (
<CredentialsSection
provider={draft.provider}
draft={draft}
onUpdate={updateProvider}
synthesize={synthesize}
/>
)}
<Separator className="my-6" />
{draft.provider && (
<VoiceModelSection
provider={draft.provider}
voiceId={getVoiceId(draft)}
modelId={getModelId(draft)}
onVoiceChange={handleVoiceChange}
onModelChange={handleModelChange}
/>
)}
{draft.provider && (
<TestPlayground
provider={draft.provider}
voiceId={getVoiceId(draft)}
modelId={getModelId(draft)}
synthesize={synthesize}
/>
)}
<BehaviorSection draft={draft} onUpdate={update} />
{error && <p className="text-sm text-destructive">{error}</p>}