diff --git a/wavefront/client/src/api/tool-service.ts b/wavefront/client/src/api/tool-service.ts index 5ed8b3ea..e432b980 100644 --- a/wavefront/client/src/api/tool-service.ts +++ b/wavefront/client/src/api/tool-service.ts @@ -1,22 +1,112 @@ import { IApiResponse } from '@app/lib/axios'; -import { ToolDetailsData, ToolDetailsResponse, ToolNamesData, ToolNamesResponse } from '@app/types/tool'; +import { + VoiceAgentTool, + CreateToolRequest, + UpdateToolRequest, + AttachToolToAgentRequest, + UpdateAgentToolRequest, + ListToolsParams, + ToolResponse, + ToolDetailResponse, + ToolListResponse, + AgentToolsResponse, + ToolListData, + AgentToolsData, + ToolData, +} from '@app/types/tool'; import { AxiosInstance } from 'axios'; export class ToolService { constructor(private http: AxiosInstance) {} - async getToolNames(): Promise { - const response: IApiResponse = await this.http.get(`/v1/:appId/floware/v1/tools/names`); + /** + * Create a new tool + */ + async createTool(data: CreateToolRequest): Promise { + const response: IApiResponse = await this.http.post(`/v1/:appId/floware/v1/tools`, data); return response; } - async getToolNamesAndDetails(): Promise { - const response: IApiResponse = await this.http.get(`/v1/:appId/floware/v1/tools/tool-details`); + /** + * Get a single tool by ID + */ + async getTool(toolId: string): Promise { + const response: IApiResponse = await this.http.get(`/v1/:appId/floware/v1/tools/${toolId}`); return response; } - async getToolDetails(toolName: string): Promise { - const response: IApiResponse = await this.http.get(`/v1/:appId/floware/v1/tools/${toolName}`); + /** + * List all tools with optional filters + */ + async listTools(params?: ListToolsParams): Promise { + const response: IApiResponse = await this.http.get(`/v1/:appId/floware/v1/tools`, { params }); + return response; + } + + /** + * Get tool names and details + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async getToolNamesAndDetails(): Promise { + const response = await this.http.get(`/v1/:appId/floware/v1/tools/names`); + return response; + } + + /** + * Update an existing tool + */ + async updateTool(toolId: string, data: UpdateToolRequest): Promise { + const response: IApiResponse = await this.http.patch(`/v1/:appId/floware/v1/tools/${toolId}`, data); + return response; + } + + /** + * Delete a tool (soft delete) + */ + async deleteTool(toolId: string): Promise { + const response: IApiResponse = await this.http.delete(`/v1/:appId/floware/v1/tools/${toolId}`); + return response; + } + + /** + * Attach a tool to a voice agent + */ + async attachToolToAgent(agentId: string, data: AttachToolToAgentRequest): Promise { + const response: IApiResponse = await this.http.post( + `/v1/:appId/floware/v1/voice-agents/${agentId}/tools`, + data + ); + return response; + } + + /** + * Get all tools attached to a voice agent + */ + async getAgentTools(agentId: string): Promise { + const response: IApiResponse = await this.http.get( + `/v1/:appId/floware/v1/voice-agents/${agentId}/tools` + ); + return response; + } + + /** + * Update a tool association for a voice agent + */ + async updateAgentTool(agentId: string, toolId: string, data: UpdateAgentToolRequest): Promise { + const response: IApiResponse = await this.http.patch( + `/v1/:appId/floware/v1/voice-agents/${agentId}/tools/${toolId}`, + data + ); + return response; + } + + /** + * Detach a tool from a voice agent + */ + async detachToolFromAgent(agentId: string, toolId: string): Promise { + const response: IApiResponse = await this.http.delete( + `/v1/:appId/floware/v1/voice-agents/${agentId}/tools/${toolId}` + ); return response; } } diff --git a/wavefront/client/src/components/ui/badge.tsx b/wavefront/client/src/components/ui/badge.tsx new file mode 100644 index 00000000..27be7fda --- /dev/null +++ b/wavefront/client/src/components/ui/badge.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@app/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', + secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps extends React.HTMLAttributes, VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/wavefront/client/src/constants/languages.ts b/wavefront/client/src/constants/languages.ts new file mode 100644 index 00000000..8de0af4f --- /dev/null +++ b/wavefront/client/src/constants/languages.ts @@ -0,0 +1,65 @@ +/** + * Language constants for voice agent multi-language support + * Matches backend language validation + */ + +export interface LanguageOption { + code: string; + name: string; + nativeName?: string; +} + +export const SUPPORTED_LANGUAGES: LanguageOption[] = [ + { code: 'en', name: 'English' }, + { code: 'es', name: 'Spanish', nativeName: 'Español' }, + { code: 'hi', name: 'Hindi', nativeName: 'हिंदी' }, + { code: 'te', name: 'Telugu', nativeName: 'తెలుగు' }, + { code: 'ta', name: 'Tamil', nativeName: 'தமிழ்' }, + { code: 'kn', name: 'Kannada', nativeName: 'ಕನ್ನಡ' }, + { code: 'ml', name: 'Malayalam', nativeName: 'മലയാളം' }, + { code: 'mr', name: 'Marathi', nativeName: 'मराठी' }, + { code: 'gu', name: 'Gujarati', nativeName: 'ગુજરાતી' }, + { code: 'pa', name: 'Punjabi', nativeName: 'ਪੰਜਾਬੀ' }, + { code: 'bn', name: 'Bengali', nativeName: 'বাংলা' }, + { code: 'zh', name: 'Chinese', nativeName: '中文' }, + { code: 'ja', name: 'Japanese', nativeName: '日本語' }, + { code: 'ko', name: 'Korean', nativeName: '한국어' }, + { code: 'fr', name: 'French', nativeName: 'Français' }, + { code: 'de', name: 'German', nativeName: 'Deutsch' }, + { code: 'it', name: 'Italian', nativeName: 'Italiano' }, + { code: 'pt', name: 'Portuguese', nativeName: 'Português' }, + { code: 'ru', name: 'Russian', nativeName: 'Русский' }, + { code: 'ar', name: 'Arabic', nativeName: 'العربية' }, + { code: 'tr', name: 'Turkish', nativeName: 'Türkçe' }, + { code: 'vi', name: 'Vietnamese', nativeName: 'Tiếng Việt' }, + { code: 'th', name: 'Thai', nativeName: 'ภาษาไทย' }, + { code: 'id', name: 'Indonesian', nativeName: 'Bahasa Indonesia' }, + { code: 'ms', name: 'Malay', nativeName: 'Bahasa Melayu' }, + { code: 'nl', name: 'Dutch', nativeName: 'Nederlands' }, + { code: 'pl', name: 'Polish', nativeName: 'Polski' }, + { code: 'uk', name: 'Ukrainian', nativeName: 'Українська' }, + { code: 'ro', name: 'Romanian', nativeName: 'Română' }, + { code: 'cs', name: 'Czech', nativeName: 'Čeština' }, + { code: 'sv', name: 'Swedish', nativeName: 'Svenska' }, + { code: 'da', name: 'Danish', nativeName: 'Dansk' }, + { code: 'no', name: 'Norwegian', nativeName: 'Norsk' }, + { code: 'fi', name: 'Finnish', nativeName: 'Suomi' }, + { code: 'el', name: 'Greek', nativeName: 'Ελληνικά' }, + { code: 'he', name: 'Hebrew', nativeName: 'עברית' }, + { code: 'hu', name: 'Hungarian', nativeName: 'Magyar' }, + { code: 'sk', name: 'Slovak', nativeName: 'Slovenčina' }, + { code: 'bg', name: 'Bulgarian', nativeName: 'Български' }, + { code: 'hr', name: 'Croatian', nativeName: 'Hrvatski' }, + { code: 'fil', name: 'Filipino' }, +]; + +export const getLanguageName = (code: string): string => { + const lang = SUPPORTED_LANGUAGES.find((l) => l.code === code); + return lang ? lang.name : code; +}; + +export const getLanguageDisplayName = (code: string): string => { + const lang = SUPPORTED_LANGUAGES.find((l) => l.code === code); + if (!lang) return code; + return lang.nativeName ? `${lang.name} (${lang.nativeName})` : lang.name; +}; diff --git a/wavefront/client/src/hooks/data/fetch-hooks.ts b/wavefront/client/src/hooks/data/fetch-hooks.ts index 41da45a3..c49feb3d 100644 --- a/wavefront/client/src/hooks/data/fetch-hooks.ts +++ b/wavefront/client/src/hooks/data/fetch-hooks.ts @@ -12,7 +12,7 @@ import { MessageProcessor, MessageProcessorListItem } from '@app/types/message-p import { Pipeline, PipelineFile, PipelineStatus } from '@app/types/pipeline'; import { SttConfig } from '@app/types/stt-config'; import { TelephonyConfig } from '@app/types/telephony-config'; -import { ToolDetails } from '@app/types/tool'; +import { ToolDetails, VoiceAgentTool, VoiceAgentToolWithAssociation } from '@app/types/tool'; import { TtsConfig } from '@app/types/tts-config'; import { VoiceAgent } from '@app/types/voice-agent'; import { WorkflowListItem, WorkflowPipelineListItem, WorkflowRunListData } from '@app/types/workflow'; @@ -54,12 +54,15 @@ import { getToolsQueryFn, getTtsConfigQueryFn, getTtsConfigsQueryFn, + getUsersQueryFn, getVoiceAgentQueryFn, + getVoiceAgentToolQueryFn, + getVoiceAgentToolsQueryFn, + getAgentToolsQueryFn, getVoiceAgentsQueryFn, getWorkflowPipelinesQueryFn, getWorkflowRunsQueryFn, getWorkflowsQueryFn, - getUsersQueryFn, readYamlQueryFn, } from './query-functions'; import { @@ -97,12 +100,15 @@ import { getToolsKey, getTtsConfigKey, getTtsConfigsKey, + getUsersKey, getVoiceAgentKey, + getVoiceAgentToolKey, + getVoiceAgentToolsKey, + getAgentToolsKey, getVoiceAgentsKey, getWorkflowPipelinesKey, getWorkflowRunsKey, getWorkflowsKey, - getUsersKey, readYamlKey, } from './query-keys'; @@ -414,3 +420,30 @@ export const useGetAppById = (appId: string, enabled: boolean = true): UseQueryR export const useGetUsers = (): UseQueryResult => { return useQueryInit(getUsersKey(), getUsersQueryFn, true); }; + +// Voice Agent Tools Hooks +export const useGetVoiceAgentTools = (appId: string | undefined): UseQueryResult => { + return useQueryInit(getVoiceAgentToolsKey(appId || ''), getVoiceAgentToolsQueryFn, !!appId); +}; + +export const useGetVoiceAgentTool = ( + appId: string | undefined, + toolId: string | undefined +): UseQueryResult => { + return useQueryInit( + getVoiceAgentToolKey(appId || '', toolId || ''), + () => getVoiceAgentToolQueryFn(toolId!), + !!appId && !!toolId + ); +}; + +export const useGetAgentTools = ( + appId: string | undefined, + agentId: string | undefined +): UseQueryResult => { + return useQueryInit( + getAgentToolsKey(appId || '', agentId || ''), + () => getAgentToolsQueryFn(agentId!), + !!appId && !!agentId + ); +}; diff --git a/wavefront/client/src/hooks/data/query-functions.ts b/wavefront/client/src/hooks/data/query-functions.ts index a7a12619..4b463c6f 100644 --- a/wavefront/client/src/hooks/data/query-functions.ts +++ b/wavefront/client/src/hooks/data/query-functions.ts @@ -11,7 +11,7 @@ import { MessageProcessor, MessageProcessorListItem } from '@app/types/message-p import { Pipeline, PipelineFile, PipelineStatus } from '@app/types/pipeline'; import { SttConfig } from '@app/types/stt-config'; import { TelephonyConfig } from '@app/types/telephony-config'; -import { ToolDetails } from '@app/types/tool'; +import { ToolDetails, VoiceAgentTool, VoiceAgentToolWithAssociation } from '@app/types/tool'; import { TtsConfig } from '@app/types/tts-config'; import { IUser } from '@app/types/user'; import { VoiceAgent } from '@app/types/voice-agent'; @@ -371,6 +371,31 @@ const getAppByIdFn = async (appId: string) => { return data?.app; }; +// Voice Agent Tools Query Functions +const getVoiceAgentToolsQueryFn = async (): Promise => { + const response = await floConsoleService.toolService.listTools(); + if (response.data?.meta?.status === 'success' && response.data.data?.tools) { + return response.data.data.tools; + } + return []; +}; + +const getVoiceAgentToolQueryFn = async (toolId: string): Promise => { + const response = await floConsoleService.toolService.getTool(toolId); + if (response.data?.meta?.status === 'success' && response.data.data) { + return response.data.data; + } + return null; +}; + +const getAgentToolsQueryFn = async (agentId: string): Promise => { + const response = await floConsoleService.toolService.getAgentTools(agentId); + if (response.data?.meta?.status === 'success' && response.data.data?.tools) { + return response.data.data.tools; + } + return []; +}; + const getUsersQueryFn = async (): Promise => { const response = await floConsoleService.userService.listUsers(); if (response.data?.data?.users && Array.isArray(response.data.data.users)) { @@ -382,6 +407,7 @@ const getUsersQueryFn = async (): Promise => { export { getAgentQueryFn, getAgentsQueryFn, + getAgentToolsQueryFn, getAllAppsQueryFn, getAllDatasourcesQueryFn, getAllYamlsQueryFn, @@ -414,11 +440,13 @@ export { getToolsQueryFn, getTtsConfigQueryFn, getTtsConfigsQueryFn, + getUsersQueryFn, getVoiceAgentQueryFn, + getVoiceAgentToolQueryFn, + getVoiceAgentToolsQueryFn, getVoiceAgentsQueryFn, getWorkflowPipelinesQueryFn, getWorkflowRunsQueryFn, getWorkflowsQueryFn, - getUsersQueryFn, readYamlQueryFn, }; diff --git a/wavefront/client/src/hooks/data/query-keys.ts b/wavefront/client/src/hooks/data/query-keys.ts index 4f9dae8c..b7cddee6 100644 --- a/wavefront/client/src/hooks/data/query-keys.ts +++ b/wavefront/client/src/hooks/data/query-keys.ts @@ -64,10 +64,14 @@ const getPipelineFilesKey = (appId: string, pipelineId: string) => ['pipeline-fi const getAppByIdKey = (appId: string) => ['app-by-id', appId]; const getUsersKey = () => ['users']; const getUserKey = (userId: string) => ['user', userId]; +const getVoiceAgentToolsKey = (appId: string) => ['voice-agent-tools', appId]; +const getVoiceAgentToolKey = (appId: string, toolId: string) => ['voice-agent-tool', appId, toolId]; +const getAgentToolsKey = (appId: string, agentId: string) => ['agent-tools', appId, agentId]; export { getAgentKey, getAgentsKey, + getAgentToolsKey, getAllAppsKey, getAllDatasourcesKey, getApiServiceKey, @@ -100,12 +104,14 @@ export { getToolsKey, getTtsConfigKey, getTtsConfigsKey, + getUserKey, + getUsersKey, getVoiceAgentKey, + getVoiceAgentToolKey, + getVoiceAgentToolsKey, getVoiceAgentsKey, getWorkflowPipelinesKey, getWorkflowRunsKey, getWorkflowsKey, getAppByIdKey, - getUserKey, - getUsersKey, }; diff --git a/wavefront/client/src/pages/apps/[appId]/voice-agents/CreateVoiceAgentDialog.tsx b/wavefront/client/src/pages/apps/[appId]/voice-agents/CreateVoiceAgentDialog.tsx index a7d91e68..ec275b5f 100644 --- a/wavefront/client/src/pages/apps/[appId]/voice-agents/CreateVoiceAgentDialog.tsx +++ b/wavefront/client/src/pages/apps/[appId]/voice-agents/CreateVoiceAgentDialog.tsx @@ -19,6 +19,9 @@ import { } from '@app/components/ui/form'; import { Input } from '@app/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@app/components/ui/select'; +import { Checkbox } from '@app/components/ui/checkbox'; +import { Label } from '@app/components/ui/label'; +import { Slider } from '@app/components/ui/slider'; import { useGetLLMConfigs, useGetSttConfigs, @@ -28,6 +31,14 @@ import { import { extractErrorMessage } from '@app/lib/utils'; import { useDashboardStore, useNotifyStore } from '@app/store'; import { CreateVoiceAgentRequest } from '@app/types/voice-agent'; +import { SUPPORTED_LANGUAGES, getLanguageDisplayName } from '@app/constants/languages'; +import { getProviderConfig, initializeParameters } from '@app/config/voice-providers'; +import { + getBooleanParameterWithDefault, + getNumberOrStringParameter, + getNumberParameterWithDefault, + getStringParameter, +} from '@app/utils/parameter-helpers'; import { zodResolver } from '@hookform/resolvers/zod'; import { langs } from '@uiw/codemirror-extensions-langs'; import CodeMirror from '@uiw/react-codemirror'; @@ -36,6 +47,8 @@ import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { z } from 'zod'; +const E164_REGEX = /^\+[1-9]\d{1,14}$/; + const createVoiceAgentSchema = z.object({ name: z.string().min(1, 'Name is required').max(100, 'Name must be 100 characters or less'), description: z.string().max(500, 'Description must be 500 characters or less').optional(), @@ -43,10 +56,15 @@ const createVoiceAgentSchema = z.object({ tts_config_id: z.string().min(1, 'TTS configuration is required'), stt_config_id: z.string().min(1, 'STT configuration is required'), telephony_config_id: z.string().min(1, 'Telephony configuration is required'), + tts_voice_id: z.string().min(1, 'TTS Voice ID is required'), system_prompt: z.string().min(1, 'System prompt is required'), welcome_message: z.string().min(1, 'Welcome message is required'), conversation_config: z.string().optional(), status: z.enum(['active', 'inactive']), + inbound_numbers: z.string().optional(), + outbound_numbers: z.string().optional(), + supported_languages: z.array(z.string()).min(1, 'At least one language is required'), + default_language: z.string().min(1, 'Default language is required'), }); type CreateVoiceAgentInput = z.infer; @@ -63,6 +81,8 @@ const CreateVoiceAgentDialog: React.FC = ({ isOpen, const { notifySuccess, notifyError } = useNotifyStore(); const { selectedApp } = useDashboardStore(); const [creating, setCreating] = useState(false); + const [ttsParameters, setTtsParameters] = useState>({}); + const [sttParameters, setSttParameters] = useState>({}); // Fetch configs for dropdowns const { data: llmConfigs = [] } = useGetLLMConfigs(appId); @@ -79,13 +99,39 @@ const CreateVoiceAgentDialog: React.FC = ({ isOpen, tts_config_id: '', stt_config_id: '', telephony_config_id: '', + tts_voice_id: '', system_prompt: '', welcome_message: '', conversation_config: '{}', status: 'inactive', + inbound_numbers: '', + outbound_numbers: '', + supported_languages: ['en'], + default_language: 'en', }, }); + // Watch config selections to determine providers + const watchedTtsConfigId = form.watch('tts_config_id'); + const watchedSttConfigId = form.watch('stt_config_id'); + + // Get selected providers + const selectedTtsProvider = ttsConfigs.find((c) => c.id === watchedTtsConfigId)?.provider; + const selectedSttProvider = sttConfigs.find((c) => c.id === watchedSttConfigId)?.provider; + + // Initialize parameters when provider changes + useEffect(() => { + if (isOpen && selectedTtsProvider) { + setTtsParameters(initializeParameters('tts', selectedTtsProvider)); + } + }, [selectedTtsProvider, isOpen]); + + useEffect(() => { + if (isOpen && selectedSttProvider) { + setSttParameters(initializeParameters('stt', selectedSttProvider)); + } + }, [selectedSttProvider, isOpen]); + // Reset form when dialog closes useEffect(() => { if (!isOpen) { @@ -96,11 +142,18 @@ const CreateVoiceAgentDialog: React.FC = ({ isOpen, tts_config_id: '', stt_config_id: '', telephony_config_id: '', + tts_voice_id: '', system_prompt: '', welcome_message: '', conversation_config: '{}', status: 'inactive', + inbound_numbers: '', + outbound_numbers: '', + supported_languages: ['en'], + default_language: 'en', }); + setTtsParameters({}); + setSttParameters({}); } }, [isOpen, form]); @@ -116,6 +169,60 @@ const CreateVoiceAgentDialog: React.FC = ({ isOpen, } } + // Build TTS parameters (filter out empty values + unsupported keys) + const allowedTtsKeys = new Set( + Object.keys(ttsProviderConfig?.parameters ?? {}).filter((key) => key !== 'language') + ); + const builtTtsParameters: Record = {}; + Object.entries(ttsParameters).forEach(([key, value]) => { + if (allowedTtsKeys.has(key) && value !== '' && value !== undefined && value !== null) { + builtTtsParameters[key] = value; + } + }); + + // Build STT parameters (filter out empty values + unsupported keys) + const allowedSttKeys = new Set( + Object.keys(sttProviderConfig?.parameters ?? {}).filter((key) => key !== 'language') + ); + const builtSttParameters: Record = {}; + Object.entries(sttParameters).forEach(([key, value]) => { + if (allowedSttKeys.has(key) && value !== '' && value !== undefined && value !== null) { + builtSttParameters[key] = value; + } + }); + + // Parse phone numbers (comma-separated) + const parsePhoneNumbers = (input: string): string[] => { + if (!input.trim()) return []; + return input + .split(',') + .map((num) => num.trim()) + .filter((num) => num); + }; + + const inboundNumbers = parsePhoneNumbers(data.inbound_numbers || ''); + const outboundNumbers = parsePhoneNumbers(data.outbound_numbers || ''); + + // Validate E.164 format + const invalidInbound = inboundNumbers.filter((num) => !E164_REGEX.test(num)); + const invalidOutbound = outboundNumbers.filter((num) => !E164_REGEX.test(num)); + + if (invalidInbound.length > 0) { + notifyError(`Invalid inbound phone numbers (must be E.164 format): ${invalidInbound.join(', ')}`); + return; + } + + if (invalidOutbound.length > 0) { + notifyError(`Invalid outbound phone numbers (must be E.164 format): ${invalidOutbound.join(', ')}`); + return; + } + + // Validate default language is in supported languages + if (!data.supported_languages.includes(data.default_language)) { + notifyError('Default language must be one of the supported languages'); + return; + } + setCreating(true); try { const requestData: CreateVoiceAgentRequest = { @@ -125,10 +232,17 @@ const CreateVoiceAgentDialog: React.FC = ({ isOpen, tts_config_id: data.tts_config_id.trim(), stt_config_id: data.stt_config_id.trim(), telephony_config_id: data.telephony_config_id.trim(), + tts_voice_id: data.tts_voice_id.trim(), + tts_parameters: Object.keys(builtTtsParameters).length > 0 ? builtTtsParameters : null, + stt_parameters: Object.keys(builtSttParameters).length > 0 ? builtSttParameters : null, system_prompt: data.system_prompt.trim(), welcome_message: data.welcome_message.trim(), conversation_config: conversationConfig, status: data.status, + inbound_numbers: inboundNumbers.length > 0 ? inboundNumbers : undefined, + outbound_numbers: outboundNumbers.length > 0 ? outboundNumbers : undefined, + supported_languages: data.supported_languages, + default_language: data.default_language, }; const response = await floConsoleService.voiceAgentService.createVoiceAgent(requestData); @@ -156,6 +270,231 @@ const CreateVoiceAgentDialog: React.FC = ({ isOpen, } }; + // Helper functions for parameter management + const setTtsParameter = (key: string, value: unknown) => { + setTtsParameters((prev) => ({ ...prev, [key]: value })); + }; + + const setSttParameter = (key: string, value: unknown) => { + setSttParameters((prev) => ({ ...prev, [key]: value })); + }; + + // Render TTS parameter field + const renderTtsParameterField = (key: string) => { + if (!selectedTtsProvider) return null; + const config = getProviderConfig('tts', selectedTtsProvider); + if (!config) return null; + + const paramConfig = config.parameters[key]; + if (!paramConfig) return null; + + switch (paramConfig.type) { + case 'boolean': + return ( +
+
+ setTtsParameter(key, checked)} + /> + +
+
+ ); + + case 'number': + if (paramConfig.min !== undefined && paramConfig.max !== undefined) { + const sliderValue = getNumberParameterWithDefault(ttsParameters, key, paramConfig.default, paramConfig.min); + return ( +
+ + setTtsParameter(key, values[0])} + /> +

+ {paramConfig.min} - {paramConfig.max} +

+
+ ); + } + + return ( +
+ + setTtsParameter(key, e.target.value ? parseFloat(e.target.value) : undefined)} + placeholder={paramConfig.placeholder} + step={paramConfig.step} + /> + {paramConfig.placeholder && ( +

e.g., {paramConfig.placeholder}

+ )} +
+ ); + + case 'string': + default: + if (key === 'language') return null; + + if (paramConfig.options && paramConfig.options.length > 0) { + const selectValue = + getStringParameter(ttsParameters, key) || (paramConfig.default ? String(paramConfig.default) : '') || ''; + return ( +
+ + +
+ ); + } + + return ( +
+ + setTtsParameter(key, e.target.value)} + placeholder={paramConfig.placeholder} + /> + {paramConfig.placeholder && ( +

Default: {paramConfig.placeholder}

+ )} +
+ ); + } + }; + + // Render STT parameter field + const renderSttParameterField = (key: string) => { + if (!selectedSttProvider) return null; + const config = getProviderConfig('stt', selectedSttProvider); + if (!config) return null; + + const paramConfig = config.parameters[key]; + if (!paramConfig) return null; + + switch (paramConfig.type) { + case 'boolean': + return ( +
+
+ setSttParameter(key, checked)} + /> + +
+
+ ); + + case 'number': + if (paramConfig.min !== undefined && paramConfig.max !== undefined) { + const sliderValue = getNumberParameterWithDefault(sttParameters, key, paramConfig.default, paramConfig.min); + return ( +
+ + setSttParameter(key, values[0])} + /> +

+ {paramConfig.min} - {paramConfig.max} +

+
+ ); + } + + return ( +
+ + setSttParameter(key, e.target.value ? parseInt(e.target.value) : undefined)} + placeholder={paramConfig.placeholder} + /> + {paramConfig.placeholder && ( +

e.g., {paramConfig.placeholder}

+ )} +
+ ); + + case 'string': + default: + if (key === 'language') return null; + + if (paramConfig.options && paramConfig.options.length > 0) { + const selectValue = + getStringParameter(sttParameters, key) || (paramConfig.default ? String(paramConfig.default) : '') || ''; + return ( +
+ + +
+ ); + } + + return ( +
+ + setSttParameter(key, e.target.value)} + placeholder={paramConfig.placeholder} + /> + {paramConfig.placeholder && ( +

Default: {paramConfig.placeholder}

+ )} +
+ ); + } + }; + + const ttsProviderConfig = selectedTtsProvider ? getProviderConfig('tts', selectedTtsProvider) : null; + const sttProviderConfig = selectedSttProvider ? getProviderConfig('stt', selectedSttProvider) : null; + return ( @@ -366,6 +705,168 @@ const CreateVoiceAgentDialog: React.FC = ({ isOpen, )} />
+ +
+

TTS Voice Settings

+ ( + + + TTS Voice ID* + + + + + + Provider-specific voice identifier (e.g., for Deepgram: aura-2-helena-en) + + + + )} + /> + + {ttsProviderConfig && + Object.keys(ttsProviderConfig.parameters).filter((key) => key !== 'language').length > 0 && ( +
+

TTS Parameters

+
+ {Object.keys(ttsProviderConfig.parameters) + .filter((key) => key !== 'language') + .map((key) => renderTtsParameterField(key))} +
+
+ )} + + {sttProviderConfig && + Object.keys(sttProviderConfig.parameters).filter((key) => key !== 'language').length > 0 && ( +
+

STT Parameters

+
+ {Object.keys(sttProviderConfig.parameters) + .filter((key) => key !== 'language') + .map((key) => renderSttParameterField(key))} +
+
+ )} +
+ + + {/* Phone Numbers */} +
+

Phone Numbers

+
+ ( + + Inbound Phone Numbers + + + + + Phone numbers for receiving inbound calls (E.164 format, comma-separated, globally unique) + + + + )} + /> + + ( + + Outbound Phone Numbers + + + + + Phone numbers for making outbound calls (E.164 format, comma-separated) + + + + )} + /> +
+
+ + {/* Language Configuration */} +
+

Language Configuration

+ ( + + + Supported Languages* + +
+
+ {SUPPORTED_LANGUAGES.map((lang) => ( +
+ { + const current = field.value || []; + if (checked) { + field.onChange([...current, lang.code]); + } else { + field.onChange(current.filter((l) => l !== lang.code)); + } + }} + /> + +
+ ))} +
+
+ + Select languages this agent can converse in. If multiple languages are selected, the agent will + detect the caller's language. + + +
+ )} + /> + + ( + + + Default Language* + + + + Language used if detection fails or for single-language agents. Must be one of the supported + languages. + + + + )} + />
{/* Behavior */} diff --git a/wavefront/client/src/pages/apps/[appId]/voice-agents/EditVoiceAgentDialog.tsx b/wavefront/client/src/pages/apps/[appId]/voice-agents/EditVoiceAgentDialog.tsx index 245d7b59..5475064e 100644 --- a/wavefront/client/src/pages/apps/[appId]/voice-agents/EditVoiceAgentDialog.tsx +++ b/wavefront/client/src/pages/apps/[appId]/voice-agents/EditVoiceAgentDialog.tsx @@ -8,6 +8,7 @@ import { DialogHeader, DialogTitle, } from '@app/components/ui/dialog'; +import VoiceAgentToolsManager from './VoiceAgentToolsManager'; import { Form, FormControl, @@ -18,7 +19,10 @@ import { FormMessage, } from '@app/components/ui/form'; import { Input } from '@app/components/ui/input'; +import { Label } from '@app/components/ui/label'; +import { Slider } from '@app/components/ui/slider'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@app/components/ui/select'; +import { Checkbox } from '@app/components/ui/checkbox'; import { useGetLLMConfigs, useGetSttConfigs, @@ -28,6 +32,9 @@ import { import { extractErrorMessage } from '@app/lib/utils'; import { useDashboardStore, useNotifyStore } from '@app/store'; import { UpdateVoiceAgentRequest, VoiceAgent } from '@app/types/voice-agent'; +import { SUPPORTED_LANGUAGES, getLanguageDisplayName } from '@app/constants/languages'; +import { getProviderConfig } from '@app/config/voice-providers'; +import { getBooleanParameterWithDefault, getNumberParameterWithDefault } from '@app/utils/parameter-helpers'; import { zodResolver } from '@hookform/resolvers/zod'; import { langs } from '@uiw/codemirror-extensions-langs'; import CodeMirror from '@uiw/react-codemirror'; @@ -35,6 +42,8 @@ import React, { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +const E164_REGEX = /^\+[1-9]\d{1,14}$/; + const updateVoiceAgentSchema = z.object({ name: z.string().min(1, 'Name is required').max(100, 'Name must be 100 characters or less'), description: z.string().max(500, 'Description must be 500 characters or less').optional(), @@ -42,10 +51,15 @@ const updateVoiceAgentSchema = z.object({ tts_config_id: z.string().min(1, 'TTS configuration is required'), stt_config_id: z.string().min(1, 'STT configuration is required'), telephony_config_id: z.string().min(1, 'Telephony configuration is required'), + tts_voice_id: z.string().min(1, 'TTS Voice ID is required'), system_prompt: z.string().min(1, 'System prompt is required'), welcome_message: z.string().min(1, 'Welcome message is required'), conversation_config: z.string().optional(), status: z.enum(['active', 'inactive']), + inbound_numbers: z.string().optional(), + outbound_numbers: z.string().optional(), + supported_languages: z.array(z.string()).min(1, 'At least one language is required'), + default_language: z.string().min(1, 'Default language is required'), }); type UpdateVoiceAgentInput = z.infer; @@ -75,6 +89,10 @@ const EditVoiceAgentDialog: React.FC = ({ const { data: sttConfigs = [] } = useGetSttConfigs(appId); const { data: telephonyConfigs = [] } = useGetTelephonyConfigs(appId); + // State for TTS/STT parameters (managed separately from form) + const [ttsParameters, setTtsParameters] = useState>({}); + const [sttParameters, setSttParameters] = useState>({}); + const form = useForm({ resolver: zodResolver(updateVoiceAgentSchema), defaultValues: { @@ -84,13 +102,28 @@ const EditVoiceAgentDialog: React.FC = ({ tts_config_id: agent.tts_config_id, stt_config_id: agent.stt_config_id, telephony_config_id: agent.telephony_config_id, + tts_voice_id: agent.tts_voice_id, system_prompt: agent.system_prompt, welcome_message: agent.welcome_message, conversation_config: agent.conversation_config ? JSON.stringify(agent.conversation_config, null, 2) : '{}', status: agent.status, + inbound_numbers: agent.inbound_numbers?.join(', ') || '', + outbound_numbers: agent.outbound_numbers?.join(', ') || '', + supported_languages: agent.supported_languages || ['en'], + default_language: agent.default_language || 'en', }, }); + // Watch for config changes to determine providers + const watchedTtsConfigId = form.watch('tts_config_id'); + const watchedSttConfigId = form.watch('stt_config_id'); + + const selectedTtsProvider = ttsConfigs.find((c) => c.id === watchedTtsConfigId)?.provider; + const selectedSttProvider = sttConfigs.find((c) => c.id === watchedSttConfigId)?.provider; + + const ttsProviderConfig = selectedTtsProvider ? getProviderConfig('tts', selectedTtsProvider) : null; + const sttProviderConfig = selectedSttProvider ? getProviderConfig('stt', selectedSttProvider) : null; + // Reset form when dialog opens with agent data useEffect(() => { if (isOpen && agent) { @@ -101,14 +134,42 @@ const EditVoiceAgentDialog: React.FC = ({ tts_config_id: agent.tts_config_id, stt_config_id: agent.stt_config_id, telephony_config_id: agent.telephony_config_id, + tts_voice_id: agent.tts_voice_id, system_prompt: agent.system_prompt, welcome_message: agent.welcome_message, conversation_config: agent.conversation_config ? JSON.stringify(agent.conversation_config, null, 2) : '{}', status: agent.status, + inbound_numbers: agent.inbound_numbers?.join(', ') || '', + outbound_numbers: agent.outbound_numbers?.join(', ') || '', + supported_languages: agent.supported_languages || ['en'], + default_language: agent.default_language || 'en', }); } }, [isOpen, agent, form]); + // Initialize TTS parameters when dialog opens - use only existing values, not merged with defaults + useEffect(() => { + if (isOpen) { + setTtsParameters(agent.tts_parameters || {}); + } + }, [isOpen, agent.tts_parameters]); + + // Initialize STT parameters when dialog opens - use only existing values, not merged with defaults + useEffect(() => { + if (isOpen) { + setSttParameters(agent.stt_parameters || {}); + } + }, [isOpen, agent.stt_parameters]); + + // Helper functions to update parameters + const setTtsParameter = (key: string, value: unknown) => { + setTtsParameters((prev) => ({ ...prev, [key]: value })); + }; + + const setSttParameter = (key: string, value: unknown) => { + setSttParameters((prev) => ({ ...prev, [key]: value })); + }; + const onSubmit = async (data: UpdateVoiceAgentInput) => { // Validate JSON if provided let conversationConfig = null; @@ -121,20 +182,147 @@ const EditVoiceAgentDialog: React.FC = ({ } } + // Build TTS parameters (filter out empty values + unsupported keys) + const allowedTtsKeys = new Set( + Object.keys(ttsProviderConfig?.parameters ?? {}).filter((key) => key !== 'language') + ); + const builtTtsParameters: Record = {}; + Object.entries(ttsParameters).forEach(([key, value]) => { + if (allowedTtsKeys.has(key) && value !== '' && value !== undefined && value !== null) { + builtTtsParameters[key] = value; + } + }); + + // Build STT parameters (filter out empty values + unsupported keys) + const allowedSttKeys = new Set( + Object.keys(sttProviderConfig?.parameters ?? {}).filter((key) => key !== 'language') + ); + const builtSttParameters: Record = {}; + Object.entries(sttParameters).forEach(([key, value]) => { + if (allowedSttKeys.has(key) && value !== '' && value !== undefined && value !== null) { + builtSttParameters[key] = value; + } + }); + + // Parse phone numbers (comma-separated) + const parsePhoneNumbers = (input: string): string[] => { + if (!input.trim()) return []; + return input + .split(',') + .map((num) => num.trim()) + .filter((num) => num); + }; + + const inboundNumbers = parsePhoneNumbers(data.inbound_numbers || ''); + const outboundNumbers = parsePhoneNumbers(data.outbound_numbers || ''); + + // Validate E.164 format + const invalidInbound = inboundNumbers.filter((num) => !E164_REGEX.test(num)); + const invalidOutbound = outboundNumbers.filter((num) => !E164_REGEX.test(num)); + + if (invalidInbound.length > 0) { + notifyError(`Invalid inbound phone numbers (must be E.164 format): ${invalidInbound.join(', ')}`); + return; + } + + if (invalidOutbound.length > 0) { + notifyError(`Invalid outbound phone numbers (must be E.164 format): ${invalidOutbound.join(', ')}`); + return; + } + + // Validate default language is in supported languages + if (!data.supported_languages.includes(data.default_language)) { + notifyError('Default language must be one of the supported languages'); + return; + } + setUpdating(true); try { - const requestData: UpdateVoiceAgentRequest = { - name: data.name.trim(), - description: data.description?.trim() || null, - llm_config_id: data.llm_config_id.trim(), - tts_config_id: data.tts_config_id.trim(), - stt_config_id: data.stt_config_id.trim(), - telephony_config_id: data.telephony_config_id.trim(), - system_prompt: data.system_prompt.trim(), - welcome_message: data.welcome_message.trim(), - conversation_config: conversationConfig, - status: data.status, - }; + // Build partial update - only include changed fields + const requestData: Partial = {}; + + if (data.name.trim() !== agent.name) { + requestData.name = data.name.trim(); + } + + const newDescription = data.description?.trim() || null; + if (newDescription !== (agent.description || null)) { + requestData.description = newDescription; + } + + if (data.llm_config_id !== agent.llm_config_id) { + requestData.llm_config_id = data.llm_config_id; + } + + if (data.tts_config_id !== agent.tts_config_id) { + requestData.tts_config_id = data.tts_config_id; + } + + if (data.stt_config_id !== agent.stt_config_id) { + requestData.stt_config_id = data.stt_config_id; + } + + if (data.telephony_config_id !== agent.telephony_config_id) { + requestData.telephony_config_id = data.telephony_config_id; + } + + if (data.tts_voice_id.trim() !== agent.tts_voice_id) { + requestData.tts_voice_id = data.tts_voice_id.trim(); + } + + // Check if TTS parameters changed + const newTtsParams = Object.keys(builtTtsParameters).length > 0 ? builtTtsParameters : null; + if (JSON.stringify(newTtsParams) !== JSON.stringify(agent.tts_parameters || null)) { + requestData.tts_parameters = newTtsParams; + } + + // Check if STT parameters changed + const newSttParams = Object.keys(builtSttParameters).length > 0 ? builtSttParameters : null; + if (JSON.stringify(newSttParams) !== JSON.stringify(agent.stt_parameters || null)) { + requestData.stt_parameters = newSttParams; + } + + if (data.system_prompt.trim() !== agent.system_prompt) { + requestData.system_prompt = data.system_prompt.trim(); + } + + if (data.welcome_message.trim() !== agent.welcome_message) { + requestData.welcome_message = data.welcome_message.trim(); + } + + // Check if conversation config changed + if (JSON.stringify(conversationConfig) !== JSON.stringify(agent.conversation_config || null)) { + requestData.conversation_config = conversationConfig; + } + + if (data.status !== agent.status) { + requestData.status = data.status; + } + + // Check if phone numbers changed + if (JSON.stringify(inboundNumbers) !== JSON.stringify(agent.inbound_numbers || [])) { + requestData.inbound_numbers = inboundNumbers; + } + + if (JSON.stringify(outboundNumbers) !== JSON.stringify(agent.outbound_numbers || [])) { + requestData.outbound_numbers = outboundNumbers; + } + + // Check if languages changed + if (JSON.stringify(data.supported_languages) !== JSON.stringify(agent.supported_languages || ['en'])) { + requestData.supported_languages = data.supported_languages; + } + + if (data.default_language !== agent.default_language) { + requestData.default_language = data.default_language; + } + + // Only send request if there are changes + if (Object.keys(requestData).length === 0) { + notifySuccess('No changes to update'); + onOpenChange(false); + return; + } await floConsoleService.voiceAgentService.updateVoiceAgent(agent.id, requestData); @@ -154,6 +342,206 @@ const EditVoiceAgentDialog: React.FC = ({ } }; + // Render TTS parameter field based on type + const renderTtsParameterField = (key: string) => { + if (!selectedTtsProvider) return null; + const config = getProviderConfig('tts', selectedTtsProvider); + if (!config) return null; + + const paramConfig = config.parameters[key]; + if (!paramConfig) return null; + + switch (paramConfig.type) { + case 'boolean': + return ( +
+
+ setTtsParameter(key, checked)} + /> + +
+
+ ); + + case 'number': + if (paramConfig.min !== undefined && paramConfig.max !== undefined) { + const sliderValue = getNumberParameterWithDefault(ttsParameters, key, paramConfig.default, paramConfig.min); + return ( +
+ + setTtsParameter(key, values[0])} + /> +

+ {paramConfig.min} - {paramConfig.max} +

+
+ ); + } + // Number without range - use input + return ( +
+ + setTtsParameter(key, parseFloat(e.target.value) || 0)} + /> +
+ ); + + case 'string': + if (paramConfig.options && paramConfig.options.length > 0) { + // Dropdown for predefined options + const currentValue = String(ttsParameters[key] ?? paramConfig.default ?? ''); + return ( +
+ + +
+ ); + } + // Text input for free-form strings + return ( +
+ + setTtsParameter(key, e.target.value)} + /> +
+ ); + + default: + return null; + } + }; + + // Render STT parameter field based on type + const renderSttParameterField = (key: string) => { + if (!selectedSttProvider) return null; + const config = getProviderConfig('stt', selectedSttProvider); + if (!config) return null; + + const paramConfig = config.parameters[key]; + if (!paramConfig) return null; + + switch (paramConfig.type) { + case 'boolean': + return ( +
+
+ setSttParameter(key, checked)} + /> + +
+
+ ); + + case 'number': + if (paramConfig.min !== undefined && paramConfig.max !== undefined) { + const sliderValue = getNumberParameterWithDefault(sttParameters, key, paramConfig.default, paramConfig.min); + return ( +
+ + setSttParameter(key, values[0])} + /> +

+ {paramConfig.min} - {paramConfig.max} +

+
+ ); + } + // Number without range - use input + return ( +
+ + setSttParameter(key, parseFloat(e.target.value) || 0)} + /> +
+ ); + + case 'string': + if (paramConfig.options && paramConfig.options.length > 0) { + // Dropdown for predefined options + const currentValue = String(sttParameters[key] ?? paramConfig.default ?? ''); + return ( +
+ + +
+ ); + } + // Text input for free-form strings + return ( +
+ + setSttParameter(key, e.target.value)} + /> +
+ ); + + default: + return null; + } + }; + return ( @@ -364,6 +752,168 @@ const EditVoiceAgentDialog: React.FC = ({ )} /> + +
+

TTS Voice Settings

+ ( + + + TTS Voice ID* + + + + + + Provider-specific voice identifier (e.g., for Deepgram: aura-2-helena-en) + + + + )} + /> + + {ttsProviderConfig && + Object.keys(ttsProviderConfig.parameters).filter((key) => key !== 'language').length > 0 && ( +
+

TTS Parameters

+
+ {Object.keys(ttsProviderConfig.parameters) + .filter((key) => key !== 'language') + .map((key) => renderTtsParameterField(key))} +
+
+ )} + + {sttProviderConfig && + Object.keys(sttProviderConfig.parameters).filter((key) => key !== 'language').length > 0 && ( +
+

STT Parameters

+
+ {Object.keys(sttProviderConfig.parameters) + .filter((key) => key !== 'language') + .map((key) => renderSttParameterField(key))} +
+
+ )} +
+ + + {/* Phone Numbers */} +
+

Phone Numbers

+
+ ( + + Inbound Phone Numbers + + + + + Phone numbers for receiving inbound calls (E.164 format, comma-separated, globally unique) + + + + )} + /> + + ( + + Outbound Phone Numbers + + + + + Phone numbers for making outbound calls (E.164 format, comma-separated) + + + + )} + /> +
+
+ + {/* Language Configuration */} +
+

Language Configuration

+ ( + + + Supported Languages* + +
+
+ {SUPPORTED_LANGUAGES.map((lang) => ( +
+ { + const current = field.value || []; + if (checked) { + field.onChange([...current, lang.code]); + } else { + field.onChange(current.filter((l) => l !== lang.code)); + } + }} + /> + +
+ ))} +
+
+ + Select languages this agent can converse in. If multiple languages are selected, the agent will + detect the caller's language. + + +
+ )} + /> + + ( + + + Default Language* + + + + Language used if detection fails or for single-language agents. Must be one of the supported + languages. + + + + )} + />
{/* Behavior */} @@ -446,6 +996,12 @@ const EditVoiceAgentDialog: React.FC = ({ )} /> + + {/* Tools Section */} +
+

Tools & Function Calling

+ +
diff --git a/wavefront/client/src/pages/apps/[appId]/voice-agents/OutboundCallDialog.tsx b/wavefront/client/src/pages/apps/[appId]/voice-agents/OutboundCallDialog.tsx index 1cf00cc2..dee80587 100644 --- a/wavefront/client/src/pages/apps/[appId]/voice-agents/OutboundCallDialog.tsx +++ b/wavefront/client/src/pages/apps/[appId]/voice-agents/OutboundCallDialog.tsx @@ -11,7 +11,6 @@ import { import { Input } from '@app/components/ui/input'; import { Label } from '@app/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@app/components/ui/select'; -import { useGetTelephonyConfig } from '@app/hooks/data/fetch-hooks'; import { extractErrorMessage } from '@app/lib/utils'; import { useNotifyStore } from '@app/store'; import { VoiceAgent } from '@app/types/voice-agent'; @@ -25,19 +24,17 @@ interface CallInfo { interface OutboundCallDialogProps { isOpen: boolean; onOpenChange: (open: boolean) => void; - appId: string; agent: VoiceAgent; } -const OutboundCallDialog: React.FC = ({ isOpen, onOpenChange, appId, agent }) => { +const OutboundCallDialog: React.FC = ({ isOpen, onOpenChange, agent }) => { const { notifySuccess, notifyError } = useNotifyStore(); const [toNumber, setToNumber] = useState(''); const [fromNumber, setFromNumber] = useState(''); const [callLoading, setCallLoading] = useState(false); - // Fetch the specific telephony config to get phone numbers for call initiation - const { data: currentTelephonyConfig } = useGetTelephonyConfig(appId, agent.telephony_config_id); - const availablePhoneNumbers = currentTelephonyConfig?.phone_numbers || []; + // Get outbound phone numbers from the voice agent (not telephony config) + const availablePhoneNumbers = agent.outbound_numbers || []; // E.164 phone number validation const isValidE164PhoneNumber = (phoneNumber: string): boolean => { diff --git a/wavefront/client/src/pages/apps/[appId]/voice-agents/VoiceAgentToolsManager.tsx b/wavefront/client/src/pages/apps/[appId]/voice-agents/VoiceAgentToolsManager.tsx new file mode 100644 index 00000000..59b7a9e0 --- /dev/null +++ b/wavefront/client/src/pages/apps/[appId]/voice-agents/VoiceAgentToolsManager.tsx @@ -0,0 +1,186 @@ +import floConsoleService from '@app/api'; +import { Button } from '@app/components/ui/button'; +import { Switch } from '@app/components/ui/switch'; +import { Badge } from '@app/components/ui/badge'; +import { useGetVoiceAgentTools, useGetAgentTools } from '@app/hooks'; +import { getVoiceAgentToolsKey, getAgentToolsKey } from '@app/hooks/data/query-keys'; +import { extractErrorMessage } from '@app/lib/utils'; +import { useNotifyStore } from '@app/store'; +import { useQueryClient } from '@tanstack/react-query'; +import { Plus, X } from 'lucide-react'; +import React, { useState } from 'react'; + +interface VoiceAgentToolsManagerProps { + appId: string; + agentId: string; +} + +const VoiceAgentToolsManager: React.FC = ({ appId, agentId }) => { + const queryClient = useQueryClient(); + const { notifySuccess, notifyError } = useNotifyStore(); + const [loadingToolId, setLoadingToolId] = useState(null); + + // Fetch all tools + const { data: allTools = [], isLoading: allToolsLoading } = useGetVoiceAgentTools(appId); + + // Fetch tools attached to this agent + const { data: agentTools = [], isLoading: agentToolsLoading } = useGetAgentTools(appId, agentId); + + const attachedToolIds = new Set(agentTools.map((t) => t.id)); + const availableTools = allTools.filter((t) => !attachedToolIds.has(t.id)); + + const handleToggleEnabled = async (toolId: string, currentEnabled: boolean) => { + setLoadingToolId(toolId); + try { + await floConsoleService.toolService.updateAgentTool(agentId, toolId, { + is_enabled: !currentEnabled, + }); + notifySuccess(`Tool ${!currentEnabled ? 'enabled' : 'disabled'} successfully`); + queryClient.invalidateQueries({ queryKey: getAgentToolsKey(appId, agentId) }); + } catch (error) { + const errorMessage = extractErrorMessage(error); + notifyError(errorMessage || 'Failed to toggle tool'); + } finally { + setLoadingToolId(null); + } + }; + + const handleAttachTool = async (toolId: string) => { + setLoadingToolId(toolId); + try { + await floConsoleService.toolService.attachToolToAgent(agentId, { + tool_id: toolId, + is_enabled: true, + priority: 0, + }); + notifySuccess('Tool attached successfully'); + queryClient.invalidateQueries({ queryKey: getAgentToolsKey(appId, agentId) }); + queryClient.invalidateQueries({ queryKey: getVoiceAgentToolsKey(appId) }); + } catch (error) { + const errorMessage = extractErrorMessage(error); + notifyError(errorMessage || 'Failed to attach tool'); + } finally { + setLoadingToolId(null); + } + }; + + const handleDetachTool = async (toolId: string) => { + setLoadingToolId(toolId); + try { + await floConsoleService.toolService.detachToolFromAgent(agentId, toolId); + notifySuccess('Tool detached successfully'); + queryClient.invalidateQueries({ queryKey: getAgentToolsKey(appId, agentId) }); + queryClient.invalidateQueries({ queryKey: getVoiceAgentToolsKey(appId) }); + } catch (error) { + const errorMessage = extractErrorMessage(error); + notifyError(errorMessage || 'Failed to detach tool'); + } finally { + setLoadingToolId(null); + } + }; + + const getToolTypeColor = (type: string) => { + return type === 'api' ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'; + }; + + if (allToolsLoading || agentToolsLoading) { + return ( +
+
Loading tools...
+
+ ); + } + + return ( +
+ {/* Attached Tools */} +
+

Attached Tools ({agentTools.length})

+ {agentTools.length === 0 ? ( +
+ No tools attached to this agent yet. Add tools from the available tools below. +
+ ) : ( +
+ {agentTools.map((tool) => ( +
+
+ {tool.tool_type.toUpperCase()} +
+
{tool.display_name}
+
Function: {tool.name}
+
+
+
+
+ + {tool.association.is_enabled ? 'Enabled' : 'Disabled'} + + handleToggleEnabled(tool.id, tool.association.is_enabled)} + disabled={loadingToolId === tool.id} + /> +
+ +
+
+ ))} +
+ )} +
+ + {/* Available Tools */} +
+

Available Tools ({availableTools.length})

+ {availableTools.length === 0 ? ( +
+ {allTools.length === 0 + ? 'No tools available. Create tools first from the Tools page.' + : 'All available tools are already attached to this agent.'} +
+ ) : ( +
+ {availableTools.map((tool) => ( +
+
+ {tool.tool_type.toUpperCase()} +
+
{tool.display_name}
+
Function: {tool.name}
+
{tool.description}
+
+
+ +
+ ))} +
+ )} +
+
+ ); +}; + +export default VoiceAgentToolsManager; diff --git a/wavefront/client/src/pages/apps/[appId]/voice-agents/index.tsx b/wavefront/client/src/pages/apps/[appId]/voice-agents/index.tsx index a37858ed..64b44495 100644 --- a/wavefront/client/src/pages/apps/[appId]/voice-agents/index.tsx +++ b/wavefront/client/src/pages/apps/[appId]/voice-agents/index.tsx @@ -9,6 +9,7 @@ import { getVoiceAgentsKey } from '@app/hooks/data/query-keys'; import { extractErrorMessage } from '@app/lib/utils'; import { useNotifyStore } from '@app/store'; import { VoiceAgent } from '@app/types/voice-agent'; +import { getLanguageName } from '@app/constants/languages'; import { useQueryClient } from '@tanstack/react-query'; import { Pencil, Phone, Trash2 } from 'lucide-react'; import React, { useState } from 'react'; @@ -133,6 +134,9 @@ const VoiceAgentsPage: React.FC = () => { Name Description + Inbound #s + Outbound #s + Languages Status Created Actions @@ -143,6 +147,20 @@ const VoiceAgentsPage: React.FC = () => { {agent.name} {agent.description || '-'} + + {agent.inbound_numbers?.length || 0} + + + {agent.outbound_numbers?.length || 0} + + + + {agent.supported_languages?.length || 1} ({agent.default_language || 'en'}) + + { !open && setCallItem(null)} - appId={app} agent={callItem} /> )} diff --git a/wavefront/client/src/pages/apps/[appId]/voice-agents/layout.tsx b/wavefront/client/src/pages/apps/[appId]/voice-agents/layout.tsx index 74d1e3d8..971f4d1b 100644 --- a/wavefront/client/src/pages/apps/[appId]/voice-agents/layout.tsx +++ b/wavefront/client/src/pages/apps/[appId]/voice-agents/layout.tsx @@ -28,6 +28,9 @@ const VoiceAgentsLayout: React.FC = () => { if (location.pathname.startsWith(`${basePath}/telephony-configs`)) { return 'telephony-configs'; } + if (location.pathname.startsWith(`${basePath}/tools`)) { + return 'tools'; + } return 'agents'; }; @@ -88,6 +91,7 @@ const VoiceAgentsLayout: React.FC = () => { Agents + Tools STT Configs TTS Configs Telephony Configs diff --git a/wavefront/client/src/pages/apps/[appId]/voice-agents/stt-configs/CreateSttConfigDialog.tsx b/wavefront/client/src/pages/apps/[appId]/voice-agents/stt-configs/CreateSttConfigDialog.tsx index 10f67c80..5d233c91 100644 --- a/wavefront/client/src/pages/apps/[appId]/voice-agents/stt-configs/CreateSttConfigDialog.tsx +++ b/wavefront/client/src/pages/apps/[appId]/voice-agents/stt-configs/CreateSttConfigDialog.tsx @@ -1,7 +1,6 @@ import floConsoleService from '@app/api'; import { Alert, AlertDescription } from '@app/components/ui/alert'; import { Button } from '@app/components/ui/button'; -import { Checkbox } from '@app/components/ui/checkbox'; import { Dialog, DialogContent, @@ -20,19 +19,11 @@ import { FormMessage, } from '@app/components/ui/form'; import { Input } from '@app/components/ui/input'; -import { Label } from '@app/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@app/components/ui/select'; -import { Slider } from '@app/components/ui/slider'; import { Textarea } from '@app/components/ui/textarea'; -import { VOICE_PROVIDERS_CONFIG, getProviderConfig, initializeParameters } from '@app/config/voice-providers'; +import { VOICE_PROVIDERS_CONFIG, getProviderConfig } from '@app/config/voice-providers'; import { extractErrorMessage } from '@app/lib/utils'; import { useNotifyStore } from '@app/store'; -import { - getBooleanParameterWithDefault, - getNumberOrStringParameter, - getNumberParameterWithDefault, - getStringParameter, -} from '@app/utils/parameter-helpers'; import { zodResolver } from '@hookform/resolvers/zod'; import React, { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; @@ -43,7 +34,6 @@ const createSttConfigSchema = z.object({ description: z.string().max(500, 'Description must be 500 characters or less').optional(), provider: z.enum(['deepgram'] as [string, ...string[]]), api_key: z.string().min(1, 'API key is required'), - language: z.string().optional(), }); type CreateSttConfigInput = z.infer; @@ -56,8 +46,6 @@ interface CreateSttConfigDialogProps { const CreateSttConfigDialog: React.FC = ({ isOpen, onOpenChange, onSuccess }) => { const { notifySuccess, notifyError } = useNotifyStore(); - - const [parameters, setParameters] = useState>({}); const [loading, setLoading] = useState(false); const form = useForm({ @@ -67,19 +55,9 @@ const CreateSttConfigDialog: React.FC = ({ isOpen, o description: '', provider: 'deepgram', api_key: '', - language: '', }, }); - const watchedProvider = form.watch('provider'); - - // Reset parameters when provider changes - useEffect(() => { - if (isOpen && watchedProvider) { - setParameters(initializeParameters('stt', watchedProvider)); - } - }, [watchedProvider, isOpen]); - // Reset form when dialog closes useEffect(() => { if (!isOpen) { @@ -88,34 +66,10 @@ const CreateSttConfigDialog: React.FC = ({ isOpen, o description: '', provider: 'deepgram', api_key: '', - language: '', }); - setParameters({}); } }, [isOpen, form]); - const setParameter = (key: string, value: unknown) => { - setParameters((prev) => ({ ...prev, [key]: value })); - }; - - const buildParameters = () => { - const config = getProviderConfig('stt', watchedProvider); - if (!config) return null; - - const params: Record = {}; - - Object.entries(parameters).forEach(([key, value]) => { - const paramConfig = config.parameters[key]; - if (!paramConfig) return; - - if (value !== '' && value !== undefined) { - params[key] = value; - } - }); - - return Object.keys(params).length > 0 ? params : null; - }; - const onSubmit = async (data: CreateSttConfigInput) => { setLoading(true); try { @@ -124,8 +78,6 @@ const CreateSttConfigDialog: React.FC = ({ isOpen, o description: data.description?.trim() || null, provider: data.provider as 'deepgram', api_key: data.api_key.trim(), - language: data.language?.trim() || null, - parameters: buildParameters(), }); notifySuccess('STT configuration created successfully'); onSuccess?.(); @@ -138,142 +90,6 @@ const CreateSttConfigDialog: React.FC = ({ isOpen, o } }; - const renderParameterField = (key: string) => { - const config = getProviderConfig('stt', watchedProvider); - if (!config) return null; - - const paramConfig = config.parameters[key]; - if (!paramConfig) return null; - - switch (paramConfig.type) { - case 'boolean': - return ( -
-
- setParameter(key, checked)} - /> - -
-
- ); - - case 'number': - if (paramConfig.options && paramConfig.options.length > 0) { - const numValue = getNumberParameterWithDefault(parameters, key, paramConfig.default); - return ( -
- - -
- ); - } - - if (paramConfig.min !== undefined && paramConfig.max !== undefined) { - const sliderValue = getNumberParameterWithDefault(parameters, key, paramConfig.default, paramConfig.min); - return ( -
- - setParameter(key, values[0])} - /> - {paramConfig.description && ( -

{paramConfig.description}

- )} -
- ); - } - - return ( -
- - setParameter(key, e.target.value ? parseInt(e.target.value) : undefined)} - placeholder={paramConfig.placeholder} - /> - {paramConfig.placeholder && ( -

e.g., {paramConfig.placeholder}

- )} -
- ); - - case 'string': - default: - if (key === 'language') return null; - - if (paramConfig.options && paramConfig.options.length > 0) { - const selectValue = - getStringParameter(parameters, key) || (paramConfig.default ? String(paramConfig.default) : '') || ''; - return ( -
- - -
- ); - } - - return ( -
- - setParameter(key, e.target.value)} - placeholder={paramConfig.placeholder} - /> - {paramConfig.placeholder && ( -

Default: {paramConfig.placeholder}

- )} -
- ); - } - }; - - const providerConfig = getProviderConfig('stt', watchedProvider); - return ( @@ -294,7 +110,7 @@ const CreateSttConfigDialog: React.FC = ({ isOpen, o Display Name * - + {field.value?.length || 0}/100 characters @@ -337,7 +153,7 @@ const CreateSttConfigDialog: React.FC = ({ isOpen, o control={form.control} name="description" render={({ field }) => ( - + Description (Optional)