mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-09 02:17:17 +00:00
Baseline groundwork for TTS expansion plan. Introduces a capabilities system that the follow-up plan (phase-01..04) builds on. Backend: - `audio.ParamSchema` / `ProviderCapabilities` types + per-provider capabilities.go for edge, elevenlabs, minimax, openai, gemini. - Gemini TTS provider (client, models, voices, wav encoder, multi-speaker via SpeakerVoice, audio-tag aware prompts). - TTSOptions gains `Params map[string]any` (read-only) + `Speakers`. - `VoiceListProvider` interface decouples HTTP voice handler from provider-specific impls. - `nested_keys.go` resolves dot-separated param paths for nested provider bodies (voice_settings.stability etc.). - Characterization + defaults-invariant tests per provider lock nil-params byte-equivalence before new params land. - `/v1/tts/capabilities` HTTP endpoint + integration coverage. - Dual-read tests (PG + SQLite) for tts_config. Frontend (web + desktop): - `DynamicParamForm` with depends-on evaluation, split into fields/logic modules. Slider primitive added. - `AudioTagPicker` + `MultiSpeakerEditor` for Gemini. - `voice-picker` refactored toward portal UX; combobox tightened. - tts-capabilities API client + typed hooks. - i18n catalogs (en/vi/zh) expanded; parity tests guard key drift. - TTS page reorganised (voice-model-section removed; playground + credentials + provider-setup split cleanly). Docs: codebase-summary, project-changelog, tts-provider-capabilities.
145 lines
5.0 KiB
Go
145 lines
5.0 KiB
Go
package http
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
|
"github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs"
|
|
"github.com/nextlevelbuilder/goclaw/internal/audio/minimax"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// VoicesHandler serves GET /v1/voices and POST /v1/voices/refresh.
|
|
// provider is typed as audio.VoiceListProvider so any provider can be injected.
|
|
type VoicesHandler struct {
|
|
cache *audio.VoiceCache
|
|
provider audio.VoiceListProvider // nil when resolved at request time from stores
|
|
secretStore store.ConfigSecretsStore
|
|
tenantStore store.TenantStore
|
|
}
|
|
|
|
// NewVoicesHandler creates a handler that resolves the provider at request time
|
|
// from config_secrets. Use NewVoicesHandlerWithProvider for tests.
|
|
func NewVoicesHandler(cache *audio.VoiceCache, secretStore store.ConfigSecretsStore, tenantStore store.TenantStore) *VoicesHandler {
|
|
return &VoicesHandler{cache: cache, secretStore: secretStore, tenantStore: tenantStore}
|
|
}
|
|
|
|
// NewVoicesHandlerWithProvider creates a handler with a pre-built provider.
|
|
// Accepts audio.VoiceListProvider so any provider (ElevenLabs, MiniMax, mock) can be injected.
|
|
// Primarily used in tests.
|
|
func NewVoicesHandlerWithProvider(cache *audio.VoiceCache, p audio.VoiceListProvider) *VoicesHandler {
|
|
return &VoicesHandler{cache: cache, provider: p}
|
|
}
|
|
|
|
// RegisterRoutes wires the voices endpoints onto mux.
|
|
func (h *VoicesHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/voices", requireAuth("", h.handleList))
|
|
mux.HandleFunc("POST /v1/voices/refresh", requireAuth(permissions.RoleAdmin, h.handleRefresh))
|
|
}
|
|
|
|
// handleList serves GET /v1/voices — returns cached list or fetches live.
|
|
func (h *VoicesHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
locale := store.LocaleFromContext(ctx)
|
|
|
|
if voices, ok := h.cache.Get(tenantID); ok {
|
|
writeJSON(w, http.StatusOK, map[string]any{"voices": voices})
|
|
return
|
|
}
|
|
|
|
p, err := h.resolveProvider(r, tenantID)
|
|
if err != nil {
|
|
slog.Warn("voices: no provider configured", "tenant_id", tenantID, "error", err)
|
|
writeJSON(w, http.StatusNotFound, map[string]string{
|
|
"error": i18n.T(locale, i18n.MsgVoicesListFailed, err.Error()),
|
|
})
|
|
return
|
|
}
|
|
|
|
voices, err := p.ListVoices(ctx)
|
|
if err != nil {
|
|
slog.Warn("voices: list failed", "tenant_id", tenantID, "error", err)
|
|
writeJSON(w, http.StatusBadGateway, map[string]string{
|
|
"error": i18n.T(locale, i18n.MsgTtsMiniMaxVoicesFailed, err.Error()),
|
|
})
|
|
return
|
|
}
|
|
|
|
h.cache.Set(tenantID, voices)
|
|
writeJSON(w, http.StatusOK, map[string]any{"voices": voices})
|
|
}
|
|
|
|
// handleRefresh serves POST /v1/voices/refresh — admin-only, forces a live
|
|
// refetch by invalidating the tenant's cache entry.
|
|
func (h *VoicesHandler) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
locale := store.LocaleFromContext(ctx)
|
|
|
|
h.cache.Invalidate(tenantID)
|
|
|
|
p, err := h.resolveProvider(r, tenantID)
|
|
if err != nil {
|
|
slog.Warn("voices: no provider on refresh", "tenant_id", tenantID, "error", err)
|
|
writeJSON(w, http.StatusNotFound, map[string]string{
|
|
"error": i18n.T(locale, i18n.MsgVoicesListFailed, err.Error()),
|
|
})
|
|
return
|
|
}
|
|
|
|
voices, err := p.ListVoices(ctx)
|
|
if err != nil {
|
|
slog.Warn("voices: refresh fetch failed", "tenant_id", tenantID, "error", err)
|
|
writeJSON(w, http.StatusBadGateway, map[string]string{
|
|
"error": i18n.T(locale, i18n.MsgVoicesListFailed, err.Error()),
|
|
})
|
|
return
|
|
}
|
|
|
|
h.cache.Set(tenantID, voices)
|
|
writeJSON(w, http.StatusOK, map[string]any{"voices": voices})
|
|
}
|
|
|
|
// resolveProvider returns the VoiceListProvider for this request.
|
|
// Priority: injected provider (test/pre-built) > query-param ?provider > secret store lookup (elevenlabs default).
|
|
func (h *VoicesHandler) resolveProvider(r *http.Request, tenantID uuid.UUID) (audio.VoiceListProvider, error) {
|
|
if h.provider != nil {
|
|
return h.provider, nil
|
|
}
|
|
if h.secretStore == nil {
|
|
return nil, fmt.Errorf("no voice provider configured")
|
|
}
|
|
|
|
providerName := r.URL.Query().Get("provider")
|
|
if providerName == "" {
|
|
providerName = "elevenlabs" // backward-compatible default
|
|
}
|
|
|
|
switch providerName {
|
|
case "minimax":
|
|
apiKey, err := h.secretStore.Get(r.Context(), "tts.minimax.api_key")
|
|
if err != nil || apiKey == "" {
|
|
return nil, fmt.Errorf("MiniMax API key not found for tenant %s", tenantID)
|
|
}
|
|
apiBase, _ := h.secretStore.Get(r.Context(), "tts.minimax.api_base")
|
|
return minimax.NewVoiceLister(apiKey, apiBase, 15000, tenantID), nil
|
|
|
|
case "elevenlabs":
|
|
apiKey, err := h.secretStore.Get(r.Context(), "tts.elevenlabs.api_key")
|
|
if err != nil || apiKey == "" {
|
|
return nil, fmt.Errorf("ElevenLabs API key not found for tenant %s", tenantID)
|
|
}
|
|
return elevenlabs.NewTTSProvider(elevenlabs.Config{APIKey: apiKey}), nil
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unsupported voice provider: %s", providerName)
|
|
}
|
|
}
|