diff --git a/Unreal/PS_AI_Agent/Content/MetaHumans/Taro/BP_Taro.uasset b/Unreal/PS_AI_Agent/Content/MetaHumans/Taro/BP_Taro.uasset index c5c1354..c041c22 100644 Binary files a/Unreal/PS_AI_Agent/Content/MetaHumans/Taro/BP_Taro.uasset and b/Unreal/PS_AI_Agent/Content/MetaHumans/Taro/BP_Taro.uasset differ diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/Martin.uasset b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/Martin.uasset new file mode 100644 index 0000000..baa8a0c Binary files /dev/null and b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/Martin.uasset differ diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/NewPS_AI_ConvAgent_AgentConfig_ElevenLabs.uasset b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/NewPS_AI_ConvAgent_AgentConfig_ElevenLabs.uasset new file mode 100644 index 0000000..40470e3 Binary files /dev/null and b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/NewPS_AI_ConvAgent_AgentConfig_ElevenLabs.uasset differ diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/NewPS_AI_ConvAgent_AgentConfig_ElevenLabs1.uasset b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/NewPS_AI_ConvAgent_AgentConfig_ElevenLabs1.uasset new file mode 100644 index 0000000..b7a6737 Binary files /dev/null and b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/NewPS_AI_ConvAgent_AgentConfig_ElevenLabs1.uasset differ diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/NewPS_AI_ConvAgent_AgentConfig_ElevenLabs2.uasset b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/NewPS_AI_ConvAgent_AgentConfig_ElevenLabs2.uasset new file mode 100644 index 0000000..c41b0a1 Binary files /dev/null and b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/NewPS_AI_ConvAgent_AgentConfig_ElevenLabs2.uasset differ diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Demo_Metahuman.umap b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Demo_Metahuman.umap index 0af21b1..3aea8e4 100644 Binary files a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Demo_Metahuman.umap and b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Demo_Metahuman.umap differ diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/FirstPerson/Blueprints/BP_FirstPersonCharacter.uasset b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/FirstPerson/Blueprints/BP_FirstPersonCharacter.uasset index 353d9d1..1fa508b 100644 Binary files a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/FirstPerson/Blueprints/BP_FirstPersonCharacter.uasset and b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/FirstPerson/Blueprints/BP_FirstPersonCharacter.uasset differ diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_AgentConfig_ElevenLabs.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_AgentConfig_ElevenLabs.cpp new file mode 100644 index 0000000..7e58982 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_AgentConfig_ElevenLabs.cpp @@ -0,0 +1,3 @@ +// Copyright ASTERION. All Rights Reserved. + +#include "PS_AI_ConvAgent_AgentConfig_ElevenLabs.h" diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_ElevenLabsComponent.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_ElevenLabsComponent.cpp index 55ba959..1712c1f 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_ElevenLabsComponent.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_ElevenLabsComponent.cpp @@ -1,6 +1,7 @@ // Copyright ASTERION. All Rights Reserved. #include "PS_AI_ConvAgent_ElevenLabsComponent.h" +#include "PS_AI_ConvAgent_AgentConfig_ElevenLabs.h" #include "PS_AI_ConvAgent_MicrophoneCaptureComponent.h" #include "PS_AI_ConvAgent_PostureComponent.h" #include "PS_AI_ConvAgent_InteractionSubsystem.h" @@ -266,7 +267,13 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::StartConversation_Internal() // Pass configuration to the proxy before connecting. WebSocketProxy->TurnMode = TurnMode; - WebSocketProxy->Connect(AgentID); + // Resolve AgentID by priority: AgentConfig > component string > project default. + FString ResolvedAgentID = AgentID; + if (AgentConfig && !AgentConfig->AgentID.IsEmpty()) + { + ResolvedAgentID = AgentConfig->AgentID; + } + WebSocketProxy->Connect(ResolvedAgentID); } void UPS_AI_ConvAgent_ElevenLabsComponent::EndConversation() diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_InteractionComponent.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_InteractionComponent.cpp index 879d670..d7f4e22 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_InteractionComponent.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_InteractionComponent.cpp @@ -252,15 +252,17 @@ void UPS_AI_ConvAgent_InteractionComponent::SetSelectedAgent(UPS_AI_ConvAgent_El } // Network: auto-start conversation if the agent isn't connected yet. - if (!NewAgent->IsConnected() && !NewAgent->bNetIsConversing) + // Only when bAutoStartConversation is true — otherwise the user must + // call StartConversationWithSelectedAgent() explicitly (e.g. on key press). + if (bAutoStartConversation && !NewAgent->IsConnected() && !NewAgent->bNetIsConversing) { NewAgent->StartConversation(); - } - // Ensure mic is capturing so we can route audio to the new agent. - if (MicComponent && !MicComponent->IsCapturing()) - { - MicComponent->StartCapture(); + // Ensure mic is capturing so we can route audio to the new agent. + if (MicComponent && !MicComponent->IsCapturing()) + { + MicComponent->StartCapture(); + } } // ── Posture: attach (eyes+head only — body tracking is enabled later @@ -351,6 +353,48 @@ void UPS_AI_ConvAgent_InteractionComponent::ClearSelection() SetSelectedAgent(nullptr); } +void UPS_AI_ConvAgent_InteractionComponent::StartConversationWithSelectedAgent() +{ + UPS_AI_ConvAgent_ElevenLabsComponent* Agent = SelectedAgent.Get(); + if (!Agent) + { + if (bDebug) + { + UE_LOG(LogPS_AI_ConvAgent_Select, Warning, TEXT("StartConversationWithSelectedAgent: no agent selected.")); + } + return; + } + + if (Agent->IsConnected() || Agent->bNetIsConversing) + { + if (bDebug) + { + UE_LOG(LogPS_AI_ConvAgent_Select, Log, TEXT("StartConversationWithSelectedAgent: agent already connected/conversing.")); + } + return; + } + + if (bDebug) + { + UE_LOG(LogPS_AI_ConvAgent_Select, Log, TEXT("StartConversationWithSelectedAgent: starting conversation with %s"), + Agent->GetOwner() ? *Agent->GetOwner()->GetName() : TEXT("(null)")); + } + + Agent->StartConversation(); + + // Ensure mic is capturing so we can route audio to the agent. + if (MicComponent && !MicComponent->IsCapturing()) + { + MicComponent->StartCapture(); + } + + // Start listening if auto-managed. + if (bAutoManageListening) + { + Agent->StartListening(); + } +} + // ───────────────────────────────────────────────────────────────────────────── // Posture helpers // ───────────────────────────────────────────────────────────────────────────── diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_WebSocket_ElevenLabsProxy.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_WebSocket_ElevenLabsProxy.cpp index fd8d107..73ca198 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_WebSocket_ElevenLabsProxy.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_WebSocket_ElevenLabsProxy.cpp @@ -724,9 +724,9 @@ FString UPS_AI_ConvAgent_WebSocket_ElevenLabsProxy::BuildWebSocketURL(const FStr return Settings->CustomWebSocketURL; } - const FString ResolvedAgentID = AgentIDOverride.IsEmpty() ? Settings->AgentID : AgentIDOverride; - if (ResolvedAgentID.IsEmpty()) + if (AgentIDOverride.IsEmpty()) { + UE_LOG(LogTemp, Error, TEXT("[PS_AI_ConvAgent] No AgentID provided. Set one via AgentConfig data asset or the AgentID property on the component.")); return FString(); } @@ -734,5 +734,5 @@ FString UPS_AI_ConvAgent_WebSocket_ElevenLabsProxy::BuildWebSocketURL(const FStr // wss://api.elevenlabs.io/v1/convai/conversation?agent_id= return FString::Printf( TEXT("wss://api.elevenlabs.io/v1/convai/conversation?agent_id=%s"), - *ResolvedAgentID); + *AgentIDOverride); } diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent.h index 922afd9..2dfaee7 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent.h @@ -23,13 +23,6 @@ public: UPROPERTY(Config, EditAnywhere, Category = "PS AI ConvAgent|ElevenLabs API") FString API_Key; - /** - * The default ElevenLabs Agent ID to use when none is specified - * on the component. Create agents at https://elevenlabs.io/app/conversational-ai - */ - UPROPERTY(Config, EditAnywhere, Category = "PS AI ConvAgent|ElevenLabs API") - FString AgentID; - /** * Override the ElevenLabs WebSocket base URL. Leave empty to use the default: * wss://api.elevenlabs.io/v1/convai/conversation diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_AgentConfig_ElevenLabs.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_AgentConfig_ElevenLabs.h new file mode 100644 index 0000000..8c12ca1 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_AgentConfig_ElevenLabs.h @@ -0,0 +1,234 @@ +// Copyright ASTERION. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "PS_AI_ConvAgent_AgentConfig_ElevenLabs.generated.h" + +/** + * Reusable data asset that encapsulates a full ElevenLabs agent configuration: + * voice, LLM prompt, language, emotion tool, and API identity. + * + * Create ONE instance per agent in the Content Browser + * (right-click > Miscellaneous > PS AI ConvAgent Agent Config), + * then assign it on the PS AI ConvAgent ElevenLabs component. + * + * The editor Detail Customization provides: + * - Voice picker (fetches available voices from the ElevenLabs API) + * - Model picker (fetches TTS models from the ElevenLabs API) + * - LLM picker (dropdown with supported LLMs) + * - Language picker (dropdown with supported languages) + * - Create / Update / Fetch Agent buttons (REST API) + * - Pre-configured emotion tool prompt fragment + * + * At runtime, the ElevenLabsComponent reads AgentID from this asset + * to establish the WebSocket conversation. + */ +UCLASS(BlueprintType, Blueprintable, + DisplayName = "PS AI ConvAgent Agent Config (ElevenLabs)") +class PS_AI_CONVAGENT_API UPS_AI_ConvAgent_AgentConfig_ElevenLabs : public UPrimaryDataAsset +{ + GENERATED_BODY() + +public: + // ── Identity ───────────────────────────────────────────────────────────── + + /** Agent ID assigned by ElevenLabs after Create/Sync. + * Populated automatically by the "Create Agent" editor action. + * This is the ID used to connect the WebSocket conversation. + * You can also paste an existing ID here, then use "Fetch Agent" to pull its config. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Identity", + meta = (ToolTip = "ElevenLabs Agent ID.\nPopulated when created/synced via API.\nPaste an existing ID + Fetch Agent to import.")) + FString AgentID; + + /** Human-readable name for this agent. + * Used as the agent name when creating on ElevenLabs. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Identity", + meta = (ToolTip = "Agent display name (visible on ElevenLabs dashboard).")) + FString AgentName; + + // ── Voice ──────────────────────────────────────────────────────────────── + + /** Voice ID from ElevenLabs. + * Managed by the Voice picker dropdown — do not edit manually. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voice", + meta = (ToolTip = "ElevenLabs Voice ID.\nManaged by the Voice picker dropdown.")) + FString VoiceID; + + /** Display name of the selected voice (informational, not sent to API). */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Voice", + meta = (ToolTip = "Name of the selected voice (display only).")) + FString VoiceName; + + /** TTS model ID (e.g. "eleven_turbo_v2_5", "eleven_multilingual_v2"). + * Managed by the Model picker dropdown. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voice", + meta = (ToolTip = "TTS model ID.\nManaged by the Model picker dropdown.")) + FString TTSModelID = TEXT("eleven_turbo_v2_5"); + + /** TTS stability (0.0 - 1.0). Controls voice consistency. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voice", + meta = (ClampMin = "0.0", ClampMax = "1.0", + ToolTip = "Voice stability.\n0 = variable/expressive, 1 = consistent.\nDefault: 0.5")) + float Stability = 0.5f; + + /** TTS similarity boost (0.0 - 1.0). Higher = closer to original voice. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voice", + meta = (ClampMin = "0.0", ClampMax = "1.0", + ToolTip = "Similarity boost.\n0 = less similar, 1 = more similar.\nDefault: 0.75")) + float SimilarityBoost = 0.75f; + + /** TTS speed multiplier. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voice", + meta = (ClampMin = "0.7", ClampMax = "1.95", + ToolTip = "Speech speed multiplier.\nRange: 0.7-1.95.\nDefault: 1.0")) + float Speed = 1.0f; + + /** LLM model used by the agent (e.g. "gpt-4o-mini", "claude-3-5-sonnet"). + * Managed by the LLM picker dropdown. + * Leave empty for ElevenLabs default. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voice", + meta = (ToolTip = "LLM model.\nManaged by the LLM picker dropdown.")) + FString LLMModel = TEXT("gemini-2.5-flash"); + + /** Agent language code (e.g. "en", "fr", "ja"). + * Managed by the Language picker dropdown. + * Controls STT and TTS language selection on ElevenLabs. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voice", + meta = (ToolTip = "Language code.\nManaged by the Language picker dropdown.")) + FString Language = TEXT("en"); + + /** Enable multilingual mode: the agent dynamically adapts to whatever + * language the user speaks in, switching seamlessly mid-conversation. + * Requires a multilingual TTS model (e.g. eleven_multilingual_v2 or eleven_turbo_v2_5). + * When enabled, the fixed language instruction (bAutoLanguageInstruction) is replaced + * by a multilingual prompt that tells the LLM to mirror the user's language. + * The Language field still serves as the default/fallback for STT. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voice", + meta = (ToolTip = "Allow the agent to switch languages dynamically.\nThe agent responds in whatever language the user speaks.\nRequires a multilingual TTS model (turbo_v2_5, multilingual_v2, flash_v2_5).")) + bool bMultilingual = false; + + /** Prompt fragment appended when bMultilingual is true. + * Instructs the LLM to mirror the user's language. Editable for customization. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voice", + meta = (MultiLine = "true", EditCondition = "bMultilingual", + ToolTip = "Prompt instructions for multilingual behavior.\nAppended when bMultilingual is true.")) + FString MultilingualPromptFragment = TEXT( + "## Language\n" + "You are multilingual. ALWAYS respond in the same language the user is speaking. " + "If the user switches language mid-conversation, switch with them immediately. " + "Match the user's language exactly — do not default to English. " + "If the user has not spoken yet, use the language of your first message."); + + /** Append a language instruction to the system prompt when not English. + * Ensures the LLM generates text in the correct language. + * The Language field controls STT/TTS, but NOT the LLM output language. + * Without this, the LLM may default to English for follow-up messages. + * Ignored when bMultilingual is true (multilingual prompt takes priority). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voice", + meta = (EditCondition = "!bMultilingual", + ToolTip = "Append a language instruction for non-English agents.\nThe Language field only controls STT/TTS, not the LLM output.\nIgnored when Multilingual is enabled.")) + bool bAutoLanguageInstruction = true; + + /** Prompt fragment appended when bAutoLanguageInstruction is true and language is not English. + * Use {Language} as a placeholder — it will be replaced by the language name (e.g. "French"). + * Pre-filled with a standard instruction. Editable for customization. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voice", + meta = (MultiLine = "true", + EditCondition = "bAutoLanguageInstruction && !bMultilingual", + ToolTip = "Prompt instruction for fixed-language mode.\n{Language} is replaced by the selected language name.\nAppended when language is not English.")) + FString LanguagePromptFragment = TEXT( + "## Language\n" + "You MUST always respond in {Language}. " + "Never switch to any other language, " + "even for follow-up messages or when the user is silent."); + + // ── Behavior ───────────────────────────────────────────────────────────── + + /** Character-specific prompt describing THIS agent's personality and context. + * This is YOUR prompt — write what makes this character unique. + * The emotion tool instructions are appended automatically if enabled. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Behavior", + meta = (MultiLine = "true", + ToolTip = "Character-specific prompt.\nDescribe the character's personality, backstory, and behavior.\nEmotion tool instructions are appended automatically if enabled.")) + FString CharacterPrompt; + + /** First message the agent says when the conversation starts. + * Leave empty to let the agent wait for the user to speak first. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Behavior", + meta = (ToolTip = "Agent's opening message.\nLeave empty for no greeting.")) + FString FirstMessage; + + /** Disable the idle follow-up behavior where the agent automatically speaks again + * if the user remains silent after the greeting / last response. + * When enabled: sets turn_timeout to -1 (infinite wait) so the agent + * waits indefinitely for the user to speak first. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voice", + meta = (ToolTip = "Prevent the agent from speaking again unprompted.\nSets turn_timeout to -1 (infinite wait).\nUseful when you want the player to initiate the conversation.")) + bool bDisableIdleFollowUp = false; + + /** Time (seconds) the agent waits for the user to speak before re-engaging. + * ElevenLabs API: conversation_config.turn.turn_timeout. + * Range: 1–30 seconds. Default: 7. -1 = wait indefinitely. + * Higher values make the agent more patient. + * When bDisableIdleFollowUp is true, this is overridden to -1 (infinite). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voice", + meta = (DisplayName = "Follow-up Timeout", + ClampMin = "-1.0", ClampMax = "30.0", + EditCondition = "!bDisableIdleFollowUp", + ToolTip = "Seconds before the agent speaks again if the user is silent.\nRange: 1-30. Default: 7. -1 = wait indefinitely.\nWhen 'Disable Idle Follow-up' is on, forced to -1 (infinite).")) + float TurnTimeout = 7.0f; + + /** Maximum number of turns in a conversation. 0 = unlimited. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voice", + meta = (ClampMin = "0", + ToolTip = "Max conversation turns.\n0 = unlimited.")) + int32 MaxTurns = 0; + + // ── Emotion Tool ───────────────────────────────────────────────────────── + + /** Include the built-in "set_emotion" client tool in the agent configuration. + * Allows the LLM to set facial expressions (Joy, Sadness, Anger, etc.) + * that drive the FacialExpression component in real-time. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Emotion Tool", + meta = (ToolTip = "Include the set_emotion client tool.\nAllows the LLM to drive facial expressions.")) + bool bIncludeEmotionTool = true; + + /** System prompt fragment appended to CharacterPrompt when bIncludeEmotionTool is true. + * Pre-filled with the standard emotion instruction. Editable for customization. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Emotion Tool", + meta = (MultiLine = "true", EditCondition = "bIncludeEmotionTool", + ToolTip = "Prompt instructions for the emotion tool.\nAppended to CharacterPrompt when creating/updating the agent.")) + FString EmotionToolPromptFragment = TEXT( + "## Facial Expressions\n" + "You have a set_emotion tool to control your facial expression. " + "Use it whenever the emotional context changes:\n" + "- Call set_emotion with emotion=\"joy\" when happy, laughing, or excited\n" + "- Call set_emotion with emotion=\"sadness\" when empathetic or discussing sad topics\n" + "- Call set_emotion with emotion=\"anger\" when frustrated or discussing injustice\n" + "- Call set_emotion with emotion=\"surprise\" when reacting to unexpected information\n" + "- Call set_emotion with emotion=\"fear\" when discussing scary or worrying topics\n" + "- Call set_emotion with emotion=\"disgust\" when reacting to unpleasant things\n" + "- Call set_emotion with emotion=\"neutral\" to return to a calm expression\n\n" + "Use intensity to match the strength of the emotion:\n" + "- \"low\" for subtle hints (slight smile, mild concern)\n" + "- \"medium\" for normal expression (default)\n" + "- \"high\" for strong reactions (big laugh, deep sadness, shock)\n\n" + "Always return to neutral when the emotional moment passes."); + + // ── Dynamic Variables ──────────────────────────────────────────────────── + + /** Key-value pairs sent as dynamic_variables at conversation start. + * Referenced in the system prompt as {{variable_name}}. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dynamic Variables", + meta = (ToolTip = "Dynamic variables available in the system prompt as {{key}}.\nSent at conversation start.")) + TMap DefaultDynamicVariables; + + // ── Metadata (read-only, populated by API) ─────────────────────────────── + + /** Timestamp of last API sync (ISO 8601). */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Metadata", + meta = (ToolTip = "When this asset was last synced with ElevenLabs.")) + FString LastSyncTimestamp; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_ElevenLabsComponent.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_ElevenLabsComponent.h index aa99b0c..1f1682f 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_ElevenLabsComponent.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_ElevenLabsComponent.h @@ -14,6 +14,7 @@ class UAudioComponent; class USoundAttenuation; class UPS_AI_ConvAgent_MicrophoneCaptureComponent; +class UPS_AI_ConvAgent_AgentConfig_ElevenLabs; class APlayerController; // ───────────────────────────────────────────────────────────────────────────── @@ -110,9 +111,17 @@ public: // ── Configuration ───────────────────────────────────────────────────────── - /** ElevenLabs Agent ID used for this conversation. Leave empty to use the default from Project Settings > PS AI ConvAgent - ElevenLabs. */ + /** Agent configuration data asset. + * When set, the AgentID is resolved from this asset at conversation start. + * Create one via Content Browser → right-click → Miscellaneous → PS AI ConvAgent Agent Config. */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|ElevenLabs", - meta = (ToolTip = "ElevenLabs Agent ID. Leave empty to use the project default from Project Settings.")) + meta = (ToolTip = "Agent configuration data asset.\nOverrides the AgentID string below when set.")) + TObjectPtr AgentConfig; + + /** ElevenLabs Agent ID used for this conversation. Leave empty to use the default from Project Settings > PS AI ConvAgent - ElevenLabs. + * Overridden by AgentConfig if set. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|ElevenLabs", + meta = (ToolTip = "ElevenLabs Agent ID. Leave empty to use the project default from Project Settings.\nOverridden by AgentConfig if set.")) FString AgentID; /** How turn-taking is managed between the user and the agent.\n- Server VAD (recommended): ElevenLabs automatically detects when the user stops speaking.\n- Client Controlled: You manually call StartListening/StopListening (push-to-talk with a key). */ diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_InteractionComponent.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_InteractionComponent.h index 32f17cb..7214666 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_InteractionComponent.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_InteractionComponent.h @@ -114,6 +114,18 @@ public: ToolTip = "Seconds to wait before the agent stops looking at the pawn.\n0 = immediate.")) float PostureDetachDelay = 0.0f; + // ── Conversation management ────────────────────────────────────────────── + + /** Automatically start the WebSocket conversation when an agent is selected + * (enters range + view cone). When false, selecting an agent only manages + * posture and visual awareness — the conversation must be started explicitly + * via StartConversationWithSelectedAgent() (e.g. on a key press). + * Set to false when you have multiple agents in a scene to prevent them + * all from greeting the player simultaneously. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|Interaction", + meta = (ToolTip = "Auto-start the WebSocket when an agent is selected by proximity.\nSet to false to require explicit interaction (call StartConversationWithSelectedAgent).\nUseful with multiple agents to prevent simultaneous greetings.")) + bool bAutoStartConversation = true; + // ── Listening management ───────────────────────────────────────────────── /** Automatically call StartListening/StopListening on the agent's @@ -163,6 +175,14 @@ public: UFUNCTION(BlueprintCallable, Category = "PS AI ConvAgent|Interaction") void ForceSelectAgent(UPS_AI_ConvAgent_ElevenLabsComponent* Agent); + /** Start the WebSocket conversation with the currently selected agent. + * Use this when bAutoStartConversation is false and the player explicitly + * interacts (e.g. presses a key, enters a trigger zone). + * Does nothing if no agent is selected or the agent is already connected. + * Also starts mic capture and listening automatically. */ + UFUNCTION(BlueprintCallable, Category = "PS AI ConvAgent|Interaction") + void StartConversationWithSelectedAgent(); + /** Clear the current selection. Automatic selection resumes next tick. */ UFUNCTION(BlueprintCallable, Category = "PS AI ConvAgent|Interaction") void ClearSelection(); diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/PS_AI_ConvAgentEditor.Build.cs b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/PS_AI_ConvAgentEditor.Build.cs index b613731..f9f9395 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/PS_AI_ConvAgentEditor.Build.cs +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/PS_AI_ConvAgentEditor.Build.cs @@ -22,5 +22,17 @@ public class PS_AI_ConvAgentEditor : ModuleRules // Runtime module containing FAnimNode_PS_AI_ConvAgent_LipSync "PS_AI_ConvAgent", }); + + PrivateDependencyModuleNames.AddRange(new string[] + { + // Slate UI for Detail Customization + "Slate", + "SlateCore", + "PropertyEditor", + // HTTP requests for ElevenLabs API (voice list, agent CRUD) + "HTTP", + "Json", + "JsonUtilities", + }); } } diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgentEditorModule.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgentEditorModule.cpp index 88fe202..b15796e 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgentEditorModule.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgentEditorModule.cpp @@ -1,16 +1,39 @@ // Copyright ASTERION. All Rights Reserved. #include "Modules/ModuleManager.h" +#include "PropertyEditorModule.h" +#include "PS_AI_ConvAgent_AgentConfig_ElevenLabs.h" +#include "PS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs.h" /** * Editor module for PS_AI_ConvAgent plugin. - * Provides AnimGraph node(s) for the PS AI ConvAgent Lip Sync system. + * Provides AnimGraph nodes, asset factories, and Detail Customizations. */ class FPS_AI_ConvAgentEditorModule : public IModuleInterface { public: - virtual void StartupModule() override {} - virtual void ShutdownModule() override {} + virtual void StartupModule() override + { + FPropertyEditorModule& PropertyModule = + FModuleManager::LoadModuleChecked("PropertyEditor"); + + PropertyModule.RegisterCustomClassLayout( + UPS_AI_ConvAgent_AgentConfig_ElevenLabs::StaticClass()->GetFName(), + FOnGetDetailCustomizationInstance::CreateStatic( + &FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::MakeInstance)); + } + + virtual void ShutdownModule() override + { + if (FModuleManager::Get().IsModuleLoaded("PropertyEditor")) + { + FPropertyEditorModule& PropertyModule = + FModuleManager::GetModuleChecked("PropertyEditor"); + + PropertyModule.UnregisterCustomClassLayout( + UPS_AI_ConvAgent_AgentConfig_ElevenLabs::StaticClass()->GetFName()); + } + } }; IMPLEMENT_MODULE(FPS_AI_ConvAgentEditorModule, PS_AI_ConvAgentEditor) diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs.cpp new file mode 100644 index 0000000..f66d714 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs.cpp @@ -0,0 +1,1600 @@ +// Copyright ASTERION. All Rights Reserved. + +#include "PS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs.h" +#include "PS_AI_ConvAgent_AgentConfig_ElevenLabs.h" +#include "PS_AI_ConvAgent.h" + +#include "DetailLayoutBuilder.h" +#include "DetailCategoryBuilder.h" +#include "DetailWidgetRow.h" +#include "Widgets/Input/SButton.h" +#include "Widgets/Input/STextComboBox.h" +#include "Widgets/Input/SMultiLineEditableTextBox.h" +#include "Widgets/Text/STextBlock.h" + +#include "HttpModule.h" +#include "Interfaces/IHttpRequest.h" +#include "Interfaces/IHttpResponse.h" +#include "Dom/JsonObject.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonWriter.h" +#include "Serialization/JsonSerializer.h" + +DEFINE_LOG_CATEGORY_STATIC(LogPS_AI_AgentConfigEditor, Log, All); + +// Approximate LLM latencies as shown on the ElevenLabs dashboard. +// The API does not expose this data — values are indicative and may change. +// Update this table periodically to stay current. +static FString GetLLMLatencyHint(const FString& ModelID) +{ + struct FLatencyEntry { const TCHAR* ID; const TCHAR* Latency; }; + static const FLatencyEntry Entries[] = + { + // OpenAI + { TEXT("gpt-4o-mini"), TEXT("~350ms") }, + { TEXT("gpt-4o"), TEXT("~700ms") }, + { TEXT("gpt-4"), TEXT("~900ms") }, + { TEXT("gpt-4-turbo"), TEXT("~650ms") }, + // Anthropic + { TEXT("claude-sonnet-4-5"), TEXT("~750ms") }, + { TEXT("claude-haiku-4-5"), TEXT("~350ms") }, + { TEXT("claude-3-5-sonnet"), TEXT("~700ms") }, + // Google + { TEXT("gemini-1.5-pro"), TEXT("~500ms") }, + { TEXT("gemini-2.0-flash"), TEXT("~300ms") }, + { TEXT("gemini-2.5-flash"), TEXT("~250ms") }, + // xAI + { TEXT("grok-beta"), TEXT("~500ms") }, + // ElevenLabs-hosted + { TEXT("qwen3-30b-a3b"), TEXT("~207ms") }, + { TEXT("glm-4.5-air"), TEXT("~980ms") }, + { TEXT("gpt-oss-120b"), TEXT("~331ms") }, + }; + + for (const auto& E : Entries) + { + if (ModelID == E.ID) return FString(E.Latency); + } + return FString(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Factory +// ───────────────────────────────────────────────────────────────────────────── +TSharedRef FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::MakeInstance() +{ + return MakeShareable(new FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs()); +} + +// ───────────────────────────────────────────────────────────────────────────── +// CustomizeDetails +// ───────────────────────────────────────────────────────────────────────────── +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::CustomizeDetails( + IDetailLayoutBuilder& DetailBuilder) +{ + DetailBuilder.GetObjectsBeingCustomized(SelectedObjects); + + // ── Hide properties managed by custom dropdowns ────────────────────────── + DetailBuilder.HideProperty( + DetailBuilder.GetProperty( + GET_MEMBER_NAME_CHECKED(UPS_AI_ConvAgent_AgentConfig_ElevenLabs, VoiceID))); + DetailBuilder.HideProperty( + DetailBuilder.GetProperty( + GET_MEMBER_NAME_CHECKED(UPS_AI_ConvAgent_AgentConfig_ElevenLabs, VoiceName))); + DetailBuilder.HideProperty( + DetailBuilder.GetProperty( + GET_MEMBER_NAME_CHECKED(UPS_AI_ConvAgent_AgentConfig_ElevenLabs, TTSModelID))); + DetailBuilder.HideProperty( + DetailBuilder.GetProperty( + GET_MEMBER_NAME_CHECKED(UPS_AI_ConvAgent_AgentConfig_ElevenLabs, LLMModel))); + DetailBuilder.HideProperty( + DetailBuilder.GetProperty( + GET_MEMBER_NAME_CHECKED(UPS_AI_ConvAgent_AgentConfig_ElevenLabs, Language))); + DetailBuilder.HideProperty( + DetailBuilder.GetProperty( + GET_MEMBER_NAME_CHECKED(UPS_AI_ConvAgent_AgentConfig_ElevenLabs, CharacterPrompt))); + + // ── Identity category: API action buttons ──────────────────────────────── + IDetailCategoryBuilder& IdentityCat = DetailBuilder.EditCategory( + TEXT("Identity"), FText::GetEmpty(), ECategoryPriority::Important); + + IdentityCat.AddCustomRow(FText::FromString(TEXT("Agent API Actions"))) + .WholeRowContent() + [ + SNew(SVerticalBox) + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0, 4) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot() + .AutoWidth() + .Padding(0, 0, 4, 0) + [ + SNew(SButton) + .Text(FText::FromString(TEXT("Create Agent"))) + .ToolTipText(FText::FromString(TEXT("POST /v1/convai/agents/create — creates a new agent on ElevenLabs."))) + .OnClicked_Lambda([this]() + { + OnCreateAgentClicked(); + return FReply::Handled(); + }) + ] + + SHorizontalBox::Slot() + .AutoWidth() + .Padding(0, 0, 4, 0) + [ + SNew(SButton) + .Text(FText::FromString(TEXT("Update Agent"))) + .ToolTipText(FText::FromString(TEXT("PATCH /v1/convai/agents/{id} — updates the existing agent."))) + .OnClicked_Lambda([this]() + { + OnUpdateAgentClicked(); + return FReply::Handled(); + }) + ] + + SHorizontalBox::Slot() + .AutoWidth() + [ + SNew(SButton) + .Text(FText::FromString(TEXT("Fetch Agent"))) + .ToolTipText(FText::FromString(TEXT("GET /v1/convai/agents/{id} — pulls existing config into this asset."))) + .OnClicked_Lambda([this]() + { + OnFetchAgentClicked(); + return FReply::Handled(); + }) + ] + ] + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0, 2) + [ + SAssignNew(StatusTextBlock, STextBlock) + .Text(FText::GetEmpty()) + .Font(IDetailLayoutBuilder::GetDetailFont()) + .ColorAndOpacity(FSlateColor(FLinearColor(0.3f, 0.7f, 1.0f))) + ] + ]; + + // ── Agent Settings category: LLM + Language + Voice + TTS Model ───────── + IDetailCategoryBuilder& AgentSettingsCat = DetailBuilder.EditCategory( + TEXT("Voice"), FText::FromString(TEXT("Agent Settings")), ECategoryPriority::Important); + + // LLM picker: Fetch button + dropdown + AgentSettingsCat.AddCustomRow(FText::FromString(TEXT("LLM"))) + .NameContent() + [ + SNew(STextBlock) + .Text(FText::FromString(TEXT("LLM Model"))) + .Font(IDetailLayoutBuilder::GetDetailFont()) + ] + .ValueContent() + .MaxDesiredWidth(300.f) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot() + .AutoWidth() + .Padding(0, 0, 4, 0) + [ + SNew(SButton) + .Text(FText::FromString(TEXT("Fetch"))) + .OnClicked_Lambda([this]() + { + OnFetchLLMsClicked(); + return FReply::Handled(); + }) + ] + + SHorizontalBox::Slot() + .FillWidth(1.f) + [ + SAssignNew(LLMComboBox, STextComboBox) + .OptionsSource(&LLMDisplayNames) + .OnSelectionChanged(this, &FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnLLMSelected) + .Font(IDetailLayoutBuilder::GetDetailFont()) + ] + ]; + + // Language picker: Fetch button + dropdown + AgentSettingsCat.AddCustomRow(FText::FromString(TEXT("Language"))) + .NameContent() + [ + SNew(STextBlock) + .Text(FText::FromString(TEXT("Language"))) + .Font(IDetailLayoutBuilder::GetDetailFont()) + ] + .ValueContent() + .MaxDesiredWidth(300.f) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot() + .AutoWidth() + .Padding(0, 0, 4, 0) + [ + SNew(SButton) + .Text(FText::FromString(TEXT("Fetch"))) + .OnClicked_Lambda([this]() + { + OnFetchLanguagesClicked(); + return FReply::Handled(); + }) + ] + + SHorizontalBox::Slot() + .FillWidth(1.f) + [ + SAssignNew(LanguageComboBox, STextComboBox) + .OptionsSource(&LanguageDisplayNames) + .OnSelectionChanged(this, &FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnLanguageSelected) + .Font(IDetailLayoutBuilder::GetDetailFont()) + ] + ]; + + // Voice picker: Fetch button + dropdown + AgentSettingsCat.AddCustomRow(FText::FromString(TEXT("Voice"))) + .NameContent() + [ + SNew(STextBlock) + .Text(FText::FromString(TEXT("Voice"))) + .Font(IDetailLayoutBuilder::GetDetailFont()) + ] + .ValueContent() + .MaxDesiredWidth(300.f) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot() + .AutoWidth() + .Padding(0, 0, 4, 0) + [ + SNew(SButton) + .Text(FText::FromString(TEXT("Fetch"))) + .OnClicked_Lambda([this]() + { + OnFetchVoicesClicked(); + return FReply::Handled(); + }) + ] + + SHorizontalBox::Slot() + .FillWidth(1.f) + [ + SAssignNew(VoiceComboBox, STextComboBox) + .OptionsSource(&VoiceDisplayNames) + .OnSelectionChanged(this, &FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnVoiceSelected) + .Font(IDetailLayoutBuilder::GetDetailFont()) + ] + ]; + + // TTS Model picker: Fetch button + dropdown + AgentSettingsCat.AddCustomRow(FText::FromString(TEXT("TTS Model"))) + .NameContent() + [ + SNew(STextBlock) + .Text(FText::FromString(TEXT("TTS Model"))) + .Font(IDetailLayoutBuilder::GetDetailFont()) + ] + .ValueContent() + .MaxDesiredWidth(300.f) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot() + .AutoWidth() + .Padding(0, 0, 4, 0) + [ + SNew(SButton) + .Text(FText::FromString(TEXT("Fetch"))) + .OnClicked_Lambda([this]() + { + OnFetchModelsClicked(); + return FReply::Handled(); + }) + ] + + SHorizontalBox::Slot() + .FillWidth(1.f) + [ + SAssignNew(ModelComboBox, STextComboBox) + .OptionsSource(&ModelDisplayNames) + .OnSelectionChanged(this, &FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnModelSelected) + .Font(IDetailLayoutBuilder::GetDetailFont()) + ] + ]; + + // ── Behavior category: tall CharacterPrompt editor ────────────────────── + IDetailCategoryBuilder& BehaviorCat = DetailBuilder.EditCategory( + TEXT("Behavior"), FText::GetEmpty(), ECategoryPriority::Default); + + BehaviorCat.AddCustomRow(FText::FromString(TEXT("Character Prompt"))) + .NameContent() + [ + SNew(STextBlock) + .Text(FText::FromString(TEXT("Character Prompt"))) + .Font(IDetailLayoutBuilder::GetDetailFont()) + ] + .ValueContent() + .MaxDesiredWidth(600.f) + [ + SNew(SBox) + .MinDesiredHeight(200.f) + [ + SNew(SMultiLineEditableTextBox) + .Font(IDetailLayoutBuilder::GetDetailFont()) + .AutoWrapText(true) + .Text_Lambda([this]() + { + if (const UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = GetEditedAsset()) + { + return FText::FromString(Asset->CharacterPrompt); + } + return FText::GetEmpty(); + }) + .OnTextCommitted_Lambda([this](const FText& NewText, ETextCommit::Type CommitType) + { + if (UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = GetEditedAsset()) + { + Asset->Modify(); + Asset->CharacterPrompt = NewText.ToString(); + } + }) + ] + ]; + + // ── Auto-fetch on editor open ─────────────────────────────────────────── + // Populate static Language list (instant) and pre-select current value. + OnFetchLanguagesClicked(); + + // Guard: PostEditChange() in the fetch callback re-triggers CustomizeDetails(). + // Without this guard, we'd get an infinite fetch → PostEditChange → CustomizeDetails loop. + if (!bAutoFetchDone && !GetAPIKey().IsEmpty()) + { + bAutoFetchDone = true; + + const UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = GetEditedAsset(); + if (Asset && !Asset->AgentID.IsEmpty()) + { + // Full agent fetch — pulls config + refreshes Voice/Model/LLM dropdowns. + SetStatusText(TEXT("Syncing agent from ElevenLabs...")); + OnFetchAgentClicked(); + } + else + { + // No AgentID yet — just fetch dropdown data. + SetStatusText(TEXT("Loading voices, models & LLMs...")); + OnFetchVoicesClicked(); + OnFetchModelsClicked(); + OnFetchLLMsClicked(); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Voice Picker +// ───────────────────────────────────────────────────────────────────────────── +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnFetchVoicesClicked() +{ + const FString APIKey = GetAPIKey(); + if (APIKey.IsEmpty()) + { + SetStatusError(TEXT("API Key not set in Project Settings > PS AI ConvAgent - ElevenLabs.")); + return; + } + + SetStatusText(TEXT("Fetching voices...")); + + TSharedRef Request = FHttpModule::Get().CreateRequest(); + Request->SetURL(TEXT("https://api.elevenlabs.io/v1/voices")); + Request->SetVerb(TEXT("GET")); + Request->SetHeader(TEXT("xi-api-key"), APIKey); + Request->SetHeader(TEXT("Accept"), TEXT("application/json")); + + TWeakPtr WeakSelf = + StaticCastSharedRef(this->AsShared()); + + Request->OnProcessRequestComplete().BindLambda( + [WeakSelf](FHttpRequestPtr Req, FHttpResponsePtr Resp, bool bConnected) + { + auto Pinned = WeakSelf.Pin(); + if (!Pinned.IsValid()) return; + + if (!bConnected || !Resp.IsValid()) + { + Pinned->SetStatusError(TEXT("Could not reach ElevenLabs API.")); + return; + } + + if (Resp->GetResponseCode() != 200) + { + Pinned->SetStatusError(ParseAPIError( + Resp->GetResponseCode(), Resp->GetContentAsString())); + return; + } + + TSharedPtr Root; + if (!FJsonSerializer::Deserialize( + TJsonReaderFactory<>::Create(Resp->GetContentAsString()), Root) || !Root.IsValid()) + { + Pinned->SetStatusError(TEXT("Failed to parse voices JSON.")); + return; + } + + const TArray>* Voices = nullptr; + if (!Root->TryGetArrayField(TEXT("voices"), Voices)) + { + Pinned->SetStatusError(TEXT("No 'voices' array in response.")); + return; + } + + Pinned->VoiceDisplayNames.Reset(); + Pinned->VoiceIDs.Reset(); + + for (const auto& VoiceVal : *Voices) + { + const TSharedPtr* VoiceObj = nullptr; + if (!VoiceVal->TryGetObject(VoiceObj)) continue; + + FString Name, ID; + (*VoiceObj)->TryGetStringField(TEXT("name"), Name); + (*VoiceObj)->TryGetStringField(TEXT("voice_id"), ID); + + if (!ID.IsEmpty()) + { + Pinned->VoiceDisplayNames.Add(MakeShareable(new FString(Name))); + Pinned->VoiceIDs.Add(ID); + } + } + + // Pre-select the currently set VoiceID, or auto-select the first voice for new assets. + if (UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = Pinned->GetEditedAsset()) + { + int32 Idx = Pinned->VoiceIDs.IndexOfByKey(Asset->VoiceID); + if (Idx == INDEX_NONE && Asset->VoiceID.IsEmpty() && Pinned->VoiceIDs.Num() > 0) + { + // New asset with no voice set — auto-select the first available voice. + Idx = 0; + Asset->Modify(); + Asset->VoiceID = Pinned->VoiceIDs[0]; + Asset->VoiceName = *Pinned->VoiceDisplayNames[0]; + } + if (Idx != INDEX_NONE && Pinned->VoiceComboBox.IsValid()) + { + Pinned->VoiceComboBox->SetSelectedItem(Pinned->VoiceDisplayNames[Idx]); + } + } + + if (Pinned->VoiceComboBox.IsValid()) + { + Pinned->VoiceComboBox->RefreshOptions(); + } + + Pinned->SetStatusSuccess(FString::Printf(TEXT("Fetched %d voices."), Pinned->VoiceIDs.Num())); + }); + + Request->ProcessRequest(); +} + +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnVoiceSelected( + TSharedPtr NewSelection, ESelectInfo::Type SelectInfo) +{ + if (!NewSelection.IsValid()) return; + + int32 Idx = VoiceDisplayNames.IndexOfByKey(NewSelection); + if (Idx == INDEX_NONE) return; + + if (UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = GetEditedAsset()) + { + Asset->Modify(); + Asset->VoiceID = VoiceIDs[Idx]; + Asset->VoiceName = *NewSelection; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Model Picker +// ───────────────────────────────────────────────────────────────────────────── +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnFetchModelsClicked() +{ + const FString APIKey = GetAPIKey(); + if (APIKey.IsEmpty()) + { + SetStatusError(TEXT("API Key not set in Project Settings > PS AI ConvAgent - ElevenLabs.")); + return; + } + + SetStatusText(TEXT("Fetching models...")); + + TSharedRef Request = FHttpModule::Get().CreateRequest(); + Request->SetURL(TEXT("https://api.elevenlabs.io/v1/models")); + Request->SetVerb(TEXT("GET")); + Request->SetHeader(TEXT("xi-api-key"), APIKey); + Request->SetHeader(TEXT("Accept"), TEXT("application/json")); + + TWeakPtr WeakSelf = + StaticCastSharedRef(this->AsShared()); + + Request->OnProcessRequestComplete().BindLambda( + [WeakSelf](FHttpRequestPtr Req, FHttpResponsePtr Resp, bool bConnected) + { + auto Pinned = WeakSelf.Pin(); + if (!Pinned.IsValid()) return; + + if (!bConnected || !Resp.IsValid()) + { + Pinned->SetStatusError(TEXT("Could not reach ElevenLabs API.")); + return; + } + + if (Resp->GetResponseCode() != 200) + { + Pinned->SetStatusError(ParseAPIError( + Resp->GetResponseCode(), Resp->GetContentAsString())); + return; + } + + // Response is a JSON array of model objects. + TArray> Models; + if (!FJsonSerializer::Deserialize( + TJsonReaderFactory<>::Create(Resp->GetContentAsString()), Models)) + { + Pinned->SetStatusError(TEXT("Failed to parse models JSON.")); + return; + } + + Pinned->ModelDisplayNames.Reset(); + Pinned->ModelIDs.Reset(); + + for (const auto& ModelVal : Models) + { + const TSharedPtr* ModelObj = nullptr; + if (!ModelVal->TryGetObject(ModelObj)) continue; + + FString Name, ID; + (*ModelObj)->TryGetStringField(TEXT("name"), Name); + (*ModelObj)->TryGetStringField(TEXT("model_id"), ID); + + // Only show TTS-capable models. + bool bCanTTS = false; + (*ModelObj)->TryGetBoolField(TEXT("can_do_text_to_speech"), bCanTTS); + if (!bCanTTS) continue; + + if (!ID.IsEmpty()) + { + FString DisplayStr = FString::Printf(TEXT("%s (%s)"), *Name, *ID); + Pinned->ModelDisplayNames.Add(MakeShareable(new FString(DisplayStr))); + Pinned->ModelIDs.Add(ID); + } + } + + // Pre-select the currently set TTSModelID if it exists in the list. + if (UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = Pinned->GetEditedAsset()) + { + int32 Idx = Pinned->ModelIDs.IndexOfByKey(Asset->TTSModelID); + if (Idx != INDEX_NONE && Pinned->ModelComboBox.IsValid()) + { + Pinned->ModelComboBox->SetSelectedItem(Pinned->ModelDisplayNames[Idx]); + } + } + + if (Pinned->ModelComboBox.IsValid()) + { + Pinned->ModelComboBox->RefreshOptions(); + } + + Pinned->SetStatusSuccess(FString::Printf(TEXT("Fetched %d TTS models."), Pinned->ModelIDs.Num())); + }); + + Request->ProcessRequest(); +} + +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnModelSelected( + TSharedPtr NewSelection, ESelectInfo::Type SelectInfo) +{ + if (!NewSelection.IsValid()) return; + + int32 Idx = ModelDisplayNames.IndexOfByKey(NewSelection); + if (Idx == INDEX_NONE) return; + + if (UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = GetEditedAsset()) + { + Asset->Modify(); + Asset->TTSModelID = ModelIDs[Idx]; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// LLM Picker +// ───────────────────────────────────────────────────────────────────────────── +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnFetchLLMsClicked() +{ + const FString APIKey = GetAPIKey(); + if (APIKey.IsEmpty()) + { + SetStatusError(TEXT("API Key not set in Project Settings > PS AI ConvAgent - ElevenLabs.")); + return; + } + + SetStatusText(TEXT("Fetching LLMs...")); + + TSharedRef Request = FHttpModule::Get().CreateRequest(); + Request->SetURL(TEXT("https://api.elevenlabs.io/v1/convai/llm/list")); + Request->SetVerb(TEXT("GET")); + Request->SetHeader(TEXT("xi-api-key"), APIKey); + Request->SetHeader(TEXT("Accept"), TEXT("application/json")); + + TWeakPtr WeakSelf = + StaticCastSharedRef(this->AsShared()); + + Request->OnProcessRequestComplete().BindLambda( + [WeakSelf](FHttpRequestPtr Req, FHttpResponsePtr Resp, bool bConnected) + { + auto Pinned = WeakSelf.Pin(); + if (!Pinned.IsValid()) return; + + if (!bConnected || !Resp.IsValid()) + { + Pinned->SetStatusError(TEXT("Could not reach ElevenLabs API (LLM list).")); + return; + } + + if (Resp->GetResponseCode() != 200) + { + Pinned->SetStatusError(ParseAPIError( + Resp->GetResponseCode(), Resp->GetContentAsString())); + return; + } + + TSharedPtr Root; + if (!FJsonSerializer::Deserialize( + TJsonReaderFactory<>::Create(Resp->GetContentAsString()), Root) || !Root.IsValid()) + { + Pinned->SetStatusError(TEXT("Failed to parse LLM list JSON.")); + return; + } + + const TArray>* LLMs = nullptr; + if (!Root->TryGetArrayField(TEXT("llms"), LLMs)) + { + Pinned->SetStatusError(TEXT("No 'llms' array in response.")); + return; + } + + Pinned->LLMDisplayNames.Reset(); + Pinned->LLMModelIDs.Reset(); + + for (const auto& LLMVal : *LLMs) + { + const TSharedPtr* LLMObj = nullptr; + if (!LLMVal->TryGetObject(LLMObj)) continue; + + FString ModelID; + (*LLMObj)->TryGetStringField(TEXT("llm"), ModelID); + + if (ModelID.IsEmpty()) continue; + + // Skip "custom-llm" entry — that's for server integrations. + if (ModelID == TEXT("custom-llm")) continue; + + // Skip deprecated models. + const TSharedPtr* DeprecationInfo = nullptr; + if ((*LLMObj)->TryGetObjectField(TEXT("deprecation_info"), DeprecationInfo)) + { + bool bDeprecated = false; + if (DeprecationInfo->Get()->TryGetBoolField(TEXT("is_deprecated"), bDeprecated) && bDeprecated) + { + continue; + } + } + + // Check if it's a checkpoint model (sub-version). + bool bIsCheckpoint = false; + (*LLMObj)->TryGetBoolField(TEXT("is_checkpoint"), bIsCheckpoint); + + // Build display string: "model-id (~350ms)" or " model-id (checkpoint, ~350ms)" + const FString Latency = GetLLMLatencyHint(ModelID); + FString Display; + if (bIsCheckpoint) + { + Display = Latency.IsEmpty() + ? FString::Printf(TEXT(" %s (checkpoint)"), *ModelID) + : FString::Printf(TEXT(" %s (checkpoint, %s)"), *ModelID, *Latency); + } + else + { + Display = Latency.IsEmpty() + ? ModelID + : FString::Printf(TEXT("%s (%s)"), *ModelID, *Latency); + } + + Pinned->LLMDisplayNames.Add(MakeShareable(new FString(Display))); + Pinned->LLMModelIDs.Add(ModelID); + } + + // Pre-select the currently set LLMModel if it exists in the list. + if (const UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = Pinned->GetEditedAsset()) + { + if (!Asset->LLMModel.IsEmpty()) + { + int32 Idx = Pinned->LLMModelIDs.IndexOfByKey(Asset->LLMModel); + if (Idx == INDEX_NONE) + { + // Asset's model not in list — add it as a custom entry. + FString CustomDisplay = FString::Printf(TEXT("%s (custom)"), *Asset->LLMModel); + Pinned->LLMDisplayNames.Add(MakeShareable(new FString(CustomDisplay))); + Pinned->LLMModelIDs.Add(Asset->LLMModel); + Idx = Pinned->LLMModelIDs.Num() - 1; + } + if (Pinned->LLMComboBox.IsValid()) + { + Pinned->LLMComboBox->SetSelectedItem(Pinned->LLMDisplayNames[Idx]); + } + } + } + + if (Pinned->LLMComboBox.IsValid()) + { + Pinned->LLMComboBox->RefreshOptions(); + } + + Pinned->SetStatusSuccess(FString::Printf(TEXT("Fetched %d LLMs."), Pinned->LLMModelIDs.Num())); + }); + + Request->ProcessRequest(); +} + +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnLLMSelected( + TSharedPtr NewSelection, ESelectInfo::Type SelectInfo) +{ + if (!NewSelection.IsValid()) return; + + int32 Idx = LLMDisplayNames.IndexOfByKey(NewSelection); + if (Idx == INDEX_NONE) return; + + if (UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = GetEditedAsset()) + { + Asset->Modify(); + Asset->LLMModel = LLMModelIDs[Idx]; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Language Picker (static list) +// ───────────────────────────────────────────────────────────────────────────── +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnFetchLanguagesClicked() +{ + LanguageDisplayNames.Reset(); + LanguageCodes.Reset(); + + struct FLangEntry { const TCHAR* Code; const TCHAR* Name; }; + static const FLangEntry Langs[] = + { + { TEXT("en"), TEXT("English") }, + { TEXT("fr"), TEXT("French") }, + { TEXT("de"), TEXT("German") }, + { TEXT("es"), TEXT("Spanish") }, + { TEXT("it"), TEXT("Italian") }, + { TEXT("pt"), TEXT("Portuguese") }, + { TEXT("ja"), TEXT("Japanese") }, + { TEXT("ko"), TEXT("Korean") }, + { TEXT("zh"), TEXT("Chinese") }, + { TEXT("nl"), TEXT("Dutch") }, + { TEXT("pl"), TEXT("Polish") }, + { TEXT("ru"), TEXT("Russian") }, + { TEXT("sv"), TEXT("Swedish") }, + { TEXT("tr"), TEXT("Turkish") }, + { TEXT("hi"), TEXT("Hindi") }, + { TEXT("cs"), TEXT("Czech") }, + { TEXT("ar"), TEXT("Arabic") }, + { TEXT("id"), TEXT("Indonesian") }, + { TEXT("fi"), TEXT("Finnish") }, + { TEXT("da"), TEXT("Danish") }, + { TEXT("el"), TEXT("Greek") }, + { TEXT("hu"), TEXT("Hungarian") }, + { TEXT("no"), TEXT("Norwegian") }, + { TEXT("ro"), TEXT("Romanian") }, + { TEXT("uk"), TEXT("Ukrainian") }, + { TEXT("vi"), TEXT("Vietnamese") }, + }; + + for (const auto& L : Langs) + { + FString Display = FString::Printf(TEXT("%s (%s)"), L.Name, L.Code); + LanguageDisplayNames.Add(MakeShareable(new FString(Display))); + LanguageCodes.Add(L.Code); + } + + // Pre-select the currently set Language if it exists in the list. + if (const UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = GetEditedAsset()) + { + int32 Idx = LanguageCodes.IndexOfByKey(Asset->Language); + if (Idx != INDEX_NONE && LanguageComboBox.IsValid()) + { + LanguageComboBox->SetSelectedItem(LanguageDisplayNames[Idx]); + } + } + + if (LanguageComboBox.IsValid()) + { + LanguageComboBox->RefreshOptions(); + } +} + +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnLanguageSelected( + TSharedPtr NewSelection, ESelectInfo::Type SelectInfo) +{ + if (!NewSelection.IsValid()) return; + + int32 Idx = LanguageDisplayNames.IndexOfByKey(NewSelection); + if (Idx == INDEX_NONE) return; + + if (UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = GetEditedAsset()) + { + Asset->Modify(); + Asset->Language = LanguageCodes[Idx]; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Agent API — Create +// ───────────────────────────────────────────────────────────────────────────── +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnCreateAgentClicked() +{ + const UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = GetEditedAsset(); + if (!Asset) + { + SetStatusError(TEXT("No asset selected.")); + return; + } + + if (!Asset->AgentID.IsEmpty()) + { + SetStatusError(TEXT("Agent already has an ID. Use Update instead.")); + return; + } + + const FString APIKey = GetAPIKey(); + if (APIKey.IsEmpty()) + { + SetStatusError(TEXT("API Key not set in Project Settings.")); + return; + } + + SetStatusText(TEXT("Creating agent...")); + + TSharedPtr Payload = BuildAgentPayload(); + FString PayloadStr; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&PayloadStr); + FJsonSerializer::Serialize(Payload.ToSharedRef(), Writer); + + TSharedRef Request = FHttpModule::Get().CreateRequest(); + Request->SetURL(TEXT("https://api.elevenlabs.io/v1/convai/agents/create")); + Request->SetVerb(TEXT("POST")); + Request->SetHeader(TEXT("xi-api-key"), APIKey); + Request->SetHeader(TEXT("Content-Type"), TEXT("application/json")); + Request->SetContentAsString(PayloadStr); + + TWeakPtr WeakSelf = + StaticCastSharedRef(this->AsShared()); + + Request->OnProcessRequestComplete().BindLambda( + [WeakSelf](FHttpRequestPtr Req, FHttpResponsePtr Resp, bool bConnected) + { + auto Pinned = WeakSelf.Pin(); + if (!Pinned.IsValid()) return; + + if (!bConnected || !Resp.IsValid()) + { + Pinned->SetStatusError(TEXT("Could not reach ElevenLabs API.")); + return; + } + + if (Resp->GetResponseCode() != 200 && Resp->GetResponseCode() != 201) + { + Pinned->SetStatusError(ParseAPIError( + Resp->GetResponseCode(), Resp->GetContentAsString())); + return; + } + + TSharedPtr Root; + if (!FJsonSerializer::Deserialize( + TJsonReaderFactory<>::Create(Resp->GetContentAsString()), Root) || !Root.IsValid()) + { + Pinned->SetStatusError(TEXT("Failed to parse response.")); + return; + } + + FString NewAgentID; + if (!Root->TryGetStringField(TEXT("agent_id"), NewAgentID)) + { + Pinned->SetStatusError(TEXT("No 'agent_id' in response.")); + return; + } + + if (UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = Pinned->GetEditedAsset()) + { + Asset->Modify(); + Asset->AgentID = NewAgentID; + Asset->LastSyncTimestamp = FDateTime::UtcNow().ToIso8601(); + Asset->PostEditChange(); + } + + Pinned->SetStatusSuccess(FString::Printf(TEXT("Agent created: %s"), *NewAgentID)); + }); + + Request->ProcessRequest(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Agent API — Update +// ───────────────────────────────────────────────────────────────────────────── +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnUpdateAgentClicked() +{ + const UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = GetEditedAsset(); + if (!Asset) + { + SetStatusError(TEXT("No asset selected.")); + return; + } + + if (Asset->AgentID.IsEmpty()) + { + SetStatusError(TEXT("No AgentID set. Use Create first.")); + return; + } + + const FString APIKey = GetAPIKey(); + if (APIKey.IsEmpty()) + { + SetStatusError(TEXT("API Key not set in Project Settings.")); + return; + } + + SetStatusText(TEXT("Updating agent...")); + + TSharedPtr Payload = BuildAgentPayload(); + FString PayloadStr; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&PayloadStr); + FJsonSerializer::Serialize(Payload.ToSharedRef(), Writer); + + const FString URL = FString::Printf( + TEXT("https://api.elevenlabs.io/v1/convai/agents/%s"), *Asset->AgentID); + + TSharedRef Request = FHttpModule::Get().CreateRequest(); + Request->SetURL(URL); + Request->SetVerb(TEXT("PATCH")); + Request->SetHeader(TEXT("xi-api-key"), APIKey); + Request->SetHeader(TEXT("Content-Type"), TEXT("application/json")); + Request->SetContentAsString(PayloadStr); + + TWeakPtr WeakSelf = + StaticCastSharedRef(this->AsShared()); + + Request->OnProcessRequestComplete().BindLambda( + [WeakSelf](FHttpRequestPtr Req, FHttpResponsePtr Resp, bool bConnected) + { + auto Pinned = WeakSelf.Pin(); + if (!Pinned.IsValid()) return; + + if (!bConnected || !Resp.IsValid()) + { + Pinned->SetStatusError(TEXT("Could not reach ElevenLabs API.")); + return; + } + + if (Resp->GetResponseCode() != 200) + { + Pinned->SetStatusError(ParseAPIError( + Resp->GetResponseCode(), Resp->GetContentAsString())); + return; + } + + if (UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = Pinned->GetEditedAsset()) + { + Asset->Modify(); + Asset->LastSyncTimestamp = FDateTime::UtcNow().ToIso8601(); + } + + Pinned->SetStatusSuccess(TEXT("Agent updated successfully.")); + }); + + Request->ProcessRequest(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Agent API — Fetch (pull config from ElevenLabs into asset) +// ───────────────────────────────────────────────────────────────────────────── +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::OnFetchAgentClicked() +{ + UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = GetEditedAsset(); + if (!Asset) + { + SetStatusError(TEXT("No asset selected.")); + return; + } + + if (Asset->AgentID.IsEmpty()) + { + SetStatusError(TEXT("No AgentID set. Enter an ID first or use Create.")); + return; + } + + const FString APIKey = GetAPIKey(); + if (APIKey.IsEmpty()) + { + SetStatusError(TEXT("API Key not set in Project Settings.")); + return; + } + + SetStatusText(TEXT("Fetching agent config...")); + + const FString URL = FString::Printf( + TEXT("https://api.elevenlabs.io/v1/convai/agents/%s"), *Asset->AgentID); + + TSharedRef Request = FHttpModule::Get().CreateRequest(); + Request->SetURL(URL); + Request->SetVerb(TEXT("GET")); + Request->SetHeader(TEXT("xi-api-key"), APIKey); + Request->SetHeader(TEXT("Accept"), TEXT("application/json")); + + TWeakPtr WeakSelf = + StaticCastSharedRef(this->AsShared()); + + Request->OnProcessRequestComplete().BindLambda( + [WeakSelf](FHttpRequestPtr Req, FHttpResponsePtr Resp, bool bConnected) + { + auto Pinned = WeakSelf.Pin(); + if (!Pinned.IsValid()) return; + + if (!bConnected || !Resp.IsValid()) + { + Pinned->SetStatusError(TEXT("Could not reach ElevenLabs API.")); + return; + } + + if (Resp->GetResponseCode() != 200) + { + Pinned->SetStatusError(ParseAPIError( + Resp->GetResponseCode(), Resp->GetContentAsString())); + return; + } + + TSharedPtr Root; + if (!FJsonSerializer::Deserialize( + TJsonReaderFactory<>::Create(Resp->GetContentAsString()), Root) || !Root.IsValid()) + { + Pinned->SetStatusError(TEXT("Failed to parse response.")); + return; + } + + UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = Pinned->GetEditedAsset(); + if (!Asset) return; + + Asset->Modify(); + + // Name + FString Name; + if (Root->TryGetStringField(TEXT("name"), Name)) + { + Asset->AgentName = Name; + } + + // conversation_config.agent + const TSharedPtr* ConvConfig = nullptr; + if (Root->TryGetObjectField(TEXT("conversation_config"), ConvConfig)) + { + const TSharedPtr* AgentObj = nullptr; + if ((*ConvConfig)->TryGetObjectField(TEXT("agent"), AgentObj)) + { + // prompt object + const TSharedPtr* PromptObj = nullptr; + if ((*AgentObj)->TryGetObjectField(TEXT("prompt"), PromptObj)) + { + FString Prompt; + if ((*PromptObj)->TryGetStringField(TEXT("prompt"), Prompt)) + { + // Strip auto-appended fragments from the fetched prompt + // to avoid doubling them on next Update. + // Order matters: strip from earliest marker to preserve CharacterPrompt. + + // 1. Language instruction marker + { + const FString LangMarker = TEXT("\n\n## Language"); + int32 Idx = Prompt.Find(LangMarker, ESearchCase::CaseSensitive); + if (Idx != INDEX_NONE) + { + Prompt.LeftInline(Idx); + } + } + + // 2. Legacy "Conversation Behavior" section (idle follow-up) + // Stripped to avoid doubling — idle follow-up is now handled + // entirely by turn_timeout = -1 in the API. + { + const FString IdleMarker = TEXT("\n\n## Conversation Behavior"); + int32 Idx = Prompt.Find(IdleMarker, ESearchCase::CaseSensitive); + if (Idx != INDEX_NONE) + { + Prompt.LeftInline(Idx); + } + } + + // 3. Emotion tool fragment + if (!Asset->EmotionToolPromptFragment.IsEmpty()) + { + int32 Idx = Prompt.Find(Asset->EmotionToolPromptFragment, + ESearchCase::CaseSensitive); + if (Idx != INDEX_NONE) + { + Prompt.LeftInline(Idx); + } + else + { + const FString EmotionMarker = TEXT("\n\n## Facial Expressions"); + int32 MarkerIdx = Prompt.Find(EmotionMarker, + ESearchCase::CaseSensitive); + if (MarkerIdx != INDEX_NONE) + { + Prompt.LeftInline(MarkerIdx); + } + } + } + + Asset->CharacterPrompt = Prompt; + } + + FString LLM; + if ((*PromptObj)->TryGetStringField(TEXT("llm"), LLM)) + { + Asset->LLMModel = LLM; + } + } + + // first_message + FString FirstMsg; + if ((*AgentObj)->TryGetStringField(TEXT("first_message"), FirstMsg)) + { + Asset->FirstMessage = FirstMsg; + } + + // language + FString Lang; + if ((*AgentObj)->TryGetStringField(TEXT("language"), Lang)) + { + Asset->Language = Lang; + } + + // max_tokens (maps to MaxTurns) + int32 MaxTurns = 0; + if ((*AgentObj)->TryGetNumberField(TEXT("max_tokens"), MaxTurns)) + { + Asset->MaxTurns = MaxTurns; + } + } + + // conversation_config.tts + const TSharedPtr* TTSObj = nullptr; + if ((*ConvConfig)->TryGetObjectField(TEXT("tts"), TTSObj)) + { + FString VoiceID; + if ((*TTSObj)->TryGetStringField(TEXT("voice_id"), VoiceID)) + { + Asset->VoiceID = VoiceID; + } + + FString ModelID; + if ((*TTSObj)->TryGetStringField(TEXT("model_id"), ModelID)) + { + Asset->TTSModelID = ModelID; + } + + double Stability = 0.5; + if ((*TTSObj)->TryGetNumberField(TEXT("stability"), Stability)) + { + Asset->Stability = static_cast(Stability); + } + + double SimBoost = 0.75; + if ((*TTSObj)->TryGetNumberField(TEXT("similarity_boost"), SimBoost)) + { + Asset->SimilarityBoost = static_cast(SimBoost); + } + + double Speed = 1.0; + if ((*TTSObj)->TryGetNumberField(TEXT("speed"), Speed)) + { + Asset->Speed = static_cast(Speed); + } + } + + // conversation_config.turn + const TSharedPtr* TurnObj = nullptr; + if ((*ConvConfig)->TryGetObjectField(TEXT("turn"), TurnObj)) + { + double TurnTimeout = 7.0; + if ((*TurnObj)->TryGetNumberField(TEXT("turn_timeout"), TurnTimeout)) + { + // -1 = infinite wait, otherwise clamp to 1-30 + Asset->TurnTimeout = (TurnTimeout < 0.0) + ? -1.0f + : static_cast(FMath::Clamp(TurnTimeout, 1.0, 30.0)); + } + } + } + + Asset->LastSyncTimestamp = FDateTime::UtcNow().ToIso8601(); + + // Refresh Language combo (static list, instant) + Pinned->OnFetchLanguagesClicked(); + + // Trigger API fetches so all dropdowns update with correct selection + if (!Pinned->GetAPIKey().IsEmpty()) + { + Pinned->OnFetchLLMsClicked(); + Pinned->OnFetchVoicesClicked(); + Pinned->OnFetchModelsClicked(); + } + + Asset->PostEditChange(); + + Pinned->SetStatusSuccess(TEXT("Agent config fetched successfully.")); + }); + + Request->ProcessRequest(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── +FString FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::GetAPIKey() const +{ + if (FPS_AI_ConvAgentModule::IsAvailable()) + { + if (const UPS_AI_ConvAgent_Settings_ElevenLabs* Settings = FPS_AI_ConvAgentModule::Get().GetSettings()) + { + return Settings->API_Key; + } + } + return FString(); +} + +UPS_AI_ConvAgent_AgentConfig_ElevenLabs* FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::GetEditedAsset() const +{ + for (const TWeakObjectPtr& Obj : SelectedObjects) + { + if (UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = + Cast(Obj.Get())) + { + return Asset; + } + } + return nullptr; +} + +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::SetStatusText(const FString& Text) +{ + UE_LOG(LogPS_AI_AgentConfigEditor, Log, TEXT("%s"), *Text); + if (StatusTextBlock.IsValid()) + { + StatusTextBlock->SetText(FText::FromString(Text)); + StatusTextBlock->SetColorAndOpacity(FSlateColor(FLinearColor(0.3f, 0.7f, 1.0f))); // cyan/info + } +} + +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::SetStatusError(const FString& Text) +{ + UE_LOG(LogPS_AI_AgentConfigEditor, Error, TEXT("%s"), *Text); + if (StatusTextBlock.IsValid()) + { + StatusTextBlock->SetText(FText::FromString(Text)); + StatusTextBlock->SetColorAndOpacity(FSlateColor(FLinearColor(1.0f, 0.25f, 0.25f))); // red + } +} + +void FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::SetStatusSuccess(const FString& Text) +{ + UE_LOG(LogPS_AI_AgentConfigEditor, Log, TEXT("%s"), *Text); + if (StatusTextBlock.IsValid()) + { + StatusTextBlock->SetText(FText::FromString(Text)); + StatusTextBlock->SetColorAndOpacity(FSlateColor(FLinearColor(0.2f, 0.9f, 0.3f))); // green + } +} + +FString FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::ParseAPIError( + int32 HttpCode, const FString& ResponseBody) +{ + // Try to extract the human-readable message from ElevenLabs error JSON. + // Format 1: { "detail": { "message": "..." } } + // Format 2: { "detail": { "message": "[{\"msg\": \"...\"}]" } } (validation errors) + // Format 3: { "detail": "simple string" } + TSharedPtr Root; + if (FJsonSerializer::Deserialize(TJsonReaderFactory<>::Create(ResponseBody), Root) && Root.IsValid()) + { + // Try detail as object + const TSharedPtr* DetailObj = nullptr; + if (Root->TryGetObjectField(TEXT("detail"), DetailObj)) + { + FString Message; + if ((*DetailObj)->TryGetStringField(TEXT("message"), Message)) + { + // Check if the message itself contains a JSON array of validation errors + if (Message.StartsWith(TEXT("["))) + { + TArray> Errors; + if (FJsonSerializer::Deserialize(TJsonReaderFactory<>::Create(Message), Errors)) + { + TArray Messages; + for (const auto& ErrVal : Errors) + { + const TSharedPtr* ErrObj = nullptr; + if (ErrVal->TryGetObject(ErrObj)) + { + FString Msg; + if ((*ErrObj)->TryGetStringField(TEXT("msg"), Msg)) + { + Messages.Add(Msg); + } + } + } + if (Messages.Num() > 0) + { + return FString::Printf(TEXT("HTTP %d: %s"), + HttpCode, *FString::Join(Messages, TEXT(" | "))); + } + } + } + return FString::Printf(TEXT("HTTP %d: %s"), HttpCode, *Message); + } + } + + // Try detail as simple string + FString DetailStr; + if (Root->TryGetStringField(TEXT("detail"), DetailStr)) + { + return FString::Printf(TEXT("HTTP %d: %s"), HttpCode, *DetailStr); + } + } + + // Fallback: truncate raw response + return FString::Printf(TEXT("HTTP %d: %s"), HttpCode, *ResponseBody.Left(200)); +} + +TSharedPtr FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::BuildAgentPayload() const +{ + const UPS_AI_ConvAgent_AgentConfig_ElevenLabs* Asset = GetEditedAsset(); + if (!Asset) return MakeShareable(new FJsonObject()); + + // Build the full system prompt by appending automated fragments. + // Order: CharacterPrompt + Language/Multilingual instruction + Emotion tool + FString FullPrompt = Asset->CharacterPrompt; + + UE_LOG(LogPS_AI_AgentConfigEditor, Log, + TEXT("BuildAgentPayload: CharacterPrompt=%d chars, bMultilingual=%d, bAutoLangInstr=%d, Language='%s', " + "LangFragment=%d chars, MultiFragment=%d chars, bEmotionTool=%d"), + Asset->CharacterPrompt.Len(), + Asset->bMultilingual, + Asset->bAutoLanguageInstruction, + *Asset->Language, + Asset->LanguagePromptFragment.Len(), + Asset->MultilingualPromptFragment.Len(), + Asset->bIncludeEmotionTool); + + // Language handling: multilingual mode vs fixed-language mode. + // The ElevenLabs "language" field only controls STT/TTS — the LLM defaults to + // English unless explicitly told otherwise via the prompt. + if (Asset->bMultilingual) + { + // Multilingual mode: agent mirrors the user's language dynamically. + if (!Asset->MultilingualPromptFragment.IsEmpty()) + { + FullPrompt += TEXT("\n\n"); + FullPrompt += Asset->MultilingualPromptFragment; + UE_LOG(LogPS_AI_AgentConfigEditor, Log, TEXT(" → Appended MultilingualPromptFragment")); + } + } + else if (Asset->bAutoLanguageInstruction + && !Asset->Language.IsEmpty() + && Asset->Language != TEXT("en") + && !Asset->LanguagePromptFragment.IsEmpty()) + { + // Fixed-language mode: force the LLM to always respond in one language. + // Replace {Language} placeholder with the actual language display name. + static const TMap LangNames = { + {TEXT("fr"), TEXT("French")}, {TEXT("de"), TEXT("German")}, + {TEXT("es"), TEXT("Spanish")}, {TEXT("it"), TEXT("Italian")}, + {TEXT("pt"), TEXT("Portuguese")}, {TEXT("ja"), TEXT("Japanese")}, + {TEXT("ko"), TEXT("Korean")}, {TEXT("zh"), TEXT("Chinese")}, + {TEXT("nl"), TEXT("Dutch")}, {TEXT("pl"), TEXT("Polish")}, + {TEXT("ru"), TEXT("Russian")}, {TEXT("sv"), TEXT("Swedish")}, + {TEXT("tr"), TEXT("Turkish")}, {TEXT("hi"), TEXT("Hindi")}, + {TEXT("cs"), TEXT("Czech")}, {TEXT("ar"), TEXT("Arabic")}, + {TEXT("id"), TEXT("Indonesian")}, {TEXT("fi"), TEXT("Finnish")}, + {TEXT("da"), TEXT("Danish")}, {TEXT("el"), TEXT("Greek")}, + {TEXT("hu"), TEXT("Hungarian")}, {TEXT("no"), TEXT("Norwegian")}, + {TEXT("ro"), TEXT("Romanian")}, {TEXT("uk"), TEXT("Ukrainian")}, + {TEXT("vi"), TEXT("Vietnamese")}, + }; + + const FString* LangName = LangNames.Find(Asset->Language); + const FString DisplayLang = LangName ? *LangName : Asset->Language; + FString LangFragment = Asset->LanguagePromptFragment; + LangFragment.ReplaceInline(TEXT("{Language}"), *DisplayLang); + FullPrompt += TEXT("\n\n"); + FullPrompt += LangFragment; + UE_LOG(LogPS_AI_AgentConfigEditor, Log, TEXT(" → Appended LanguagePromptFragment for '%s'"), *DisplayLang); + } + + // Append emotion tool instructions. + if (Asset->bIncludeEmotionTool && !Asset->EmotionToolPromptFragment.IsEmpty()) + { + FullPrompt += TEXT("\n\n"); + FullPrompt += Asset->EmotionToolPromptFragment; + UE_LOG(LogPS_AI_AgentConfigEditor, Log, TEXT(" → Appended EmotionToolPromptFragment")); + } + + UE_LOG(LogPS_AI_AgentConfigEditor, Log, TEXT("BuildAgentPayload: FullPrompt = %d chars"), FullPrompt.Len()); + + // prompt object (includes LLM selection + tools) + TSharedPtr PromptObj = MakeShareable(new FJsonObject()); + PromptObj->SetStringField(TEXT("prompt"), FullPrompt); + if (!Asset->LLMModel.IsEmpty()) + { + PromptObj->SetStringField(TEXT("llm"), Asset->LLMModel); + } + + // If emotion tool is enabled, add to prompt.tools[] (API path: conversation_config.agent.prompt.tools) + if (Asset->bIncludeEmotionTool) + { + TSharedPtr EmotionTool = BuildEmotionToolDefinition(); + TArray> Tools; + Tools.Add(MakeShareable(new FJsonValueObject(EmotionTool))); + PromptObj->SetArrayField(TEXT("tools"), Tools); + } + + // agent + TSharedPtr AgentObj = MakeShareable(new FJsonObject()); + AgentObj->SetObjectField(TEXT("prompt"), PromptObj); + if (!Asset->FirstMessage.IsEmpty()) + { + AgentObj->SetStringField(TEXT("first_message"), Asset->FirstMessage); + } + if (!Asset->Language.IsEmpty()) + { + AgentObj->SetStringField(TEXT("language"), Asset->Language); + } + if (Asset->MaxTurns > 0) + { + AgentObj->SetNumberField(TEXT("max_tokens"), Asset->MaxTurns); + } + + // tts + TSharedPtr TTSObj = MakeShareable(new FJsonObject()); + if (!Asset->VoiceID.IsEmpty()) + { + TTSObj->SetStringField(TEXT("voice_id"), Asset->VoiceID); + } + + // Resolve TTS model. + // Multilingual and non-English agents require a multilingual-capable model: + // eleven_multilingual_v2, eleven_turbo_v2_5, eleven_flash_v2_5 + // Monolingual models (e.g. eleven_monolingual_v1) only support English. + FString ResolvedModelID = Asset->TTSModelID; + + auto IsMultilingualModel = [](const FString& ModelID) -> bool + { + return ModelID.Contains(TEXT("multilingual")) + || ModelID.Contains(TEXT("turbo")) + || ModelID.Contains(TEXT("flash")); + }; + + if (Asset->bMultilingual) + { + // Multilingual mode: force a multilingual TTS model. + if (ResolvedModelID.IsEmpty() || !IsMultilingualModel(ResolvedModelID)) + { + ResolvedModelID = TEXT("eleven_multilingual_v2"); + UE_LOG(LogPS_AI_AgentConfigEditor, Warning, + TEXT("Multilingual agent: overriding TTS model to eleven_multilingual_v2 (multilingual support required).")); + } + } + else + { + const bool bNonEnglish = !Asset->Language.IsEmpty() && Asset->Language != TEXT("en"); + if (bNonEnglish) + { + // Non-English fixed-language agents MUST use a multilingual-capable model. + if (ResolvedModelID.IsEmpty() || !IsMultilingualModel(ResolvedModelID)) + { + ResolvedModelID = TEXT("eleven_turbo_v2_5"); + UE_LOG(LogPS_AI_AgentConfigEditor, Warning, + TEXT("Non-English agent: overriding TTS model to eleven_turbo_v2_5 (API constraint).")); + } + } + else if (ResolvedModelID.IsEmpty()) + { + // Default for English if nothing selected + ResolvedModelID = TEXT("eleven_turbo_v2_5"); + } + } + TTSObj->SetStringField(TEXT("model_id"), ResolvedModelID); + TTSObj->SetNumberField(TEXT("stability"), Asset->Stability); + TTSObj->SetNumberField(TEXT("similarity_boost"), Asset->SimilarityBoost); + TTSObj->SetNumberField(TEXT("speed"), Asset->Speed); + + // turn + TSharedPtr TurnObj = MakeShareable(new FJsonObject()); + { + // When bDisableIdleFollowUp is on, force turn_timeout to -1 (infinite wait). + // -1 means the agent will wait indefinitely for user input. + const float EffectiveTurnTimeout = Asset->bDisableIdleFollowUp + ? -1.0f + : (Asset->TurnTimeout < 0.0f ? -1.0f : FMath::Clamp(Asset->TurnTimeout, 1.0f, 30.0f)); + TurnObj->SetNumberField(TEXT("turn_timeout"), EffectiveTurnTimeout); + } + + // conversation_config + TSharedPtr ConvConfig = MakeShareable(new FJsonObject()); + ConvConfig->SetObjectField(TEXT("agent"), AgentObj); + ConvConfig->SetObjectField(TEXT("tts"), TTSObj); + ConvConfig->SetObjectField(TEXT("turn"), TurnObj); + + // Root + TSharedPtr Root = MakeShareable(new FJsonObject()); + if (!Asset->AgentName.IsEmpty()) + { + Root->SetStringField(TEXT("name"), Asset->AgentName); + } + Root->SetObjectField(TEXT("conversation_config"), ConvConfig); + + return Root; +} + +TSharedPtr FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs::BuildEmotionToolDefinition() const +{ + // Build the set_emotion client tool definition. + // Parameters: emotion (enum), intensity (enum). + + // emotion parameter + TSharedPtr EmotionParam = MakeShareable(new FJsonObject()); + EmotionParam->SetStringField(TEXT("type"), TEXT("string")); + EmotionParam->SetStringField(TEXT("description"), TEXT("The emotion to display.")); + TArray> EmotionEnum; + for (const FString& E : {TEXT("joy"), TEXT("sadness"), TEXT("anger"), TEXT("surprise"), + TEXT("fear"), TEXT("disgust"), TEXT("neutral")}) + { + EmotionEnum.Add(MakeShareable(new FJsonValueString(E))); + } + EmotionParam->SetArrayField(TEXT("enum"), EmotionEnum); + + // intensity parameter + TSharedPtr IntensityParam = MakeShareable(new FJsonObject()); + IntensityParam->SetStringField(TEXT("type"), TEXT("string")); + IntensityParam->SetStringField(TEXT("description"), TEXT("The intensity of the emotion.")); + TArray> IntensityEnum; + for (const FString& I : {TEXT("low"), TEXT("medium"), TEXT("high")}) + { + IntensityEnum.Add(MakeShareable(new FJsonValueString(I))); + } + IntensityParam->SetArrayField(TEXT("enum"), IntensityEnum); + + // properties + TSharedPtr Properties = MakeShareable(new FJsonObject()); + Properties->SetObjectField(TEXT("emotion"), EmotionParam); + Properties->SetObjectField(TEXT("intensity"), IntensityParam); + + // required + TArray> Required; + Required.Add(MakeShareable(new FJsonValueString(TEXT("emotion")))); + Required.Add(MakeShareable(new FJsonValueString(TEXT("intensity")))); + + // parameters + TSharedPtr Parameters = MakeShareable(new FJsonObject()); + Parameters->SetStringField(TEXT("type"), TEXT("object")); + Parameters->SetObjectField(TEXT("properties"), Properties); + Parameters->SetArrayField(TEXT("required"), Required); + + // Tool definition + TSharedPtr Tool = MakeShareable(new FJsonObject()); + Tool->SetStringField(TEXT("type"), TEXT("client")); + Tool->SetStringField(TEXT("name"), TEXT("set_emotion")); + Tool->SetStringField(TEXT("description"), + TEXT("Set the character's facial expression emotion and intensity.")); + Tool->SetObjectField(TEXT("parameters"), Parameters); + + return Tool; +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs.h new file mode 100644 index 0000000..7e1da92 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs.h @@ -0,0 +1,95 @@ +// Copyright ASTERION. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "IDetailCustomization.h" + +class IDetailLayoutBuilder; + +/** + * Detail Customization for UPS_AI_ConvAgent_AgentConfig_ElevenLabs data assets. + * + * Provides: + * - Voice category: "Fetch Voices" button + STextComboBox dropdown + * - Voice category: "Fetch Models" button + STextComboBox dropdown + * - Behavior category: LLM picker with "Fetch" button + STextComboBox dropdown + * - Behavior category: Language picker dropdown (static list) + * - Identity category: "Create Agent" / "Update Agent" / "Fetch Agent" buttons + status text + * - Hidden properties: VoiceID, VoiceName, TTSModelID, LLMModel, Language (managed by dropdowns) + */ +class FPS_AI_ConvAgent_AgentConfigCustomization_ElevenLabs : public IDetailCustomization +{ +public: + static TSharedRef MakeInstance(); + + virtual void CustomizeDetails(IDetailLayoutBuilder& DetailBuilder) override; + +private: + // ── Voice picker ───────────────────────────────────────────────────────── + void OnFetchVoicesClicked(); + void OnVoiceSelected(TSharedPtr NewSelection, ESelectInfo::Type SelectInfo); + + // ── Model picker ───────────────────────────────────────────────────────── + void OnFetchModelsClicked(); + void OnModelSelected(TSharedPtr NewSelection, ESelectInfo::Type SelectInfo); + + // ── LLM picker ────────────────────────────────────────────────────────── + void OnFetchLLMsClicked(); + void OnLLMSelected(TSharedPtr NewSelection, ESelectInfo::Type SelectInfo); + + // ── Language picker ────────────────────────────────────────────────────── + void OnFetchLanguagesClicked(); + void OnLanguageSelected(TSharedPtr NewSelection, ESelectInfo::Type SelectInfo); + + // ── Agent API ──────────────────────────────────────────────────────────── + void OnCreateAgentClicked(); + void OnUpdateAgentClicked(); + void OnFetchAgentClicked(); + + // ── Helpers ────────────────────────────────────────────────────────────── + FString GetAPIKey() const; + TSharedPtr BuildAgentPayload() const; + TSharedPtr BuildEmotionToolDefinition() const; + + /** Display a status message in the Identity category. + * Color: red for errors, green for success, blue/cyan for info. */ + void SetStatusText(const FString& Text); + void SetStatusError(const FString& Text); + void SetStatusSuccess(const FString& Text); + + /** Parse ElevenLabs API error JSON and return a human-readable message. */ + static FString ParseAPIError(int32 HttpCode, const FString& ResponseBody); + + /** Retrieve the data asset being edited (first selected object). */ + class UPS_AI_ConvAgent_AgentConfig_ElevenLabs* GetEditedAsset() const; + + // ── Cached state ───────────────────────────────────────────────────────── + TArray> SelectedObjects; + + // Voice combo data + TArray> VoiceDisplayNames; + TArray VoiceIDs; + TSharedPtr VoiceComboBox; + + // Model combo data + TArray> ModelDisplayNames; + TArray ModelIDs; + TSharedPtr ModelComboBox; + + // LLM combo data + TArray> LLMDisplayNames; + TArray LLMModelIDs; + TSharedPtr LLMComboBox; + + // Language combo data + TArray> LanguageDisplayNames; + TArray LanguageCodes; + TSharedPtr LanguageComboBox; + + // Status feedback + TSharedPtr StatusTextBlock; + + // Guard: prevents infinite auto-fetch loop when PostEditChange re-triggers CustomizeDetails. + bool bAutoFetchDone = false; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgent_AgentConfigFactory_ElevenLabs.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgent_AgentConfigFactory_ElevenLabs.cpp new file mode 100644 index 0000000..811dedc --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgent_AgentConfigFactory_ElevenLabs.cpp @@ -0,0 +1,29 @@ +// Copyright ASTERION. All Rights Reserved. + +#include "PS_AI_ConvAgent_AgentConfigFactory_ElevenLabs.h" +#include "PS_AI_ConvAgent_AgentConfig_ElevenLabs.h" +#include "AssetTypeCategories.h" + +UPS_AI_ConvAgent_AgentConfigFactory_ElevenLabs::UPS_AI_ConvAgent_AgentConfigFactory_ElevenLabs() +{ + SupportedClass = UPS_AI_ConvAgent_AgentConfig_ElevenLabs::StaticClass(); + bCreateNew = true; + bEditAfterNew = true; +} + +UObject* UPS_AI_ConvAgent_AgentConfigFactory_ElevenLabs::FactoryCreateNew( + UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, + UObject* Context, FFeedbackContext* Warn) +{ + return NewObject(InParent, Class, Name, Flags); +} + +FText UPS_AI_ConvAgent_AgentConfigFactory_ElevenLabs::GetDisplayName() const +{ + return FText::FromString(TEXT("PS AI ConvAgent Agent Config (ElevenLabs)")); +} + +uint32 UPS_AI_ConvAgent_AgentConfigFactory_ElevenLabs::GetMenuCategories() const +{ + return EAssetTypeCategories::Misc; +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgent_AgentConfigFactory_ElevenLabs.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgent_AgentConfigFactory_ElevenLabs.h new file mode 100644 index 0000000..2e5e8f6 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgentEditor/Private/PS_AI_ConvAgent_AgentConfigFactory_ElevenLabs.h @@ -0,0 +1,27 @@ +// Copyright ASTERION. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Factories/Factory.h" +#include "PS_AI_ConvAgent_AgentConfigFactory_ElevenLabs.generated.h" + +/** + * Factory that lets users create PS_AI_ConvAgent_AgentConfig_ElevenLabs assets + * directly from the Content Browser (right-click → Miscellaneous). + */ +UCLASS() +class UPS_AI_ConvAgent_AgentConfigFactory_ElevenLabs : public UFactory +{ + GENERATED_BODY() + +public: + UPS_AI_ConvAgent_AgentConfigFactory_ElevenLabs(); + + virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, + FName Name, EObjectFlags Flags, UObject* Context, + FFeedbackContext* Warn) override; + + virtual FText GetDisplayName() const override; + virtual uint32 GetMenuCategories() const override; +};