diff --git a/cmd/gateway.go b/cmd/gateway.go index 2a607487..4ca3e32c 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -311,6 +311,7 @@ func runGateway() { workspace: workspace, dataDir: dataDir, domainBus: domainBus, + audioMgr: audioMgr, } gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port) diff --git a/cmd/gateway_deps.go b/cmd/gateway_deps.go index a57a3852..4c860e27 100644 --- a/cmd/gateway_deps.go +++ b/cmd/gateway_deps.go @@ -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 } diff --git a/cmd/gateway_http_wiring.go b/cmd/gateway_http_wiring.go index 7093ce89..cc07eb1a 100644 --- a/cmd/gateway_http_wiring.go +++ b/cmd/gateway_http_wiring.go @@ -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) diff --git a/internal/audio/edge/tts.go b/internal/audio/edge/tts.go index a798496d..d8522787 100644 --- a/internal/audio/edge/tts.go +++ b/internal/audio/edge/tts.go @@ -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, } diff --git a/internal/audio/edge/tts_test.go b/internal/audio/edge/tts_test.go new file mode 100644 index 00000000..066ac784 --- /dev/null +++ b/internal/audio/edge/tts_test.go @@ -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") + } +} diff --git a/internal/gateway/server.go b/internal/gateway/server.go index ea10a52c..de444d6e 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -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) } diff --git a/internal/http/tts.go b/internal/http/tts.go new file mode 100644 index 00000000..5b6fd9f2 --- /dev/null +++ b/internal/http/tts.go @@ -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) +} diff --git a/internal/http/tts_test.go b/internal/http/tts_test.go new file mode 100644 index 00000000..a0bb97b7 --- /dev/null +++ b/internal/http/tts_test.go @@ -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()) + } +} diff --git a/internal/tools/tts_override_test.go b/internal/tools/tts_override_test.go index 728e63ef..6e00812c 100644 --- a/internal/tools/tts_override_test.go +++ b/internal/tools/tts_override_test.go @@ -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) + } + }) + } +} diff --git a/ui/desktop/frontend/src/components/agents/AgentDetailPanel.tsx b/ui/desktop/frontend/src/components/agents/AgentDetailPanel.tsx index bb5f5063..d09b743c 100644 --- a/ui/desktop/frontend/src/components/agents/AgentDetailPanel.tsx +++ b/ui/desktop/frontend/src/components/agents/AgentDetailPanel.tsx @@ -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

- {/* TTS Voice section */} + {/* TTS Voice section — gated on global TTS provider being configured */}

{t('tts:voice_label')}

- + {globalProvider ? ( + + ) : ( + + )}
) : ( diff --git a/ui/desktop/frontend/src/components/agents/tts-empty-state.tsx b/ui/desktop/frontend/src/components/agents/tts-empty-state.tsx new file mode 100644 index 00000000..c21ad237 --- /dev/null +++ b/ui/desktop/frontend/src/components/agents/tts-empty-state.tsx @@ -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 ( +
+ {/* VolumeX icon — inline SVG to avoid lucide-react dep on desktop */} + +

{t('empty_state.title')}

+

{t('empty_state.non_owner_hint')}

+
+ ) +} diff --git a/ui/desktop/frontend/src/components/agents/voice-picker.tsx b/ui/desktop/frontend/src/components/agents/voice-picker.tsx index 16a9e41e..a325c9bb 100644 --- a/ui/desktop/frontend/src/components/agents/voice-picker.tsx +++ b/ui/desktop/frontend/src/components/agents/voice-picker.tsx @@ -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 + } + const def = provider ? getProviderDefinition(provider) : null + if (def && !def.dynamic) { + return ( + + ) + } + return ( + + ) +} + +function EmptyStatePicker() { + const { t } = useTranslation('tts') + return ( +
+ {t('voice_picker.requires_provider')} +
+ ) +} + +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 ( + + ) +} + +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) { - {/* Preview for selected voice */} {value && voices.find((v) => v.voice_id === value) && (
{ + 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"); + }); +}); diff --git a/ui/desktop/frontend/src/data/tts-providers.ts b/ui/desktop/frontend/src/data/tts-providers.ts new file mode 100644 index 00000000..5c315e5d --- /dev/null +++ b/ui/desktop/frontend/src/data/tts-providers.ts @@ -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 = { + // 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; +} diff --git a/ui/desktop/frontend/src/hooks/use-tts-config.ts b/ui/desktop/frontend/src/hooks/use-tts-config.ts new file mode 100644 index 00000000..3388971b --- /dev/null +++ b/ui/desktop/frontend/src/hooks/use-tts-config.ts @@ -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 } +} diff --git a/ui/desktop/frontend/src/i18n/locales/en/tts.json b/ui/desktop/frontend/src/i18n/locales/en/tts.json index 7304837a..a92c1e70 100644 --- a/ui/desktop/frontend/src/i18n/locales/en/tts.json +++ b/ui/desktop/frontend/src/i18n/locales/en/tts.json @@ -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" + } } diff --git a/ui/desktop/frontend/src/i18n/locales/vi/tts.json b/ui/desktop/frontend/src/i18n/locales/vi/tts.json index b9760095..a2dae799 100644 --- a/ui/desktop/frontend/src/i18n/locales/vi/tts.json +++ b/ui/desktop/frontend/src/i18n/locales/vi/tts.json @@ -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" + } } diff --git a/ui/desktop/frontend/src/i18n/locales/zh/tts.json b/ui/desktop/frontend/src/i18n/locales/zh/tts.json index b6f2811d..4394277c 100644 --- a/ui/desktop/frontend/src/i18n/locales/zh/tts.json +++ b/ui/desktop/frontend/src/i18n/locales/zh/tts.json @@ -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": "高级设置" + } } diff --git a/ui/web/src/components/voice-picker.tsx b/ui/web/src/components/voice-picker.tsx index d5d80827..416e46eb 100644 --- a/ui/web/src/components/voice-picker.tsx +++ b/ui/web/src/components/voice-picker.tsx @@ -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) → + + + + + {voices.map((v) => ( + + {v.label} + + ))} + + + ); +} + +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 */}
- {/* Voice list */}
{isLoading ? (
diff --git a/ui/web/src/data/__tests__/tts-providers-parity.test.ts b/ui/web/src/data/__tests__/tts-providers-parity.test.ts new file mode 100644 index 00000000..1430178a --- /dev/null +++ b/ui/web/src/data/__tests__/tts-providers-parity.test.ts @@ -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"]); + }); +}); diff --git a/ui/web/src/data/__tests__/tts-providers.test.ts b/ui/web/src/data/__tests__/tts-providers.test.ts new file mode 100644 index 00000000..056d7a62 --- /dev/null +++ b/ui/web/src/data/__tests__/tts-providers.test.ts @@ -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"); + }); +}); diff --git a/ui/web/src/data/tts-providers.ts b/ui/web/src/data/tts-providers.ts new file mode 100644 index 00000000..3a1a5125 --- /dev/null +++ b/ui/web/src/data/tts-providers.ts @@ -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 = { + // 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; +} diff --git a/ui/web/src/i18n/locales/en/tts.json b/ui/web/src/i18n/locales/en/tts.json index 8a1a2c35..acd52193 100644 --- a/ui/web/src/i18n/locales/en/tts.json +++ b/ui/web/src/i18n/locales/en/tts.json @@ -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" } } diff --git a/ui/web/src/i18n/locales/vi/tts.json b/ui/web/src/i18n/locales/vi/tts.json index 12468e34..26f0a549 100644 --- a/ui/web/src/i18n/locales/vi/tts.json +++ b/ui/web/src/i18n/locales/vi/tts.json @@ -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" + } } diff --git a/ui/web/src/i18n/locales/zh/tts.json b/ui/web/src/i18n/locales/zh/tts.json index 521006f9..631ddfd7 100644 --- a/ui/web/src/i18n/locales/zh/tts.json +++ b/ui/web/src/i18n/locales/zh/tts.json @@ -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": "高级设置" + } } diff --git a/ui/web/src/pages/agents/agent-detail/overview-sections/__tests__/prompt-settings-section.test.ts b/ui/web/src/pages/agents/agent-detail/overview-sections/__tests__/prompt-settings-section.test.ts new file mode 100644 index 00000000..02fd76f7 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/overview-sections/__tests__/prompt-settings-section.test.ts @@ -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([]); + }); +}); diff --git a/ui/web/src/pages/agents/agent-detail/overview-sections/prompt-settings-section.tsx b/ui/web/src/pages/agents/agent-detail/overview-sections/prompt-settings-section.tsx index fffc0b9e..373a74b1 100644 --- a/ui/web/src/pages/agents/agent-detail/overview-sections/prompt-settings-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/overview-sections/prompt-settings-section.tsx @@ -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) => Promise; } -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; 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(savedMode); - const [ttsVoiceId, setTtsVoiceId] = useState((otherConfig.tts_voice_id as string) ?? ""); - const [ttsModelId, setTtsModelId] = useState((otherConfig.tts_model_id as string) ?? ""); + const [ttsVoiceId, setTtsVoiceId] = useState(savedVoiceId); + const [ttsModelId, setTtsModelId] = useState(savedModelId); + const [override, setOverride] = useState(!!(savedVoiceId || savedModelId)); const [saving, setSaving] = useState(false); useEffect(() => { const cfg = (agent.other_config ?? {}) as Record; + 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 = { 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) {

{tTts("title")}

-
- - + ) : ( + -
-
- - -
+ )}
); diff --git a/ui/web/src/pages/agents/agent-detail/overview-sections/tts-empty-state.tsx b/ui/web/src/pages/agents/agent-detail/overview-sections/tts-empty-state.tsx new file mode 100644 index 00000000..3556dd62 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/overview-sections/tts-empty-state.tsx @@ -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 ( +
+ +
+

{t("empty_state.title")}

+

{t("empty_state.description")}

+
+ {isOwner ? ( + + ) : ( +

+ {t("empty_state.non_owner_hint")} +

+ )} +
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/overview-sections/tts-override-block.tsx b/ui/web/src/pages/agents/agent-detail/overview-sections/tts-override-block.tsx new file mode 100644 index 00000000..d9a40851 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/overview-sections/tts-override-block.tsx @@ -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; +} + +/** + * 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 ( +
+ {/* Inheritance info chip */} +

+ {t("override.inherits", { + provider: providerLabel, + voice: globalProvider === "elevenlabs" ? t("voice_label") : (def?.defaultVoice ?? "–"), + model: def?.defaultModel ?? "–", + })} +

+ + {/* Override checkbox */} + + + {overrideEnabled && ( +
+ {/* Voice picker — provider-aware */} +
+ + +
+ + {/* Model select — catalog-driven; hidden for providers with no models (edge) */} + {hasModels && ( +
+ + +
+ )} + + {/* Inline test button */} + +
+ )} +
+ ); +} diff --git a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx index 4c932eda..6e249be3 100644 --- a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx +++ b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx @@ -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 ? ( - onSave(tool.name, settings).then(() => onOpenChange(false))} - onCancel={() => onOpenChange(false)} - /> ) : isStt && tool ? ( - 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(); for (const tool of filtered) { diff --git a/ui/web/src/pages/builtin-tools/tts-provider-form.tsx b/ui/web/src/pages/builtin-tools/tts-provider-form.tsx deleted file mode 100644 index 50793d86..00000000 --- a/ui/web/src/pages/builtin-tools/tts-provider-form.tsx +++ /dev/null @@ -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; - onSave: (settings: Record) => Promise; - onCancel: () => void; -} - -const TTS_PROVIDERS: TtsProvider[] = ["openai", "elevenlabs", "edge", "minimax"]; -const AUTO_MODES: AutoMode[] = ["off", "inbound", "tagged", "always"]; - -const AUTO_MODE_DESC_KEYS: Record = { - off: "builtin.ttsForm.autoOffDesc", - inbound: "builtin.ttsForm.autoInboundDesc", - tagged: "builtin.ttsForm.autoTaggedDesc", - always: "builtin.ttsForm.autoAlwaysDesc", -}; - -const AUTO_MODE_LABEL_KEYS: Record = { - 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( - (initialSettings.primary as TtsProvider) ?? "openai", - ); - const [auto, setAuto] = useState( - (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 ( -
- - {t("builtin.ttsForm.title")} - {t("builtin.ttsForm.description")} - - -
- {/* Primary provider */} -
- - -
- - {/* Auto mode */} -
- - -
-
- - - - - -
- ); -} diff --git a/ui/web/src/pages/tts/hooks/use-tts-config.ts b/ui/web/src/pages/tts/hooks/use-tts-config.ts index d9eaccb8..e63dfdde 100644 --- a/ui/web/src/pages/tts/hooks/use-tts-config.ts +++ b/ui/web/src/pages/tts/hooks/use-tts-config.ts @@ -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 => { + 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 }; } diff --git a/ui/web/src/pages/tts/sections/__tests__/test-playground.test.ts b/ui/web/src/pages/tts/sections/__tests__/test-playground.test.ts new file mode 100644 index 00000000..676255ca --- /dev/null +++ b/ui/web/src/pages/tts/sections/__tests__/test-playground.test.ts @@ -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); + }); +}); diff --git a/ui/web/src/pages/tts/sections/behavior-section.tsx b/ui/web/src/pages/tts/sections/behavior-section.tsx new file mode 100644 index 00000000..3cc3c274 --- /dev/null +++ b/ui/web/src/pages/tts/sections/behavior-section.tsx @@ -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) => void; +} + +export function BehaviorSection({ draft, onUpdate }: Props) { + const { t } = useTranslation("tts"); + const [open, setOpen] = useState(false); + + return ( + + + + + + {open && ( + + {/* Auto mode */} +
+ +
+ {AUTO_MODE_VALUES.map((v) => ( + + ))} +
+

+ {t(`autoModes.${draft.auto}Desc`)} +

+
+ + {/* Reply mode */} +
+ +
+ {REPLY_MODE_VALUES.map((v) => ( + + ))} +
+
+ + {/* Max length & timeout */} +
+
+ + onUpdate({ max_length: Number(e.target.value) })} + min={10} + /> +
+
+ + onUpdate({ timeout_ms: Number(e.target.value) })} + min={1000} + /> +
+
+
+ )} +
+ ); +} diff --git a/ui/web/src/pages/tts/sections/credentials-section.tsx b/ui/web/src/pages/tts/sections/credentials-section.tsx new file mode 100644 index 00000000..ece0f6eb --- /dev/null +++ b/ui/web/src/pages/tts/sections/credentials-section.tsx @@ -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, + patch: Partial, + ) => void; + synthesize: (params: SynthesizeParams) => Promise; +} + +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 ( + + + + 2. {t("providerSettings", { provider: t(`providers.${provider}`) })} + + + + {provider === "openai" && ( + <> +
+ + onUpdate("openai", { api_key: e.target.value })} + placeholder="sk-..." /> +
+
+ + onUpdate("openai", { api_base: e.target.value })} + placeholder="https://api.openai.com/v1" /> +
+ + )} + + {provider === "elevenlabs" && ( + <> +
+ + onUpdate("elevenlabs", { api_key: e.target.value })} + placeholder="xi-..." /> +
+
+ + onUpdate("elevenlabs", { base_url: e.target.value })} + placeholder="https://api.elevenlabs.io" /> +
+ + )} + + {provider === "minimax" && ( + <> +
+ + onUpdate("minimax", { api_key: e.target.value })} + placeholder="eyJh..." /> +
+
+ + onUpdate("minimax", { group_id: e.target.value })} + placeholder={t("minimax.groupIdPlaceholder")} /> +
+
+ + onUpdate("minimax", { api_base: e.target.value })} + placeholder="https://api.minimax.io/v1" /> +
+ + )} + + +
+
+ ); +} diff --git a/ui/web/src/pages/tts/sections/provider-setup.tsx b/ui/web/src/pages/tts/sections/provider-setup.tsx new file mode 100644 index 00000000..ee504f2f --- /dev/null +++ b/ui/web/src/pages/tts/sections/provider-setup.tsx @@ -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 ( + + + + 1. {t("general.primaryProvider")} + + + +
+ +
+ {PROVIDER_VALUES.map((v) => ( + + ))} +
+
+ {hint && ( +

{hint}

+ )} +
+
+ ); +} diff --git a/ui/web/src/pages/tts/sections/test-playground.tsx b/ui/web/src/pages/tts/sections/test-playground.tsx new file mode 100644 index 00000000..f94cc019 --- /dev/null +++ b/ui/web/src/pages/tts/sections/test-playground.tsx @@ -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 = { 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; +} + +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(null); + const currentUrlRef = useRef(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 ( + + + 4. {t("playground.title", "Test Playground")} + + +
+
+ + + {text.length}/{MAX_CHARS} + +
+