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