diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 5724cae..225c43f 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,5 +1,20 @@ # PS_AI_Agent — Notes pour Claude +## Workflow règles + +**TOUJOURS demander avant de lancer un changement de code.** Avant d'appeler Edit/Write/sed sur un fichier source : +1. Expliquer ce qu'on s'apprête à faire (quel fichier, quelle logique, quel impact) +2. Attendre la confirmation explicite de l'utilisateur +3. Seulement après → effectuer le changement + +Cela s'applique à : modifications de code (.cpp/.h), refactors, renames bulk, ajouts de nouvelles fonctions. +Cela ne s'applique PAS à : lecture de fichiers, recherches, git status/log/diff, builds, questions diagnostiques. + +Exceptions où on peut agir directement : +- L'utilisateur dit explicitement "fais-le" / "go" / "lance" / "commit" +- L'utilisateur demande explicitement un changement précis ("renomme X en Y", "ajoute cette fonction") +- Annulation / revert demandé par l'utilisateur + ## Plugins dans ce repo ### PS_AI_ConvAgent diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp index 65701ca..91001be 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp @@ -587,8 +587,10 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor( } else if (PerceivedActors.Num() > 0) { - UE_LOG(LogPS_AI_Behavior, Warning, - TEXT("[%s] GetHighestThreatActor: perceived %d actors but ALL were filtered out (myTeam=0x%02X). Check TeamIds and attitude."), + // Normal case: civilian surrounded by non-hostile actors, or disguised enemy + // seeing allies. Only log at Verbose — not an issue. + UE_LOG(LogPS_AI_Behavior, Verbose, + TEXT("[%s] GetHighestThreatActor: perceived %d actors, none hostile (myTeam=0x%02X)."), *Owner->GetName(), PerceivedActors.Num(), AIC ? AIC->GetGenericTeamId().GetId() : 255); } 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 index a97e9d4..97292d8 100644 Binary files a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Content/Agents/Martin.uasset 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/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 e5f3f52..3a7e0bc 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 @@ -101,10 +101,9 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::TickComponent(float DeltaTime, ELevel { Super::TickComponent(DeltaTime, TickType, ThisTickFunction); - // ── ForceDisableConversation blend-out monitoring ───────────────────── - // After ForceDisableConversation(), sub-components are blending their - // CurrentActiveAlpha to 0. Once all are at (near) zero, fire OnReadyForAction - // so the game can start the physical action. + // ── Conversation disable: blend-out monitoring ───────────────────────── + // After ForceDisableConversation(), sub-components blend their CurrentActiveAlpha + // to 0. Once all near zero (or timeout), clear the wait flag. if (bWaitingForBlendOut) { BlendOutElapsedTime += DeltaTime; @@ -139,23 +138,33 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::TickComponent(float DeltaTime, ELevel bWaitingForBlendOut = false; BlendOutElapsedTime = 0.0f; UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, - TEXT("ForceDisableConversation: all components blended to neutral — firing OnReadyForAction for action '%s'."), - *PendingActionName); - OnReadyForAction.Broadcast(PendingActionName); + TEXT("ForceDisableConversation: all components blended to neutral.")); } else if (bTimedOut) { // Safety timeout — some component didn't reach alpha 0 in time. - // Fire OnReadyForAction anyway to avoid blocking the game action forever. bWaitingForBlendOut = false; BlendOutElapsedTime = 0.0f; UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning, - TEXT("ForceDisableConversation: blend-out timed out after %.1fs — not all components at neutral. Firing OnReadyForAction for action '%s' anyway."), - BlendOutTimeoutSeconds, *PendingActionName); - OnReadyForAction.Broadcast(PendingActionName); + TEXT("ForceDisableConversation: blend-out timed out after %.1fs — not all components at neutral."), + BlendOutTimeoutSeconds); } } + // ── Action flow: fire OnReadyForAction when ready ────────────────────── + // An action was requested via OnAgentActionRequested. Fire OnReadyForAction + // as soon as the agent is ready: + // - If ForceDisableConversation was called, wait for blend-out to complete. + // - Otherwise, fire on the next tick (quasi-immediate). + if (!PendingActionName.IsEmpty() && !bWaitingForBlendOut) + { + const FString ActionToFire = PendingActionName; + PendingActionName.Empty(); + UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, + TEXT("OnReadyForAction: firing for action '%s'."), *ActionToFire); + OnReadyForAction.Broadcast(ActionToFire); + } + // Response timeout: if the server hasn't started generating within ResponseTimeoutSeconds // after the user stopped speaking, notify Blueprint so it can react (e.g. show "try again"). if (bWaitingForAgentResponse && ResponseTimeoutSeconds > 0.0f && TurnEndTime > 0.0) @@ -400,7 +409,7 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::TickComponent(float DeltaTime, ELevel // ───────────────────────────────────────────────────────────────────────────── void UPS_AI_ConvAgent_ElevenLabsComponent::StartConversation() { - if (bConversationDisabledByAction) + if (bConversationForceDisabled) { UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning, TEXT("StartConversation: blocked — conversation disabled by ForceDisableConversation(). Call ForceEnableConversation() first.")); @@ -492,6 +501,9 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::StartConversation_Internal() void UPS_AI_ConvAgent_ElevenLabsComponent::EndConversation() { + // Clear any pending listen request so it doesn't leak across sessions. + bPendingStartListening = false; + if (GetOwnerRole() == ROLE_Authority) { // Standalone / listen-server: leave via the local player controller. @@ -525,7 +537,12 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::StartListening() const bool bEffectivelyConnected = IsConnected() || (GetOwnerRole() != ROLE_Authority && bNetIsConversing); if (!bEffectivelyConnected) { - UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning, TEXT("StartListening: not connected (IsConnected=%s bNetIsConversing=%s Role=%d)."), + // Not ready yet — defer until HandleConnected fires. + // Prevents silent audio loss when StartListening is called synchronously + // after StartConversation (async WebSocket handshake). + bPendingStartListening = true; + UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, + TEXT("StartListening deferred: WebSocket not connected yet (IsConnected=%s bNetIsConversing=%s Role=%d). Will open mic on connect."), IsConnected() ? TEXT("true") : TEXT("false"), bNetIsConversing ? TEXT("true") : TEXT("false"), static_cast(GetOwnerRole())); @@ -621,6 +638,10 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::StartListening() void UPS_AI_ConvAgent_ElevenLabsComponent::StopListening() { + // Also cancel any pending-start request (e.g. StartListening was deferred + // before WS connect and the user now wants to cancel). + bPendingStartListening = false; + if (!bIsListening) return; bIsListening = false; @@ -781,21 +802,19 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::InterruptAgent() // ForceDisableConversation / ForceEnableConversation // ───────────────────────────────────────────────────────────────────────────── -void UPS_AI_ConvAgent_ElevenLabsComponent::ForceDisableConversation(const FString& ActionName) +void UPS_AI_ConvAgent_ElevenLabsComponent::ForceDisableConversation() { - if (bConversationDisabledByAction) + if (bConversationForceDisabled) { UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning, - TEXT("ForceDisableConversation: already disabled (pending action: %s)."), *PendingActionName); + TEXT("ForceDisableConversation: already disabled.")); return; } UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, - TEXT("ForceDisableConversation: shutting down conversation for action '%s', blending to neutral."), *ActionName); + TEXT("ForceDisableConversation: shutting down conversation, blending to neutral.")); - PendingActionName = ActionName; - - bConversationDisabledByAction = true; + bConversationForceDisabled = true; bWaitingForBlendOut = true; BlendOutElapsedTime = 0.0f; @@ -856,7 +875,7 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ForceDisableConversation(const FStrin void UPS_AI_ConvAgent_ElevenLabsComponent::ForceEnableConversation() { - if (!bConversationDisabledByAction) + if (!bConversationForceDisabled) { UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Warning, TEXT("ForceEnableConversation: not currently disabled.")); @@ -866,7 +885,7 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ForceEnableConversation() UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, TEXT("ForceEnableConversation: conversation re-enabled.")); - bConversationDisabledByAction = false; + bConversationForceDisabled = false; bWaitingForBlendOut = false; BlendOutElapsedTime = 0.0f; @@ -1069,7 +1088,14 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::HandleConnected(const FPS_AI_ConvAgen { StartListening(); } - + // Consume any pending listen request deferred before WebSocket was ready. + else if (bPendingStartListening) + { + bPendingStartListening = false; + UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, + TEXT("HandleConnected: consuming pending StartListening request.")); + StartListening(); + } } void UPS_AI_ConvAgent_ElevenLabsComponent::HandleDisconnected(int32 StatusCode, const FString& Reason) @@ -1451,6 +1477,10 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::HandleClientToolCall(const FPS_AI_Con TEXT("[T+%.2fs] Agent action requested: %s"), T, *ActionName); } + // Track the pending action so OnReadyForAction can fire with the same name. + // If the user synchronously calls ForceDisableConversation() in their handler, + // OnReadyForAction will wait for blend-out. Otherwise it fires on next tick. + PendingActionName = ActionName; OnAgentActionRequested.Broadcast(ActionName); // Auto-respond so the agent can continue speaking. @@ -2100,7 +2130,7 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ServerJoinConversation_Implementation if (!Pawn) return; // Block join while force-disabled (ForceDisableConversation active). - if (bConversationDisabledByAction) + if (bConversationForceDisabled) { UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, TEXT("[NET] ServerJoinConversation: blocked — conversation disabled by ForceDisableConversation().")); @@ -2367,6 +2397,14 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::ClientConversationStarted_Implementat { StartListening(); } + // Consume any pending listen request deferred before conversation was ready. + else if (bPendingStartListening) + { + bPendingStartListening = false; + UE_LOG(LogPS_AI_ConvAgent_ElevenLabs, Log, + TEXT("[Client] Consuming pending StartListening request.")); + StartListening(); + } } void UPS_AI_ConvAgent_ElevenLabsComponent::ClientConversationFailed_Implementation( @@ -2745,12 +2783,13 @@ void UPS_AI_ConvAgent_ElevenLabsComponent::DrawDebugHUD() const bWantsReconnect ? TEXT(" (ACTIVE)") : TEXT(""))); // ForceDisable state - if (bConversationDisabledByAction || bWaitingForBlendOut) + if (bConversationForceDisabled || bWaitingForBlendOut) { const FColor DisableColor = FColor::Orange; + const FString PendingStr = PendingActionName.IsEmpty() ? TEXT("") : PendingActionName; GEngine->AddOnScreenDebugMessage(BaseKey + 9, DisplayTime, DisableColor, - FString::Printf(TEXT(" FORCE DISABLED — action: '%s' blendOut: %s (%.1fs)"), - *PendingActionName, + FString::Printf(TEXT(" FORCE DISABLED — pending action: '%s' blendOut: %s (%.1fs)"), + *PendingStr, bWaitingForBlendOut ? TEXT("WAITING") : TEXT("DONE"), BlendOutElapsedTime)); 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 66ba3ac..a664534 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 @@ -548,25 +548,21 @@ public: void InterruptAgent(); /** - * Cleanly disable conversation on this agent so the NPC can switch to game AI. + * Cleanly disable conversation on this agent. * Ends the WebSocket connection, stops all audio, and begins blending all * visual components (gaze, lip sync, facial expression, body expression) * back to their neutral state. While disabled, the InteractionComponent * cannot auto-restart a conversation with this agent. * - * When all components have blended to neutral, fires OnReadyForAction. + * This function ONLY handles the conversation master-switch. It has no + * knowledge of action/reaction flow — if an action was pending via + * OnAgentActionRequested, OnReadyForAction will fire automatically once + * blend-out completes. If no action is pending, nothing else happens. + * * Call ForceEnableConversation() later to allow conversation again. - * - * Typical usage from Blueprint (OnAgentActionRequested): - * 1. ForceDisableConversation("flee") - * 2. Wait for OnReadyForAction — receives the same ActionName - * 3. Play action montage based on ActionName - * 4. When done, call ForceEnableConversation() - * - * @param ActionName The action to perform once neutral (passed through to OnReadyForAction). */ UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs") - void ForceDisableConversation(const FString& ActionName); + void ForceDisableConversation(); /** * Re-enable conversation after a ForceDisableConversation() call. @@ -612,7 +608,7 @@ public: /** True while conversation is force-disabled (ForceDisableConversation was called). * While disabled, StartConversation and ServerJoinConversation are blocked. */ UFUNCTION(BlueprintPure, Category = "ASTERION|PS_AI_ConvAgent|ElevenLabs") - bool IsConversationDisabled() const { return bConversationDisabledByAction; } + bool IsConversationDisabled() const { return bConversationForceDisabled; } /** True while audio is being pre-buffered (playback hasn't started yet). * Used by the LipSync component to pause viseme queue consumption. */ @@ -706,24 +702,33 @@ private: UPROPERTY() USoundWaveProcedural* ProceduralSoundWave = nullptr; - // ── ForceDisableConversation state ─────────────────────────────────────── + // ── Conversation disable state ─────────────────────────────────────────── // Set by ForceDisableConversation(), cleared by ForceEnableConversation(). // While true, StartConversation() and ServerJoinConversation() are blocked. - bool bConversationDisabledByAction = false; - // True while waiting for all visual components to blend back to neutral. - // Monitored in TickComponent — fires OnReadyForAction when complete. + bool bConversationForceDisabled = false; + // True while waiting for all visual components to blend back to neutral + // after ForceDisableConversation(). Monitored in TickComponent. bool bWaitingForBlendOut = false; - // The action name passed to ForceDisableConversation(), forwarded to OnReadyForAction. - FString PendingActionName; - // Accumulated time waiting for blend-out. Safety timeout fires OnReadyForAction + // Accumulated time waiting for blend-out. Safety timeout to unblock // if components haven't reached neutral within this limit. float BlendOutElapsedTime = 0.0f; static constexpr float BlendOutTimeoutSeconds = 5.0f; + // ── Action flow state (independent from disable state) ─────────────────── + // Set when OnAgentActionRequested is broadcasted (LLM tool call). + // Cleared when OnReadyForAction is broadcasted. + // - If conversation is being disabled, OnReadyForAction waits for blend-out. + // - If not, OnReadyForAction fires on the next tick (quasi-immediate). + FString PendingActionName; + // ── State ───────────────────────────────────────────────────────────────── // Atomic: read from WASAPI background thread (OnMicrophoneDataCaptured), written from game thread. std::atomic bIsListening{false}; std::atomic bAgentSpeaking{false}; + // Set when StartListening() is called before the WebSocket is connected. + // Consumed in HandleConnected() to open the microphone automatically once ready. + // Cleared by StopListening() or EndConversation() to cancel the pending request. + bool bPendingStartListening = false; // True from the first agent_chat_response_part until the first audio chunk arrives. // Used to block StartListening() while the server is processing the previous turn. // Atomic: defensive — currently game-thread only, but documents thread-safety contract.