mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
[voice] feat: add managed voice cloning profiles (#10799)
* feat(ui): add voice library workflow Give administrators a production-ready flow to record or upload consented reference audio, manage reusable profiles, inspect API usage, discover compatible models, and hand a saved voice directly to text-to-speech. Assisted-by: Codex:gpt-5 * feat(voice): add managed voice cloning profiles Make reusable reference voices manageable through the admin API instead of requiring model-directory and YAML edits. Discover compatible installed and gallery models from server-side backend capabilities, retain explicit model configuration controls, and stage saved references for supported backends. Expose profile management through REST and MCP, document backend-specific behavior, and cover the workflow from profile creation through real Qwen3-TTS synthesis. Harden the agent-job HTTP test against completion racing cancellation. Assisted-by: Codex:gpt-5 --------- Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
@@ -80,6 +80,11 @@ type LocalAIClient interface {
|
||||
// exposed over MCP — admins use the Settings UI for binary files.
|
||||
SetBranding(ctx context.Context, req SetBrandingRequest) (*Branding, error)
|
||||
|
||||
// ---- Voice profile library ----
|
||||
ListVoiceProfiles(ctx context.Context) ([]VoiceProfile, error)
|
||||
CreateVoiceProfile(ctx context.Context, req CreateVoiceProfileRequest) (*VoiceProfile, error)
|
||||
DeleteVoiceProfile(ctx context.Context, id string) error
|
||||
|
||||
// ---- Usage / billing ----
|
||||
|
||||
// GetUsageStats returns aggregated token usage. In single-user
|
||||
|
||||
@@ -42,20 +42,23 @@ var toolToHTTPRoute = map[string]string{
|
||||
ToolGetMiddlewareStatus: "GET /api/middleware/status",
|
||||
ToolGetRouterDecisions: "GET /api/router/decisions",
|
||||
ToolListAliases: "GET /api/aliases",
|
||||
ToolListVoiceProfiles: "GET /api/voice-profiles",
|
||||
|
||||
// Mutating tools.
|
||||
ToolInstallModel: "POST /models/apply",
|
||||
ToolImportModelURI: "POST /models/import-uri",
|
||||
ToolDeleteModel: "POST /models/delete/:name",
|
||||
ToolEditModelConfig: "PATCH /api/models/config-json/:name",
|
||||
ToolReloadModels: "POST /models/reload",
|
||||
ToolLoadModel: "POST /backend/load",
|
||||
ToolInstallBackend: "POST /backends/apply",
|
||||
ToolUpgradeBackend: "POST /backends/upgrade/:name",
|
||||
ToolToggleModelState: "PUT /models/toggle-state/:name/:action",
|
||||
ToolToggleModelPinned: "PUT /models/toggle-pinned/:name/:action",
|
||||
ToolSetBranding: "POST /api/settings (instance_name, instance_tagline)",
|
||||
ToolSetAlias: "PATCH /api/models/config-json/:name (swap) or POST /models/import (create)",
|
||||
ToolInstallModel: "POST /models/apply",
|
||||
ToolImportModelURI: "POST /models/import-uri",
|
||||
ToolDeleteModel: "POST /models/delete/:name",
|
||||
ToolEditModelConfig: "PATCH /api/models/config-json/:name",
|
||||
ToolReloadModels: "POST /models/reload",
|
||||
ToolLoadModel: "POST /backend/load",
|
||||
ToolInstallBackend: "POST /backends/apply",
|
||||
ToolUpgradeBackend: "POST /backends/upgrade/:name",
|
||||
ToolToggleModelState: "PUT /models/toggle-state/:name/:action",
|
||||
ToolToggleModelPinned: "PUT /models/toggle-pinned/:name/:action",
|
||||
ToolSetBranding: "POST /api/settings (instance_name, instance_tagline)",
|
||||
ToolSetAlias: "PATCH /api/models/config-json/:name (swap) or POST /models/import (create)",
|
||||
ToolCreateVoiceProfile: "POST /api/voice-profiles",
|
||||
ToolDeleteVoiceProfile: "DELETE /api/voice-profiles/:id",
|
||||
}
|
||||
|
||||
// allKnownTools is the union of expectedFullCatalog (defined in
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package localaitools
|
||||
|
||||
import "github.com/mudler/LocalAI/core/services/voiceprofile"
|
||||
|
||||
// DTOs for the LocalAIClient interface. Where the same shape already exists
|
||||
// elsewhere (config.Gallery, gallery.Metadata, schema.KnownBackend,
|
||||
// vram.EstimateResult) we surface that type directly via the interface
|
||||
@@ -145,6 +147,28 @@ type SetBrandingRequest struct {
|
||||
InstanceTagline *string `json:"instance_tagline,omitempty" jsonschema:"Optional short subtitle shown beneath the instance name. Pass an empty string to clear."`
|
||||
}
|
||||
|
||||
// VoiceProfile is the same path-free shape returned by the REST library.
|
||||
// Keeping the service type avoids REST/MCP field drift.
|
||||
type VoiceProfile = voiceprofile.Profile
|
||||
|
||||
// CreateVoiceProfileRequest is the MCP/JSON form of profile creation. Audio
|
||||
// must be a base64-encoded 16-bit PCM WAV (mono 24 kHz is recommended for
|
||||
// portability); the service enforces the same 50 MiB and duration limits as
|
||||
// the browser upload route.
|
||||
type CreateVoiceProfileRequest struct {
|
||||
Name string `json:"name" jsonschema:"Display name for the reusable voice profile."`
|
||||
Description string `json:"description,omitempty" jsonschema:"Optional note describing tone, source, or intended use."`
|
||||
Language string `json:"language,omitempty" jsonschema:"Optional BCP-47-style language tag such as en-US."`
|
||||
Transcript string `json:"transcript" jsonschema:"Exact transcript of the words spoken in the reference clip."`
|
||||
AudioBase64 string `json:"audio_base64" jsonschema:"Base64-encoded 16-bit PCM WAV reference, preferably mono 24 kHz, 1-120 seconds and at most 50 MiB decoded."`
|
||||
ConsentConfirmed bool `json:"consent_confirmed" jsonschema:"Must be true to confirm authorization to clone this voice."`
|
||||
}
|
||||
|
||||
// DeleteVoiceProfileRequest identifies the profile to remove.
|
||||
type DeleteVoiceProfileRequest struct {
|
||||
ID string `json:"id" jsonschema:"Opaque voice profile UUID returned by list_voice_profiles."`
|
||||
}
|
||||
|
||||
// UsageStatsQuery is the input for get_usage_stats. UserID is optional;
|
||||
// when empty the tool returns the calling user's own usage in auth-on
|
||||
// mode, or the synthetic local user's usage in single-user no-auth
|
||||
|
||||
@@ -47,6 +47,9 @@ type fakeClient struct {
|
||||
toggleModelPinned func(string, modeladmin.Action) error
|
||||
getBranding func() (*Branding, error)
|
||||
setBranding func(SetBrandingRequest) (*Branding, error)
|
||||
listVoiceProfiles func() ([]VoiceProfile, error)
|
||||
createVoiceProfile func(CreateVoiceProfileRequest) (*VoiceProfile, error)
|
||||
deleteVoiceProfile func(string) error
|
||||
getUsageStats func(UsageStatsQuery) (*UsageStats, error)
|
||||
getPIIEvents func(PIIEventsQuery) ([]PIIEvent, error)
|
||||
getMiddlewareStatus func() (*MiddlewareStatus, error)
|
||||
@@ -266,6 +269,30 @@ func (f *fakeClient) SetBranding(_ context.Context, req SetBrandingRequest) (*Br
|
||||
return &Branding{InstanceName: "LocalAI"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) ListVoiceProfiles(_ context.Context) ([]VoiceProfile, error) {
|
||||
f.record("ListVoiceProfiles", nil)
|
||||
if f.listVoiceProfiles != nil {
|
||||
return f.listVoiceProfiles()
|
||||
}
|
||||
return []VoiceProfile{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) CreateVoiceProfile(_ context.Context, req CreateVoiceProfileRequest) (*VoiceProfile, error) {
|
||||
f.record("CreateVoiceProfile", req)
|
||||
if f.createVoiceProfile != nil {
|
||||
return f.createVoiceProfile(req)
|
||||
}
|
||||
return &VoiceProfile{ID: "00000000-0000-0000-0000-000000000001", Name: req.Name}, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) DeleteVoiceProfile(_ context.Context, id string) error {
|
||||
f.record("DeleteVoiceProfile", id)
|
||||
if f.deleteVoiceProfile != nil {
|
||||
return f.deleteVoiceProfile(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) GetUsageStats(_ context.Context, q UsageStatsQuery) (*UsageStats, error) {
|
||||
f.record("GetUsageStats", q)
|
||||
if f.getUsageStats != nil {
|
||||
|
||||
@@ -552,6 +552,33 @@ func (c *Client) SetBranding(ctx context.Context, req localaitools.SetBrandingRe
|
||||
return c.GetBranding(ctx)
|
||||
}
|
||||
|
||||
// ---- Voice profile library ----
|
||||
|
||||
func (c *Client) ListVoiceProfiles(ctx context.Context) ([]localaitools.VoiceProfile, error) {
|
||||
var response struct {
|
||||
Data []localaitools.VoiceProfile `json:"data"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodGet, routeVoiceProfiles, nil, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateVoiceProfile(ctx context.Context, req localaitools.CreateVoiceProfileRequest) (*localaitools.VoiceProfile, error) {
|
||||
var profile localaitools.VoiceProfile
|
||||
if err := c.do(ctx, http.MethodPost, routeVoiceProfiles, req, &profile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteVoiceProfile(ctx context.Context, id string) error {
|
||||
if id == "" {
|
||||
return errors.New("id is required")
|
||||
}
|
||||
return c.do(ctx, http.MethodDelete, routeVoiceProfileDelete(id), nil, nil)
|
||||
}
|
||||
|
||||
// ---- Usage / billing ----
|
||||
|
||||
func (c *Client) GetUsageStats(ctx context.Context, q localaitools.UsageStatsQuery) (*localaitools.UsageStats, error) {
|
||||
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
routePIIEvents = "/api/pii/events"
|
||||
routeMiddleware = "/api/middleware/status"
|
||||
routeRouterDecisions = "/api/router/decisions"
|
||||
routeVoiceProfiles = "/api/voice-profiles"
|
||||
)
|
||||
|
||||
func routeJobStatus(jobID string) string {
|
||||
@@ -57,3 +58,7 @@ func routeToggleModelState(name, action string) string {
|
||||
func routeToggleModelPinned(name, action string) string {
|
||||
return fmt.Sprintf("/models/toggle-pinned/%s/%s", url.PathEscape(name), url.PathEscape(action))
|
||||
}
|
||||
|
||||
func routeVoiceProfileDelete(id string) string {
|
||||
return "/api/voice-profiles/" + url.PathEscape(id)
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ package inproc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
@@ -24,6 +26,7 @@ import (
|
||||
"github.com/mudler/LocalAI/core/services/routing/billing"
|
||||
"github.com/mudler/LocalAI/core/services/routing/pii"
|
||||
"github.com/mudler/LocalAI/core/services/routing/router"
|
||||
"github.com/mudler/LocalAI/core/services/voiceprofile"
|
||||
"github.com/mudler/LocalAI/internal"
|
||||
localaitools "github.com/mudler/LocalAI/pkg/mcp/localaitools"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
@@ -39,11 +42,12 @@ import (
|
||||
// distributed-aware, ModelConfigLoader manages on-disk YAML, etc.), so this
|
||||
// layer just translates between MCP DTOs and service signatures.
|
||||
type Client struct {
|
||||
AppConfig *config.ApplicationConfig
|
||||
SystemState *system.SystemState
|
||||
ConfigLoader *config.ModelConfigLoader
|
||||
ModelLoader *model.ModelLoader
|
||||
Gallery *galleryop.GalleryService
|
||||
AppConfig *config.ApplicationConfig
|
||||
SystemState *system.SystemState
|
||||
ConfigLoader *config.ModelConfigLoader
|
||||
ModelLoader *model.ModelLoader
|
||||
Gallery *galleryop.GalleryService
|
||||
VoiceProfiles *voiceprofile.Store
|
||||
|
||||
// StatsRecorder and FallbackUser are optional — they back the
|
||||
// get_usage_stats tool. nil StatsRecorder makes the tool return an
|
||||
@@ -73,12 +77,13 @@ type Client struct {
|
||||
// fields (StatsRecorder, FallbackUser) which gate get_usage_stats.
|
||||
func New(appConfig *config.ApplicationConfig, systemState *system.SystemState, cl *config.ModelConfigLoader, ml *model.ModelLoader, gs *galleryop.GalleryService) *Client {
|
||||
return &Client{
|
||||
AppConfig: appConfig,
|
||||
SystemState: systemState,
|
||||
ConfigLoader: cl,
|
||||
ModelLoader: ml,
|
||||
Gallery: gs,
|
||||
modelAdmin: modeladmin.NewConfigService(cl, appConfig),
|
||||
AppConfig: appConfig,
|
||||
SystemState: systemState,
|
||||
ConfigLoader: cl,
|
||||
ModelLoader: ml,
|
||||
Gallery: gs,
|
||||
VoiceProfiles: voiceprofile.NewStore(appConfig.DataPath),
|
||||
modelAdmin: modeladmin.NewConfigService(cl, appConfig),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +576,59 @@ func (c *Client) SetBranding(_ context.Context, req localaitools.SetBrandingRequ
|
||||
return c.currentBranding(), nil
|
||||
}
|
||||
|
||||
// ---- Voice profile library ----
|
||||
|
||||
func (c *Client) voiceProfileStore() (*voiceprofile.Store, error) {
|
||||
if c.VoiceProfiles != nil {
|
||||
return c.VoiceProfiles, nil
|
||||
}
|
||||
if c.AppConfig == nil {
|
||||
return nil, errors.New("voice profile store is unavailable")
|
||||
}
|
||||
c.VoiceProfiles = voiceprofile.NewStore(c.AppConfig.DataPath)
|
||||
return c.VoiceProfiles, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListVoiceProfiles(ctx context.Context) ([]localaitools.VoiceProfile, error) {
|
||||
store, err := c.voiceProfileStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.List(ctx)
|
||||
}
|
||||
|
||||
func (c *Client) CreateVoiceProfile(ctx context.Context, req localaitools.CreateVoiceProfileRequest) (*localaitools.VoiceProfile, error) {
|
||||
if req.AudioBase64 == "" {
|
||||
return nil, errors.New("audio_base64 is required")
|
||||
}
|
||||
if base64.StdEncoding.DecodedLen(len(req.AudioBase64)) > int(voiceprofile.MaxAudioBytes) {
|
||||
return nil, voiceprofile.ErrAudioTooLarge
|
||||
}
|
||||
store, err := c.voiceProfileStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profile, err := store.Create(ctx, voiceprofile.CreateInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Language: req.Language,
|
||||
Transcript: req.Transcript,
|
||||
ConsentConfirmed: req.ConsentConfirmed,
|
||||
}, base64.NewDecoder(base64.StdEncoding, strings.NewReader(req.AudioBase64)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteVoiceProfile(ctx context.Context, id string) error {
|
||||
store, err := c.voiceProfileStore()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// sendModelOp pushes op onto ch but bails if ctx is cancelled before the
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
These rules are non-negotiable. The user trusts you to operate their server without unintended changes.
|
||||
|
||||
1. **Confirm before mutating.** Before calling any of these tools — `install_model`, `import_model_uri`, `delete_model`, `install_backend`, `upgrade_backend`, `edit_model_config`, `reload_models`, `load_model`, `toggle_model_state`, `toggle_model_pinned` — first state in plain language what you are about to do (which tool, which target, which arguments) and wait for the user's explicit confirmation in the next turn. "Yes", "do it", "go ahead", "proceed" all count as confirmation. Anything else does not.
|
||||
1. **Confirm before mutating.** Before calling any of these tools — `install_model`, `import_model_uri`, `delete_model`, `install_backend`, `upgrade_backend`, `edit_model_config`, `reload_models`, `load_model`, `toggle_model_state`, `toggle_model_pinned`, `create_voice_profile`, `delete_voice_profile` — first state in plain language what you are about to do (which tool, which target, which arguments) and wait for the user's explicit confirmation in the next turn. "Yes", "do it", "go ahead", "proceed" all count as confirmation. Anything else does not.
|
||||
|
||||
2. **Disambiguate before mutating.** If the user's request is ambiguous (several gallery candidates match, the model name has multiple installed versions, the backend has variants), present the candidates as a numbered list and ask the user to pick before calling any mutating tool.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ The MCP `tools/list` endpoint also exposes the full input schema for each of the
|
||||
- `vram_estimate` — Estimate VRAM use for a model under a given config.
|
||||
- `system_info` — LocalAI version, paths, distributed flag, loaded models, installed backends.
|
||||
- `list_nodes` — List federated worker nodes (only useful in distributed mode).
|
||||
- `list_voice_profiles` — List reusable voice-cloning profiles and their stable TTS voice URIs.
|
||||
|
||||
## Mutating (require user confirmation per safety rule 1)
|
||||
|
||||
@@ -27,3 +28,5 @@ The MCP `tools/list` endpoint also exposes the full input schema for each of the
|
||||
- `load_model` — Pre-load a model into memory so the first request pays no cold-start cost. For a realtime pipeline model, every sub-model (VAD, transcription, LLM, TTS, sound_detection, voice_recognition) is loaded. Inverse of stopping a model.
|
||||
- `toggle_model_state` — Enable or disable a model (`action`: `enable` or `disable`).
|
||||
- `toggle_model_pinned` — Pin or unpin a model (`action`: `pin` or `unpin`).
|
||||
- `create_voice_profile` — Save a consent-confirmed base64 PCM-WAV reference and exact transcript for reuse in TTS.
|
||||
- `delete_voice_profile` — Permanently delete a saved voice profile by UUID.
|
||||
|
||||
@@ -49,6 +49,7 @@ func NewServer(client LocalAIClient, opts Options) *mcp.Server {
|
||||
registerSystemTools(srv, client, opts)
|
||||
registerStateTools(srv, client, opts)
|
||||
registerBrandingTools(srv, client, opts)
|
||||
registerVoiceProfileTools(srv, client, opts)
|
||||
registerUsageTools(srv, client, opts)
|
||||
registerPIITools(srv, client, opts)
|
||||
registerMiddlewareTools(srv, client, opts)
|
||||
|
||||
@@ -92,6 +92,7 @@ var expectedFullCatalog = sortedStrings(
|
||||
ToolListInstalledModels,
|
||||
ToolListKnownBackends,
|
||||
ToolListNodes,
|
||||
ToolListVoiceProfiles,
|
||||
ToolLoadModel,
|
||||
ToolReloadModels,
|
||||
ToolSetAlias,
|
||||
@@ -101,6 +102,8 @@ var expectedFullCatalog = sortedStrings(
|
||||
ToolToggleModelState,
|
||||
ToolUpgradeBackend,
|
||||
ToolVRAMEstimate,
|
||||
ToolCreateVoiceProfile,
|
||||
ToolDeleteVoiceProfile,
|
||||
)
|
||||
|
||||
// expectedReadOnlyCatalog is the tool set when DisableMutating=true. Sorted.
|
||||
@@ -119,6 +122,7 @@ var expectedReadOnlyCatalog = sortedStrings(
|
||||
ToolListInstalledModels,
|
||||
ToolListKnownBackends,
|
||||
ToolListNodes,
|
||||
ToolListVoiceProfiles,
|
||||
ToolSystemInfo,
|
||||
ToolVRAMEstimate,
|
||||
)
|
||||
@@ -160,6 +164,7 @@ var _ = Describe("Tool dispatch", func() {
|
||||
{ToolListKnownBackends, struct{}{}, "ListKnownBackends"},
|
||||
{ToolSystemInfo, struct{}{}, "SystemInfo"},
|
||||
{ToolListNodes, struct{}{}, "ListNodes"},
|
||||
{ToolListVoiceProfiles, struct{}{}, "ListVoiceProfiles"},
|
||||
{ToolInstallModel, InstallModelRequest{ModelName: "test/foo"}, "InstallModel"},
|
||||
{ToolImportModelURI, ImportModelURIRequest{URI: "Qwen/Qwen3-4B-GGUF"}, "ImportModelURI"},
|
||||
{ToolDeleteModel, map[string]any{"name": "foo"}, "DeleteModel"},
|
||||
@@ -172,6 +177,8 @@ var _ = Describe("Tool dispatch", func() {
|
||||
{ToolToggleModelPinned, map[string]any{"name": "foo", "action": "pin"}, "ToggleModelPinned"},
|
||||
{ToolSetAlias, map[string]any{"name": "gpt-4", "target": "real"}, "SetAlias"},
|
||||
{ToolListAliases, struct{}{}, "ListAliases"},
|
||||
{ToolCreateVoiceProfile, CreateVoiceProfileRequest{Name: "Narrator", Transcript: "Reference words", AudioBase64: "UklGRg==", ConsentConfirmed: true}, "CreateVoiceProfile"},
|
||||
{ToolDeleteVoiceProfile, DeleteVoiceProfileRequest{ID: "00000000-0000-0000-0000-000000000001"}, "DeleteVoiceProfile"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -226,6 +233,7 @@ var _ = Describe("Argument validation", func() {
|
||||
{"delete_model rejects missing name (schema)", ToolDeleteModel, map[string]any{}, "missing properties"},
|
||||
{"toggle_model_state rejects unknown action", ToolToggleModelState, map[string]any{"name": "foo", "action": "noop"}, "action must be one of"},
|
||||
{"edit_model_config rejects empty patch", ToolEditModelConfig, map[string]any{"name": "foo", "patch": map[string]any{}}, "patch is required"},
|
||||
{"create_voice_profile requires consent", ToolCreateVoiceProfile, CreateVoiceProfileRequest{Name: "Voice", Transcript: "words", AudioBase64: "UklGRg=="}, "consent_confirmed must be true"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -23,21 +23,24 @@ const (
|
||||
ToolGetPIIEvents = "get_pii_events"
|
||||
ToolGetMiddlewareStatus = "get_middleware_status"
|
||||
ToolGetRouterDecisions = "get_router_decisions"
|
||||
ToolListVoiceProfiles = "list_voice_profiles"
|
||||
|
||||
// Mutating tools — guarded by Options.DisableMutating and the
|
||||
// LLM-side safety prompt (see prompts/10_safety.md).
|
||||
ToolInstallModel = "install_model"
|
||||
ToolImportModelURI = "import_model_uri"
|
||||
ToolDeleteModel = "delete_model"
|
||||
ToolEditModelConfig = "edit_model_config"
|
||||
ToolReloadModels = "reload_models"
|
||||
ToolLoadModel = "load_model"
|
||||
ToolInstallBackend = "install_backend"
|
||||
ToolUpgradeBackend = "upgrade_backend"
|
||||
ToolToggleModelState = "toggle_model_state"
|
||||
ToolToggleModelPinned = "toggle_model_pinned"
|
||||
ToolSetBranding = "set_branding"
|
||||
ToolSetAlias = "set_alias"
|
||||
ToolInstallModel = "install_model"
|
||||
ToolImportModelURI = "import_model_uri"
|
||||
ToolDeleteModel = "delete_model"
|
||||
ToolEditModelConfig = "edit_model_config"
|
||||
ToolReloadModels = "reload_models"
|
||||
ToolLoadModel = "load_model"
|
||||
ToolInstallBackend = "install_backend"
|
||||
ToolUpgradeBackend = "upgrade_backend"
|
||||
ToolToggleModelState = "toggle_model_state"
|
||||
ToolToggleModelPinned = "toggle_model_pinned"
|
||||
ToolSetBranding = "set_branding"
|
||||
ToolSetAlias = "set_alias"
|
||||
ToolCreateVoiceProfile = "create_voice_profile"
|
||||
ToolDeleteVoiceProfile = "delete_voice_profile"
|
||||
|
||||
// ToolListAliases is read-only but lives here so the alias tools stay
|
||||
// grouped; the catalog tests assert its read-only placement.
|
||||
|
||||
54
pkg/mcp/localaitools/tools_voice_profiles.go
Normal file
54
pkg/mcp/localaitools/tools_voice_profiles.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package localaitools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func registerVoiceProfileTools(s *mcp.Server, client LocalAIClient, opts Options) {
|
||||
mcp.AddTool(s, &mcp.Tool{
|
||||
Name: ToolListVoiceProfiles,
|
||||
Description: "List reusable voice-cloning profiles. Returns stable voice URI values suitable for TTSRequest.voice; filesystem paths are never exposed.",
|
||||
}, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
|
||||
profiles, err := client.ListVoiceProfiles(ctx)
|
||||
if err != nil {
|
||||
return errorResult(err), nil, nil
|
||||
}
|
||||
return jsonResult(profiles), nil, nil
|
||||
})
|
||||
|
||||
if opts.DisableMutating {
|
||||
return
|
||||
}
|
||||
|
||||
mcp.AddTool(s, &mcp.Tool{
|
||||
Name: ToolCreateVoiceProfile,
|
||||
Description: "Create a reusable voice-cloning profile from a base64 16-bit PCM WAV (mono 24 kHz recommended) and its exact transcript. consent_confirmed must be true. Requires user confirmation per safety rule 1.",
|
||||
}, func(ctx context.Context, _ *mcp.CallToolRequest, args CreateVoiceProfileRequest) (*mcp.CallToolResult, any, error) {
|
||||
if args.Name == "" || args.Transcript == "" || args.AudioBase64 == "" {
|
||||
return errorResultf("name, transcript, and audio_base64 are required"), nil, nil
|
||||
}
|
||||
if !args.ConsentConfirmed {
|
||||
return errorResultf("consent_confirmed must be true"), nil, nil
|
||||
}
|
||||
profile, err := client.CreateVoiceProfile(ctx, args)
|
||||
if err != nil {
|
||||
return errorResult(err), nil, nil
|
||||
}
|
||||
return jsonResult(profile), nil, nil
|
||||
})
|
||||
|
||||
mcp.AddTool(s, &mcp.Tool{
|
||||
Name: ToolDeleteVoiceProfile,
|
||||
Description: "Permanently delete a reusable voice-cloning profile by opaque UUID. Requires user confirmation per safety rule 1.",
|
||||
}, func(ctx context.Context, _ *mcp.CallToolRequest, args DeleteVoiceProfileRequest) (*mcp.CallToolResult, any, error) {
|
||||
if args.ID == "" {
|
||||
return errorResultf("id is required"), nil, nil
|
||||
}
|
||||
if err := client.DeleteVoiceProfile(ctx, args.ID); err != nil {
|
||||
return errorResult(err), nil, nil
|
||||
}
|
||||
return jsonResult(map[string]any{"deleted": true, "id": args.ID}), nil, nil
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user