Add bPersistentSession: keep WebSocket alive across conversation cycles

When true (default), the first StartConversation() opens the WebSocket
and EndConversation() only stops the mic/posture — the WebSocket stays
open until EndPlay. The agent remembers the full conversation context.
When false, each Start/EndConversation opens/closes the WebSocket (previous behavior).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
j.foucher 2026-03-03 12:50:56 +01:00
parent 35d217f6ec
commit d035f5410a
2 changed files with 91 additions and 16 deletions

View File

@ -51,6 +51,7 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::BeginPlay()
Sub->RegisterAgent(this); Sub->RegisterAgent(this);
} }
} }
} }
void UPS_AI_ConvAgent_ElevenLabsComponent::EndPlay(const EEndPlayReason::Type EndPlayReason) void UPS_AI_ConvAgent_ElevenLabsComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
@ -65,6 +66,17 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::EndPlay(const EEndPlayReason::Type En
} }
EndConversation(); EndConversation();
// In persistent mode EndConversation() deliberately keeps the WebSocket open.
// EndPlay is the final teardown — force-close it now.
if (bPersistentSession && WebSocketProxy)
{
bWantsReconnect = false;
bIntentionalDisconnect = true;
WebSocketProxy->Disconnect();
WebSocketProxy = nullptr;
}
Super::EndPlay(EndPlayReason); Super::EndPlay(EndPlayReason);
} }
@ -257,8 +269,26 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::StartConversation()
NetConversatingPlayer = PC; NetConversatingPlayer = PC;
NetConversatingPawn = PC ? PC->GetPawn() : nullptr; NetConversatingPawn = PC ? PC->GetPawn() : nullptr;
} }
// In persistent mode the WebSocket was already opened by the first StartConversation.
// Reuse the existing connection — only set up conversation state.
if (bPersistentSession && IsConnected())
{
// WebSocket already alive — just set up conversation state (posture, etc.).
ApplyConversationPosture();
OnAgentConnected.Broadcast(WebSocketProxy->GetConversationInfo());
// Auto-start listening if configured (same as HandleConnected).
if (bAutoStartListening && TurnMode == EPS_AI_ConvAgent_TurnMode_ElevenLabs::Server)
{
StartListening();
}
}
else
{
StartConversation_Internal(); StartConversation_Internal();
} }
}
else else
{ {
// Client: route through InteractionComponent relay (clients can't call // Client: route through InteractionComponent relay (clients can't call
@ -319,9 +349,12 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::EndConversation()
{ {
if (GetOwnerRole() == ROLE_Authority) if (GetOwnerRole() == ROLE_Authority)
{ {
// Cancel any pending reconnection. // Cancel any pending reconnection (ephemeral mode only — persistent keeps reconnecting).
if (!bPersistentSession)
{
bWantsReconnect = false; bWantsReconnect = false;
ReconnectAttemptCount = 0; ReconnectAttemptCount = 0;
}
StopListening(); StopListening();
// ISSUE-4: StopListening() may set bWaitingForAgentResponse=true (normal turn end path). // ISSUE-4: StopListening() may set bWaitingForAgentResponse=true (normal turn end path).
@ -330,12 +363,16 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::EndConversation()
bWaitingForAgentResponse = false; bWaitingForAgentResponse = false;
StopAgentAudio(); StopAgentAudio();
// In persistent mode, keep the WebSocket open — only manage conversation state.
if (!bPersistentSession)
{
if (WebSocketProxy) if (WebSocketProxy)
{ {
bIntentionalDisconnect = true; bIntentionalDisconnect = true;
WebSocketProxy->Disconnect(); WebSocketProxy->Disconnect();
WebSocketProxy = nullptr; WebSocketProxy = nullptr;
} }
}
// Reset replicated state so other players can talk to this NPC. // Reset replicated state so other players can talk to this NPC.
bNetIsConversing = false; bNetIsConversing = false;
@ -1633,25 +1670,53 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ServerRequestConversation_Implementat
// Update NPC posture on the server (OnRep never fires on Authority). // Update NPC posture on the server (OnRep never fires on Authority).
ApplyConversationPosture(); ApplyConversationPosture();
// In persistent mode the WebSocket is already open — skip reconnection.
if (bPersistentSession && IsConnected())
{
// Notify the requesting client that conversation started (normally done in HandleConnected).
if (NetConversatingPawn)
{
if (auto* Relay = NetConversatingPawn->FindComponentByClass<UPS_AI_ConvAgent_InteractionComponent>())
{
Relay->ClientRelayConversationStarted(GetOwner(), WebSocketProxy->GetConversationInfo());
}
}
// Auto-start listening if configured.
if (bAutoStartListening && TurnMode == EPS_AI_ConvAgent_TurnMode_ElevenLabs::Server)
{
StartListening();
}
}
else
{
StartConversation_Internal(); StartConversation_Internal();
} }
}
void UPS_AI_ConvAgent_ElevenLabsComponent::ServerReleaseConversation_Implementation() void UPS_AI_ConvAgent_ElevenLabsComponent::ServerReleaseConversation_Implementation()
{ {
// Cancel any pending reconnection. // Cancel any pending reconnection (ephemeral mode only).
if (!bPersistentSession)
{
bWantsReconnect = false; bWantsReconnect = false;
ReconnectAttemptCount = 0; ReconnectAttemptCount = 0;
}
StopListening(); StopListening();
bWaitingForAgentResponse = false; bWaitingForAgentResponse = false;
StopAgentAudio(); StopAgentAudio();
// In persistent mode, keep the WebSocket open.
if (!bPersistentSession)
{
if (WebSocketProxy) if (WebSocketProxy)
{ {
bIntentionalDisconnect = true; bIntentionalDisconnect = true;
WebSocketProxy->Disconnect(); WebSocketProxy->Disconnect();
WebSocketProxy = nullptr; WebSocketProxy = nullptr;
} }
}
// Clear posture before nullifying the pawn pointer (ApplyConversationPosture // Clear posture before nullifying the pawn pointer (ApplyConversationPosture
// uses NetConversatingPawn to guard against clearing someone else's target). // uses NetConversatingPawn to guard against clearing someone else's target).

View File

@ -129,6 +129,16 @@ public:
meta = (ToolTip = "Turn-taking mode.\n- Server VAD: ElevenLabs detects end-of-speech automatically (hands-free).\n- Client Controlled: You call StartListening/StopListening manually (push-to-talk).")) meta = (ToolTip = "Turn-taking mode.\n- Server VAD: ElevenLabs detects end-of-speech automatically (hands-free).\n- Client Controlled: You call StartListening/StopListening manually (push-to-talk)."))
EPS_AI_ConvAgent_TurnMode_ElevenLabs TurnMode = EPS_AI_ConvAgent_TurnMode_ElevenLabs::Server; EPS_AI_ConvAgent_TurnMode_ElevenLabs TurnMode = EPS_AI_ConvAgent_TurnMode_ElevenLabs::Server;
/** Keep the WebSocket open across multiple StartConversation / EndConversation cycles.
* When true, the first StartConversation opens the WebSocket and EndConversation only
* stops the microphone and resets posture the WebSocket stays alive until EndPlay.
* The agent remembers the full conversation context between interactions.
* When false (ephemeral), each StartConversation opens a fresh WebSocket session
* and EndConversation closes it. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|ElevenLabs",
meta = (ToolTip = "Keep the WebSocket open across conversation cycles.\n- True: agent remembers the full conversation between interactions.\n- False: each StartConversation opens a new session."))
bool bPersistentSession = true;
/** Automatically open the microphone as soon as the WebSocket connection is established. Only applies in Server VAD mode. In Client (push-to-talk) mode, you must call StartListening manually. */ /** Automatically open the microphone as soon as the WebSocket connection is established. Only applies in Server VAD mode. In Client (push-to-talk) mode, you must call StartListening manually. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|ElevenLabs", UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI ConvAgent|ElevenLabs",
meta = (ToolTip = "Auto-open the microphone when the conversation starts.\nOnly applies in Server VAD mode. In push-to-talk mode, call StartListening() manually.")) meta = (ToolTip = "Auto-open the microphone when the conversation starts.\nOnly applies in Server VAD mode. In push-to-talk mode, call StartListening() manually."))