Repository: elizaOS/eliza
Pull request #4262:  Replace Text Input with Dropdown for Voice Model Selection
Author: login: Y4NK33420
Labels: name: 1.x
description: V2
Created: 2025-04-10T23:20:42Z | Merged: 2025-04-11T19:49:27Z


# Relates to

https://linear.app/eliza-labs/issue/ELI2-195/ui-replace-text-input-with-dropdown-for-voice-model-selection

# Risks

Low. The changes modify the voice model selection logic and plugin management, but use existing components and follow established patterns. The changes are isolated to the voice model selection flow and don't affect core functionality.

# Background

## What does this PR do?

This PR improves the voice model selection by enhancing how plugins are managed when changing between voice model providers. Specifically:

1. Makes voice model to plugin mapping configurable in voice-models.ts
2. Ensures only one voice-related plugin is active at a time (removes previous provider's plugin when switching)
3. Improves the UI to clearly indicate which plugins are required by the selected voice model
4. Prevents users from removing plugins that are required by the current voice model

## What kind of change is this?

Improvements (enhancement to existing voice model selection and plugin management features)

# Documentation changes needed?

My changes do not require a change to the project documentation.

# Testing

## Where should a reviewer start?

1. packages/client/src/config/voice-models.ts - Added configurable provider-to-plugin mapping
2. packages/client/src/components/character-form.tsx - Updated voice model selection logic
3. packages/client/src/components/plugins-panel.tsx - Enhanced UI for plugin management

## Detailed testing steps

1. Create a new character or edit an existing one
2. Select a local voice model (e.g., "Local Voice - Female 1")
   - Verify the Local AI plugin is automatically added
   - Verify the plugin appears with a green background in the plugins panel

3. Switch to an ElevenLabs voice model (e.g., "ElevenLabs - Rachel")
   - Verify the Local AI plugin is automatically removed
   - Verify the ElevenLabs plugin is automatically added
   - Verify only one voice-related plugin is present
   - Verify a toast notification appears about the plugin update

4. Try to remove the ElevenLabs plugin
   - Verify it can't be removed while the ElevenLabs voice model is selected
   - Verify a toast notification appears explaining why it can't be removed

5. Switch back to a local voice model
   - Verify the ElevenLabs plugin is removed and the Local AI plugin is added
   - Verify only one voice-related plugin is present

## Screenshots

-> After
![image](https://github.com/user-attachments/assets/2440ae14-f91c-4945-8e3b-66b489259e3b)

![image](https://github.com/user-attachments/assets/95cac9dd-a947-4157-a141-7fc06be9d962)

![image](https://github.com/user-attachments/assets/35970689-46a4-4f82-956a-31af08c5b5dd)

![image](https://github.com/user-attachments/assets/507f96a0-a295-42ad-982f-6d5b7e3bf40e)



Diff:
diff --git a/packages/client/src/components/character-form.tsx b/packages/client/src/components/character-form.tsx
index 201e09a87778a..3abac9af6be49 100644
--- a/packages/client/src/components/character-form.tsx
+++ b/packages/client/src/components/character-form.tsx
@@ -10,7 +10,21 @@ import { useToast } from '@/hooks/use-toast';
 import { compressImage } from '@/lib/utils';
 import type { Agent } from '@elizaos/core';
 import type React from 'react';
-import { type FormEvent, type ReactNode, useState } from 'react';
+import { type FormEvent, type ReactNode, useState, useMemo } from 'react';
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select';
+import {
+  getAllVoiceModels,
+  getVoiceModelByValue,
+  providerPluginMap,
+  getAllRequiredPlugins,
+} from '../config/voice-models';
+import { useElevenLabsVoices } from '@/hooks/use-elevenlabs-voices';
 
 type FieldType = 'text' | 'textarea' | 'number' | 'checkbox' | 'select';
 
@@ -20,6 +34,7 @@ type InputField = {
   description?: string;
   getValue: (char: Agent) => string;
   fieldType: FieldType;
+  options?: { value: string; label: string }[];
 };
 
 type ArrayField = {
@@ -34,94 +49,6 @@ enum SECTION_TYPE {
   ARRAY = 'array',
 }
 
-const CHARACTER_FORM_SCHEMA = [
-  {
-    sectionTitle: 'Basic Info',
-    sectionValue: 'basic',
-    sectionType: SECTION_TYPE.INPUT,
-    fields: [
-      {
-        title: 'Name',
-        name: 'name',
-        description: 'The display name of your character',
-        fieldType: 'text',
-        getValue: (char) => char.name || '',
-      },
-      {
-        title: 'Username',
-        name: 'username',
-        description: 'Unique identifier for your character',
-        fieldType: 'text',
-        getValue: (char) => char.username || '',
-      },
-      {
-        title: 'System',
-        name: 'system',
-        description: 'System prompt for character behavior',
-        fieldType: 'textarea',
-        getValue: (char) => char.system || '',
-      },
-      {
-        title: 'Voice Model',
-        name: 'settings.voice.model',
-        description: 'Voice model used for speech synthesis',
-        fieldType: 'text',
-        getValue: (char) => char.settings?.voice?.model || '',
-      },
-    ] as InputField[],
-  },
-  {
-    sectionTitle: 'Content',
-    sectionValue: 'content',
-    sectionType: SECTION_TYPE.ARRAY,
-    fields: [
-      {
-        title: 'Bio',
-        description: 'Key information about your character',
-        path: 'bio',
-        getData: (char) => (Array.isArray(char.bio) ? char.bio : []),
-      },
-      {
-        title: 'Topics',
-        description: 'Topics your character is knowledgeable about',
-        path: 'topics',
-        getData: (char) => char.topics || [],
-      },
-      {
-        title: 'Adjectives',
-        description: "Words that describe your character's personality",
-        path: 'adjectives',
-        getData: (char) => char.adjectives || [],
-      },
-    ] as ArrayField[],
-  },
-  {
-    sectionTitle: 'Style',
-    sectionValue: 'style',
-    sectionType: SECTION_TYPE.ARRAY,
-    fields: [
-      {
-        title: 'All',
-        description: 'Style rules applied to all interactions',
-        path: 'style.all',
-        getData: (char) => char.style?.all || [],
-      },
-      {
-        title: 'Chat',
-        description: 'Style rules for chat interactions',
-        path: 'style.chat',
-        getData: (char) => char.style?.chat || [],
-      },
-      {
-        title: 'Post',
-        description: 'Style rules for social media posts',
-        path: 'style.post',
-        getData: (char) => char.style?.post || [],
-      },
-    ] as ArrayField[],
-  },
-];
-
 type customComponent = {
   name: string;
   component: ReactNode;
@@ -158,9 +85,121 @@ export default function CharacterForm({
   customComponents = [],
 }: CharacterFormProps) {
   const { toast } = useToast();
-
+  const { data: elevenlabsVoices, isLoading: isLoadingVoices } = useElevenLabsVoices();
   const [isSubmitting, setIsSubmitting] = useState(false);
 
+  // Get all voice models, using the dynamic ElevenLabs voices when available
+  const allVoiceModels = useMemo(() => {
+    const staticModels = getAllVoiceModels();
+
+    // If we have dynamically loaded ElevenLabs voices, replace the static ones
+    if (elevenlabsVoices && !isLoadingVoices) {
+      // Filter out the static ElevenLabs voices
+      const nonElevenLabsModels = staticModels.filter((model) => model.provider !== 'elevenlabs');
+      // Return combined models with dynamic ElevenLabs voices
+      return [...nonElevenLabsModels, ...elevenlabsVoices];
+    }
+
+    // Otherwise return the static models
+    return staticModels;
+  }, [elevenlabsVoices, isLoadingVoices]);
+
+  // Define form schema with dynamic voice model options
+  const CHARACTER_FORM_SCHEMA = useMemo(
+    () => [
+      {
+        sectionTitle: 'Basic Info',
+        sectionValue: 'basic',
+        sectionType: SECTION_TYPE.INPUT,
+        fields: [
+          {
+            title: 'Name',
+            name: 'name',
+            description: 'The display name of your character',
+            fieldType: 'text',
+            getValue: (char) => char.name || '',
+          },
+          {
+            title: 'Username',
+            name: 'username',
+            description: 'Unique identifier for your character',
+            fieldType: 'text',
+            getValue: (char) => char.username || '',
+          },
+          {
+            title: 'System',
+            name: 'system',
+            description: 'System prompt for character behavior',
+            fieldType: 'textarea',
+            getValue: (char) => char.system || '',
+          },
+          {
+            title: 'Voice Model',
+            name: 'settings.voice.model',
+            description: 'Voice model used for speech synthesis',
+            fieldType: 'select',
+            getValue: (char) => char.settings?.voice?.model || '',
+            options: allVoiceModels.map((model) => ({
+              value: model.value,
+              label: model.label,
+            })),
+          },
+        ] as InputField[],
+      },
+      {
+        sectionTitle: 'Content',
+        sectionValue: 'content',
+        sectionType: SECTION_TYPE.ARRAY,
+        fields: [
+          {
+            title: 'Bio',
+            description: 'Key information about your character',
+            path: 'bio',
+            getData: (char) => (Array.isArray(char.bio) ? char.bio : []),
+          },
+          {
+            title: 'Topics',
+            description: 'Topics your character is knowledgeable about',
+            path: 'topics',
+            getData: (char) => char.topics || [],
+          },
+          {
+            title: 'Adjectives',
+            description: "Words that describe your character's personality",
+            path: 'adjectives',
+            getData: (char) => char.adjectives || [],
+          },
+        ] as ArrayField[],
+      },
+      {
+        sectionTitle: 'Style',
+        sectionValue: 'style',
+        sectionType: SECTION_TYPE.ARRAY,
+        fields: [
+          {
+            title: 'All',
+            description: 'Style rules applied to all interactions',
+            path: 'style.all',
+            getData: (char) => char.style?.all || [],
+          },
+          {
+            title: 'Chat',
+            description: 'Style rules for chat interactions',
+            path: 'style.chat',
+            getData: (char) => char.style?.chat || [],
+          },
+          {
+            title: 'Post',
+            description: 'Style rules for social media posts',
+            path: 'style.post',
+            getData: (char) => char.style?.post || [],
+          },
+        ] as ArrayField[],
+      },
+    ],
+    [allVoiceModels]
+  );
+
   const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
     const { name, value, type } = e.target;
     const checked = (e.target as HTMLInputElement).checked;
@@ -183,6 +222,65 @@ export default function CharacterForm({
     }
   };
 
+  const handleVoiceModelChange = (value: string, name: string) => {
+    if (name.startsWith('settings.')) {
+      const path = name.substring(9); // Remove 'settings.' prefix
+
+      if (setCharacterValue.updateSetting) {
+        // Use the specialized method if available
+        setCharacterValue.updateSetting(path, value);
+
+        // Handle voice model change and required plugins
+        if (path === 'voice.model' && value) {
+          const voiceModel = getVoiceModelByValue(value);
+          if (voiceModel) {
+            const currentPlugins = Array.isArray(characterValue.plugins)
+              ? [...characterValue.plugins]
+              : [];
+            const previousVoiceModel = getVoiceModelByValue(characterValue.settings?.voice?.model);
+
+            // Get all voice-related plugins
+            const voicePlugins = getAllRequiredPlugins();
+
+            // Get the required plugin for the new voice model
+            const requiredPlugin = providerPluginMap[voiceModel.provider];
+
+            // Filter out all voice-related plugins
+            const filteredPlugins = currentPlugins.filter(
+              (plugin) => !voicePlugins.includes(plugin)
+            );
+
+            // Add the required plugin for the selected voice model
+            const newPlugins = [...filteredPlugins];
+            if (requiredPlugin && !filteredPlugins.includes(requiredPlugin)) {
+              newPlugins.push(requiredPlugin);
+            }
+
+            // Update the plugins
+            if (setCharacterValue.setPlugins) {
+              setCharacterValue.setPlugins(newPlugins);
+            } else if (setCharacterValue.updateField) {
+              setCharacterValue.updateField('plugins', newPlugins);
+            }
+
+            // Show toast notification
+            if (previousVoiceModel?.provider !== voiceModel.provider) {
+              toast({
+                title: 'Plugin Updated',
+                description: `${requiredPlugin} plugin has been set for the selected voice model.`,
+              });
+            }
+          }
+        }
+      } else {
+        // Fall back to generic updateField
+        setCharacterValue.updateField(name, value);
+      }
+    } else {
+      setCharacterValue.updateField(name, value);
+    }
+  };
+
   const updateArray = (path: string, newData: string[]) => {
     // If the path is a simple field name
     if (!path.includes('.')) {
@@ -268,6 +366,29 @@ export default function CharacterForm({
           checked={(characterValue as Record<string, any>)[field.name] === 'true'}
           onChange={handleChange}
         />
+      ) : field.fieldType === 'select' ? (
+        <Select
+          name={field.name}
+          value={field.getValue(characterValue)}
+          onValueChange={(value) => handleVoiceModelChange(value, field.name)}
+        >
+          <SelectTrigger>
+            <SelectValue
+              placeholder={
+                field.name.includes('voice.model') && isLoadingVoices
+                  ? 'Loading voice models...'
+                  : 'Select a voice model'
+              }
+            />
+          </SelectTrigger>
+          <SelectContent>
+            {field.options?.map((option) => (
+              <SelectItem key={option.value} value={option.value}>
+                {option.label}
+              </SelectItem>
+            ))}
+          </SelectContent>
+        </Select>
       ) : (
         <Input
           id={field.name}
diff --git a/packages/client/src/components/plugins-panel.tsx b/packages/client/src/components/plugins-panel.tsx
index a5b9295844673..dcc6d0c625f99 100644
--- a/packages/client/src/components/plugins-panel.tsx
+++ b/packages/client/src/components/plugins-panel.tsx
@@ -7,9 +7,15 @@ import {
 } from '@/components/ui/dialog';
 import { Input } from '@/components/ui/input';
 import { usePlugins } from '@/hooks/use-plugins';
+import { useToast } from '@/hooks/use-toast';
 import type { Agent } from '@elizaos/core';
 import { useMemo, useState, useEffect } from 'react';
 import { Button } from './ui/button';
+import {
+  getVoiceModelByValue,
+  providerPluginMap,
+  getAllRequiredPlugins,
+} from '../config/voice-models';
 
 interface PluginsPanelProps {
   characterValue: Agent;
@@ -29,6 +35,7 @@ export default function PluginsPanel({
   initialPlugins,
 }: PluginsPanelProps) {
   const { data: plugins, error } = usePlugins();
+  const { toast } = useToast();
   const [searchQuery, setSearchQuery] = useState('');
   const [isDialogOpen, setIsDialogOpen] = useState(false);
 
@@ -50,6 +57,31 @@ export default function PluginsPanel({
     ];
   }, [plugins]);
 
+  // Check if the selected voice model requires specific plugins
+  const voiceModelPluginInfo = useMemo(() => {
+    const voiceModelValue = characterValue?.settings?.voice?.model;
+    if (!voiceModelValue) return null;
+
+    const voiceModel = getVoiceModelByValue(voiceModelValue);
+    if (!voiceModel) return null;
+
+    // Get required plugin from configuration
+    const requiredPlugin = providerPluginMap[voiceModel.provider];
+    const isPluginEnabled = safeCharacterPlugins.includes(requiredPlugin);
+
+    return {
+      provider: voiceModel.provider,
+      requiredPlugin,
+      isPluginEnabled,
+    };
+  }, [characterValue?.settings?.voice?.model, safeCharacterPlugins]);
+
+  // Get all voice-related plugins that are currently enabled
+  const enabledVoicePlugins = useMemo(() => {
+    const voicePlugins = getAllRequiredPlugins();
+    return safeCharacterPlugins.filter((plugin) => voicePlugins.includes(plugin));
+  }, [safeCharacterPlugins]);
+
   const hasChanged = useMemo(() => {
     if (!initialPlugins) return false;
     if (initialPlugins.length !== safeCharacterPlugins.length) return true;
@@ -97,22 +129,69 @@ export default function PluginsPanel({
             <p className="text-destructive">Failed to load plugins: {error.message}</p>
           ) : (
             <div className="space-y-4">
+              {voiceModelPluginInfo && (
+                <div className="rounded-md bg-blue-50 p-4 mb-4">
+                  <p className="text-xs text-blue-700">
+                    {(() => {
+                      switch (voiceModelPluginInfo.provider) {
+                        case 'elevenlabs':
+                          return 'ElevenLabs plugin is required for the selected voice model.';
+                        case 'openai':
+                          return 'OpenAI plugin is required for the selected voice model.';
+                        case 'local':
+                          return 'Local AI plugin is required for the selected voice model.';
+                        case 'none':
+                          return 'No voice plugin required for "No Voice" option.';
+                        default:
+                          return `${voiceModelPluginInfo.provider} plugin is required for the selected voice model.`;
+                      }
+                    })()}
+                  </p>
+                  {enabledVoicePlugins.length > 1 && (
+                    <p className="text-xs text-amber-600 mt-2">
+                      Multiple voice plugins detected. This may cause conflicts. Consider removing
+                      unused voice plugins.
+                    </p>
+                  )}
+                </div>
+              )}
               {safeCharacterPlugins.length > 0 && (
                 <div className="rounded-md bg-muted p-4">
                   <h4 className="text-sm font-medium mb-2">Currently Enabled</h4>
                   <div className="flex flex-wrap gap-2">
-                    {safeCharacterPlugins.map((plugin) => (
-                      <Button
-                        type="button"
-                        variant="ghost"
-                        size="sm"
-                        key={plugin}
-                        className="inline-flex items-center rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary hover:bg-primary/20 h-auto"
-                        onClick={() => handlePluginRemove(plugin)}
-                      >
-                        {plugin} ×
-                      </Button>
-                    ))}
+                    {safeCharacterPlugins.map((plugin) => {
+                      // Check if this plugin is required by the current voice model
+                      const isRequiredByVoice = voiceModelPluginInfo?.requiredPlugin === plugin;
+
+                      return (
+                        <Button
+                          type="button"
+                          variant="ghost"
+                          size="sm"
+                          key={plugin}
+                          className={`inline-flex items-center rounded-full ${
+                            isRequiredByVoice
+                              ? 'bg-green-100 text-green-700 hover:bg-green-200'
+                              : 'bg-primary/10 text-primary hover:bg-primary/20'
+                          } px-2.5 py-0.5 text-xs font-medium h-auto`}
+                          onClick={() => {
+                            // Don't allow removing if it's required by the voice model
+                            if (isRequiredByVoice) {
+                              toast({
+                                title: "Can't Remove Plugin",
+                                description: 'This plugin is required by the selected voice model.',
+                                variant: 'destructive',
+                              });
+                              return;
+                            }
+                            handlePluginRemove(plugin);
+                          }}
+                          title={isRequiredByVoice ? 'Required by voice model' : 'Click to remove'}
+                        >
+                          {plugin} {!isRequiredByVoice && '×'}
+                        </Button>
+                      );
+                    })}
                   </div>
                 </div>
               )}
diff --git a/packages/client/src/config/voice-models.ts b/packages/client/src/config/voice-models.ts
new file mode 100644
index 0000000000000..70719f527b21a
--- /dev/null
+++ b/packages/client/src/config/voice-models.ts
@@ -0,0 +1,189 @@
+export interface VoiceModel {
+  value: string;
+  label: string;
+  provider: 'local' | 'elevenlabs' | 'openai' | 'none';
+  gender?: 'male' | 'female';
+  language?: string;
+  features?: string[];
+}
+
+// TODO: ELI2-218 Refactor this to use plugin categories when available
+// This hardcoded mapping will be replaced with a more flexible approach
+// that leverages plugin category metadata once implemented
+
+export const providerPluginMap: Record<string, string> = {
+  elevenlabs: '@elizaos/plugin-elevenlabs',
+  local: '@elizaos/plugin-local-ai',
+  openai: '@elizaos/plugin-openai',
+  none: '', // No plugin needed for "No Voice" option
+};
+
+// No voice option for agents that don't need speech capabilities
+export const noVoiceModel: VoiceModel[] = [{ value: 'none', label: 'No Voice', provider: 'none' }];
+
+export const localVoiceModels: VoiceModel[] = [
+  { value: 'female_1', label: 'Local Voice - Female 1', provider: 'local', gender: 'female' },
+  { value: 'female_2', label: 'Local Voice - Female 2', provider: 'local', gender: 'female' },
+  { value: 'male_1', label: 'Local Voice - Male 1', provider: 'local', gender: 'male' },
+  { value: 'male_2', label: 'Local Voice - Male 2', provider: 'local', gender: 'male' },
+];
+
+export const elevenLabsVoiceModels: VoiceModel[] = [
+  {
+    value: 'EXAVITQu4vr4xnSDxMaL',
+    label: 'ElevenLabs - Rachel (Default)',
+    provider: 'elevenlabs',
+    gender: 'female',
+    language: 'en',
+    features: ['natural', 'professional'],
+  },
+  {
+    value: '21m00Tcm4TlvDq8ikWAM',
+    label: 'ElevenLabs - Adam',
+    provider: 'elevenlabs',
+    gender: 'male',
+    language: 'en',
+    features: ['natural', 'professional'],
+  },
+  {
+    value: 'AZnzlk1XvdvUeBnXmlld',
+    label: 'ElevenLabs - Domi',
+    provider: 'elevenlabs',
+    gender: 'female',
+    language: 'en',
+    features: ['natural', 'friendly'],
+  },
+  {
+    value: 'MF3mGyEYCl7XYWbV9V6O',
+    label: 'ElevenLabs - Elli',
+    provider: 'elevenlabs',
+    gender: 'female',
+    language: 'en',
+    features: ['natural', 'friendly'],
+  },
+  {
+    value: 'TxGEqnHWrfWFTfGW9XjX',
+    label: 'ElevenLabs - Josh',
+    provider: 'elevenlabs',
+    gender: 'male',
+    language: 'en',
+    features: ['natural', 'professional'],
+  },
+];
+
+export const openAIVoiceModels: VoiceModel[] = [
+  {
+    value: 'alloy',
+    label: 'OpenAI - Alloy',
+    provider: 'openai',
+    gender: 'female',
+    language: 'en',
+    features: ['natural', 'versatile'],
+  },
+  {
+    value: 'echo',
+    label: 'OpenAI - Echo',
+    provider: 'openai',
+    gender: 'male',
+    language: 'en',
+    features: ['natural', 'professional'],
+  },
+  {
+    value: 'fable',
+    label: 'OpenAI - Fable',
+    provider: 'openai',
+    gender: 'male',
+    language: 'en',
+    features: ['natural', 'narrative'],
+  },
+  {
+    value: 'onyx',
+    label: 'OpenAI - Onyx',
+    provider: 'openai',
+    gender: 'male',
+    language: 'en',
+    features: ['natural', 'deep'],
+  },
+  {
+    value: 'nova',
+    label: 'OpenAI - Nova',
+    provider: 'openai',
+    gender: 'female',
+    language: 'en',
+    features: ['natural', 'friendly'],
+  },
+  {
+    value: 'shimmer',
+    label: 'OpenAI - Shimmer',
+    provider: 'openai',
+    gender: 'female',
+    language: 'en',
+    features: ['natural', 'bright'],
+  },
+  {
+    value: 'ash',
+    label: 'OpenAI - Ash',
+    provider: 'openai',
+    gender: 'male',
+    language: 'en',
+    features: ['natural', 'calm'],
+  },
+  {
+    value: 'coral',
+    label: 'OpenAI - Coral',
+    provider: 'openai',
+    gender: 'female',
+    language: 'en',
+    features: ['natural', 'warm'],
+  },
+  {
+    value: 'sage',
+    label: 'OpenAI - Sage',
+    provider: 'openai',
+    gender: 'female',
+    language: 'en',
+    features: ['natural', 'wise'],
+  },
+  {
+    value: 'ballad',
+    label: 'OpenAI - Ballad',
+    provider: 'openai',
+    gender: 'male',
+    language: 'en',
+    features: ['natural', 'melodic'],
+  },
+];
+
+export const getAllVoiceModels = (): VoiceModel[] => {
+  return [...noVoiceModel, ...localVoiceModels, ...elevenLabsVoiceModels, ...openAIVoiceModels];
+};
+
+export const getVoiceModelsByProvider = (
+  provider: 'local' | 'elevenlabs' | 'openai' | 'none'
+): VoiceModel[] => {
+  switch (provider) {
+    case 'local':
+      return localVoiceModels;
+    case 'elevenlabs':
+      return elevenLabsVoiceModels;
+    case 'openai':
+      return openAIVoiceModels;
+    case 'none':
+      return noVoiceModel;
+    default:
+      return [];
+  }
+};
+
+export const getVoiceModelByValue = (value: string): VoiceModel | undefined => {
+  return getAllVoiceModels().find((model) => model.value === value);
+};
+
+export const getRequiredPluginForProvider = (provider: string): string | undefined => {
+  return providerPluginMap[provider];
+};
+
+export const getAllRequiredPlugins = (): string[] => {
+  // Filter out empty strings (for "none" provider)
+  return Object.values(providerPluginMap).filter(Boolean);
+};
diff --git a/packages/client/src/hooks/use-elevenlabs-voices.ts b/packages/client/src/hooks/use-elevenlabs-voices.ts
new file mode 100644
index 0000000000000..78d75a0caf476
--- /dev/null
+++ b/packages/client/src/hooks/use-elevenlabs-voices.ts
@@ -0,0 +1,75 @@
+import { useState, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { elevenLabsVoiceModels } from '@/config/voice-models';
+import type { VoiceModel } from '@/config/voice-models';
+
+// TODO: Move this to a shared config file, or the 11labs plugin once plugin categories are implemented
+
+interface ElevenLabsVoice {
+  voice_id: string;
+  name: string;
+  category: string;
+  labels?: {
+    accent?: string;
+    age?: string;
+    description?: string;
+    gender?: string;
+    use_case?: string;
+  };
+  preview_url?: string;
+}
+
+export function useElevenLabsVoices() {
+  const [apiKey, setApiKey] = useState<string | null>(null);
+
+  // Load API key from localStorage or another source
+  useEffect(() => {
+    const storedKey = localStorage.getItem('ELEVENLABS_API_KEY');
+    setApiKey(storedKey);
+  }, []);
+
+  return useQuery({
+    queryKey: ['elevenlabs-voices', apiKey],
+    queryFn: async () => {
+      // If no API key is available, use the hardcoded models
+      if (!apiKey) {
+        return elevenLabsVoiceModels;
+      }
+
+      try {
+        const response = await fetch('https://api.elevenlabs.io/v2/voices', {
+          method: 'GET',
+          headers: {
+            'xi-api-key': apiKey,
+          },
+        });
+
+        if (!response.ok) {
+          console.error('Failed to fetch ElevenLabs voices:', response.statusText);
+          return elevenLabsVoiceModels;
+        }
+
+        const data = await response.json();
+
+        // Transform the API response to match our VoiceModel format
+        const apiVoices: VoiceModel[] = data.voices.map((voice: ElevenLabsVoice) => ({
+          value: voice.voice_id,
+          label: `ElevenLabs - ${voice.name}`,
+          provider: 'elevenlabs',
+          gender: voice.labels?.gender === 'female' ? 'female' : 'male',
+          language: 'en',
+          features: [voice.category || 'professional', voice.labels?.description || 'natural'],
+        }));
+
+        return apiVoices;
+      } catch (error) {
+        console.error('Error fetching ElevenLabs voices:', error);
+        return elevenLabsVoiceModels;
+      }
+    },
+    // Refresh the data every hour
+    staleTime: 60 * 60 * 1000,
+    // Don't refetch on window focus
+    refetchOnWindowFocus: false,
+  });
+}


Reviews:
user: login: mcp97
body: LGTM outside of comments - great work
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-04-10T23:27:21Z
user: login: Y4NK33420
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T23:51:23Z
user: login: Y4NK33420
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T23:51:39Z
user: login: Y4NK33420
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T23:53:44Z
user: login: Y4NK33420
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T23:54:31Z
user: login: mcp97
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T00:56:53Z

Review comments:
diff_hunk: @@ -0,0 +1,84 @@
+export interface VoiceModel {
+  value: string;
+  label: string;
+  provider: 'local' | 'elevenlabs';
+  gender?: 'male' | 'female';
+  language?: string;
+  features?: string[];
+}
+
+// Map of voice model providers to their required plugins
+export const providerPluginMap: Record<string, string> = {
+  elevenlabs: '@elizaos/plugin-elevenlabs',
+  local: '@elizaos/plugin-local-ai',
+};
path: packages/client/src/config/voice-models.ts
user: login: mcp97
body: This will be hard to maintain so we will want to revisit this once we add plugin categories + please make a comment here and add it as a task on linear and inline the task # thanks
created_at: 2025-04-10T23:24:23Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,84 @@
+export interface VoiceModel {
+  value: string;
+  label: string;
+  provider: 'local' | 'elevenlabs';
+  gender?: 'male' | 'female';
+  language?: string;
+  features?: string[];
+}
+
+// Map of voice model providers to their required plugins
+export const providerPluginMap: Record<string, string> = {
+  elevenlabs: '@elizaos/plugin-elevenlabs',
+  local: '@elizaos/plugin-local-ai',
+};
+
+export const localVoiceModels: VoiceModel[] = [
+  { value: 'female_1', label: 'Local Voice - Female 1', provider: 'local', gender: 'female' },
+  { value: 'female_2', label: 'Local Voice - Female 2', provider: 'local', gender: 'female' },
+  { value: 'male_1', label: 'Local Voice - Male 1', provider: 'local', gender: 'male' },
+  { value: 'male_2', label: 'Local Voice - Male 2', provider: 'local', gender: 'male' },
+];
+
+export const elevenLabsVoiceModels: VoiceModel[] = [
path: packages/client/src/config/voice-models.ts
user: login: mcp97
body: Any way for us to get this dynamically instead of hardcoding?
created_at: 2025-04-10T23:24:45Z
author_association: COLLABORATOR
diff_hunk: @@ -97,22 +129,58 @@ export default function PluginsPanel({
             <p className="text-destructive">Failed to load plugins: {error.message}</p>
           ) : (
             <div className="space-y-4">
+              {voiceModelPluginInfo && (
+                <div className="rounded-md bg-blue-50 p-4 mb-4">
+                  <p className="text-xs text-blue-700">
+                    {voiceModelPluginInfo.provider === 'elevenlabs'
+                      ? 'ElevenLabs plugin is required for the selected voice model.'
+                      : 'Local AI plugin is required for the selected voice model.'}
path: packages/client/src/components/plugins-panel.tsx
user: login: mcp97
body: this will fail on additional of voice model can we make it more generic and have a switch statement 
created_at: 2025-04-10T23:25:51Z
author_association: COLLABORATOR
diff_hunk: @@ -161,7 +229,7 @@ export default function PluginsPanel({
                 </Dialog>
               </div>
               {hasChanged && (
-                <p className="text-xs text-yellow-500">Plugins configuration has been changed</p>
+                <p className="text-xs text-blue-500">Plugins configuration has been updated</p>
path: packages/client/src/components/plugins-panel.tsx
user: login: mcp97
body: is this change needed?
created_at: 2025-04-10T23:26:16Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,84 @@
+export interface VoiceModel {
+  value: string;
+  label: string;
+  provider: 'local' | 'elevenlabs';
+  gender?: 'male' | 'female';
+  language?: string;
+  features?: string[];
+}
+
+// Map of voice model providers to their required plugins
+export const providerPluginMap: Record<string, string> = {
+  elevenlabs: '@elizaos/plugin-elevenlabs',
+  local: '@elizaos/plugin-local-ai',
+};
+
+export const localVoiceModels: VoiceModel[] = [
+  { value: 'female_1', label: 'Local Voice - Female 1', provider: 'local', gender: 'female' },
+  { value: 'female_2', label: 'Local Voice - Female 2', provider: 'local', gender: 'female' },
+  { value: 'male_1', label: 'Local Voice - Male 1', provider: 'local', gender: 'male' },
+  { value: 'male_2', label: 'Local Voice - Male 2', provider: 'local', gender: 'male' },
+];
+
+export const elevenLabsVoiceModels: VoiceModel[] = [
path: packages/client/src/config/voice-models.ts
user: login: Y4NK33420
body: For the 11labs plugin I found an endpoint for the voices which can be easily integrated, since it requires an api-key, we should still add a fallback hardcoded value if api key is not present before selecting the voice, for the local-ai I found speakers definition in the plugin which I will use here. Is that okay, or do you suggest something else, we can also enforce a common method for all tts providers that provides the speakers supported by them, this way we can standardize the process for future providers.

created_at: 2025-04-10T23:51:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,22 +129,58 @@ export default function PluginsPanel({
             <p className="text-destructive">Failed to load plugins: {error.message}</p>
           ) : (
             <div className="space-y-4">
+              {voiceModelPluginInfo && (
+                <div className="rounded-md bg-blue-50 p-4 mb-4">
+                  <p className="text-xs text-blue-700">
+                    {voiceModelPluginInfo.provider === 'elevenlabs'
+                      ? 'ElevenLabs plugin is required for the selected voice model.'
+                      : 'Local AI plugin is required for the selected voice model.'}
path: packages/client/src/components/plugins-panel.tsx
user: login: Y4NK33420
body: sure will do that

created_at: 2025-04-10T23:51:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -161,7 +229,7 @@ export default function PluginsPanel({
                 </Dialog>
               </div>
               {hasChanged && (
-                <p className="text-xs text-yellow-500">Plugins configuration has been changed</p>
+                <p className="text-xs text-blue-500">Plugins configuration has been updated</p>
path: packages/client/src/components/plugins-panel.tsx
user: login: Y4NK33420
body: I guess not, sorry for that, will revert that line in the next commit.
created_at: 2025-04-10T23:53:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,84 @@
+export interface VoiceModel {
+  value: string;
+  label: string;
+  provider: 'local' | 'elevenlabs';
+  gender?: 'male' | 'female';
+  language?: string;
+  features?: string[];
+}
+
+// Map of voice model providers to their required plugins
+export const providerPluginMap: Record<string, string> = {
+  elevenlabs: '@elizaos/plugin-elevenlabs',
+  local: '@elizaos/plugin-local-ai',
+};
path: packages/client/src/config/voice-models.ts
user: login: Y4NK33420
body: sure will do that
created_at: 2025-04-10T23:54:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,84 @@
+export interface VoiceModel {
+  value: string;
+  label: string;
+  provider: 'local' | 'elevenlabs';
+  gender?: 'male' | 'female';
+  language?: string;
+  features?: string[];
+}
+
+// Map of voice model providers to their required plugins
+export const providerPluginMap: Record<string, string> = {
+  elevenlabs: '@elizaos/plugin-elevenlabs',
+  local: '@elizaos/plugin-local-ai',
+};
+
+export const localVoiceModels: VoiceModel[] = [
+  { value: 'female_1', label: 'Local Voice - Female 1', provider: 'local', gender: 'female' },
+  { value: 'female_2', label: 'Local Voice - Female 2', provider: 'local', gender: 'female' },
+  { value: 'male_1', label: 'Local Voice - Male 1', provider: 'local', gender: 'male' },
+  { value: 'male_2', label: 'Local Voice - Male 2', provider: 'local', gender: 'male' },
+];
+
+export const elevenLabsVoiceModels: VoiceModel[] = [
path: packages/client/src/config/voice-models.ts
user: login: mcp97
body: Agreed regarding having the hard coded fallback. I think that is okay for now regarding the speakers plugin. let's create a ticket for ( do you suggest something else, we can also enforce a common method for all tts providers that provides the speakers supported by them, this way we can standardize the process for future providers.) 
created_at: 2025-04-11T00:56:53Z
author_association: COLLABORATOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-04-10T23:20:49Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Review skipped
> 
> Auto reviews are disabled on this repository.
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command.
> 
> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.

<!-- end of auto-generated comment: skip review by coderabbit.ai -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=elizaOS/eliza&utm_content=4262):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR
user: login: tcm390
created_at: 2025-04-11T02:20:45Z
body: Btw openai tts has been integrated: https://github.com/elizaOS/eliza/pull/4255
author_association: COLLABORATOR
user: login: tcm390
created_at: 2025-04-11T02:36:45Z
body: https://platform.openai.com/docs/api-reference/audio/createSpeech
author_association: COLLABORATOR
user: login: Y4NK33420
created_at: 2025-04-11T02:53:56Z
body: Sure, will add support for that as well.

author_association: CONTRIBUTOR
user: login: mcp97
created_at: 2025-04-11T19:49:08Z
body: Looks great thanks for addressing the comments :) 
author_association: COLLABORATOR