From d396f394ab2f343962ad9f0a64fcdd7e5395101a Mon Sep 17 00:00:00 2001 From: jyelon Date: Fri, 17 Apr 2026 06:46:18 -0400 Subject: [PATCH] Sequences are now implemented in UE Wingman --- .../Source/UEWingman/Handlers/Sequence.h | 40 ++++++++ .../Source/UEWingman/Private/WingServer.cpp | 96 +++++++++++++------ .../Source/UEWingman/Public/WingServer.h | 7 +- 3 files changed, 113 insertions(+), 30 deletions(-) create mode 100644 Plugins/UEWingman/Source/UEWingman/Handlers/Sequence.h diff --git a/Plugins/UEWingman/Source/UEWingman/Handlers/Sequence.h b/Plugins/UEWingman/Source/UEWingman/Handlers/Sequence.h new file mode 100644 index 00000000..c9cfe38e --- /dev/null +++ b/Plugins/UEWingman/Source/UEWingman/Handlers/Sequence.h @@ -0,0 +1,40 @@ +#pragma once + +#include "CoreMinimal.h" +#include "WingServer.h" +#include "WingBasics.h" +#include "Sequence.generated.h" + + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS() +class UWing_Sequence : public UWingHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(EditAnywhere, meta=(Description= + "Array of subcommand JSON objects to execute in order. Each must contain 'command' and its parameters.")) + FWingJsonArray Subcommands; + + virtual void Register() override + { + UWingServer::AddHandler(this, + TEXT("Execute multiple commands in one request. Each subcommand produces its own content block in the response. " + "Nested Sequence commands are not allowed.")); + } + virtual void Handle() override + { + // The actual code that implements Sequence is hardwired into + // WingServer. Because of that, this handler is never actually called + // under normal conditions. The handler exists for two reasons: to + // provide documentation, and also to catch the case where somebody + // nests a sequence inside another sequence (WingServer doesn't catch + // that). + // + WingOut::Stdout.Print(TEXT("ERROR: Sequence inside a Sequence is not allowed.\n")); + } +}; diff --git a/Plugins/UEWingman/Source/UEWingman/Private/WingServer.cpp b/Plugins/UEWingman/Source/UEWingman/Private/WingServer.cpp index 17369eed..9f9cf5bd 100644 --- a/Plugins/UEWingman/Source/UEWingman/Private/WingServer.cpp +++ b/Plugins/UEWingman/Source/UEWingman/Private/WingServer.cpp @@ -170,6 +170,70 @@ TStatId UWingServer::GetStatId() const // ============================================================ FString UWingServer::HandleRequest(const FString& Line) +{ + // Parse the request as JSON before doing anything else. + TSharedPtr Value; + TSharedRef> Reader = TJsonReaderFactory<>::Create(Line); + if (!FJsonSerializer::Deserialize(Reader, Value)) + return PackageResponses({TEXT("Invalid Json")}); + + const TSharedPtr* RequestPtr = nullptr; + if (!Value->TryGetObject(RequestPtr)) + return PackageResponses({TEXT("Json must be an object")}); + TSharedPtr Request = *RequestPtr; + + FString Command; + Request->TryGetStringField(TEXT("command"), Command); + if (Command == TEXT("Sequence")) + { + const TArray>* Subcommands = nullptr; + if (!Request->TryGetArrayField(TEXT("subcommands"), Subcommands)) + return PackageResponses({TEXT("Sequence requires a 'subcommands' array.")}); + + TArray Responses; + Responses.Reserve(Subcommands->Num()); + for (const TSharedPtr& Sub : *Subcommands) + { + const TSharedPtr* SubObjPtr = nullptr; + if (!Sub->TryGetObject(SubObjPtr)) + Responses.Add(TEXT("Subcommand must be a JSON object.")); + else + Responses.Add(HandleJsonRequest(*SubObjPtr)); + } + return PackageResponses(Responses); + } + + return PackageResponses({HandleJsonRequest(Request)}); +} + +FString UWingServer::PackageResponses(const TArray& Responses) +{ + TArray> Blocks; + Blocks.Reserve(Responses.Num()); + for (const FString& Response : Responses) + { + // Unreal's JSON writer terminates string serialization at the first + // embedded null byte rather than escaping it, which would silently + // truncate output. Sanitize null bytes to spaces. + FString Sanitized = Response; + for (int32 i = 0; i < Sanitized.Len(); ++i) + { + if (Sanitized[i] == TEXT('\0')) Sanitized[i] = TEXT(' '); + } + + TSharedPtr Block = MakeShared(); + Block->SetStringField(TEXT("type"), TEXT("text")); + Block->SetStringField(TEXT("text"), Sanitized); + Blocks.Add(MakeShared(Block)); + } + + FString OutJson; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&OutJson); + FJsonSerializer::Serialize(Blocks, Writer); + return OutJson; +} + +FString UWingServer::HandleJsonRequest(TSharedPtr Request) { LogCapture.CapturedErrors.Empty(); LogCapture.bEnabled = true; @@ -178,7 +242,7 @@ FString UWingServer::HandleRequest(const FString& Line) bSuggestHandlerHelp = false; LastHandler = nullptr; - TryCallHandler(Line); + TryCallHandler(Request); Notifier.SendNotifications(); LogCapture.bEnabled = false; @@ -202,37 +266,11 @@ FString UWingServer::HandleRequest(const FString& Line) } FString Result = WingOut::StdoutBuffer.ToString(); WingOut::StdoutBuffer.Reset(); - for (int32 i = 0; i < Result.Len(); ++i) - { - if (Result[i] == TEXT('\0')) Result[i] = TEXT(' '); - } - - // Wrap the text in an MCP content-block array: [{"type":"text","text":"..."}] - TSharedPtr Block = MakeShared(); - Block->SetStringField(TEXT("type"), TEXT("text")); - Block->SetStringField(TEXT("text"), Result); - - TArray> Blocks; - Blocks.Add(MakeShared(Block)); - - FString OutJson; - TSharedRef> Writer = TJsonWriterFactory<>::Create(&OutJson); - FJsonSerializer::Serialize(Blocks, Writer); - return OutJson; + return Result; } -void UWingServer::TryCallHandler(const FString &Line) +void UWingServer::TryCallHandler(TSharedPtr Request) { - // Turn the request string into a JSON tree. - TSharedPtr Request; - TSharedRef> Reader = TJsonReaderFactory<>::Create(Line); - FJsonSerializer::Deserialize(Reader, Request); - if (!Request.IsValid()) - { - WingOut::Stdout.Printf(TEXT("Request is not valid JSON")); - return; - } - // Extract the command from the request. FString Command; if (!Request->TryGetStringField(TEXT("command"), Command)) diff --git a/Plugins/UEWingman/Source/UEWingman/Public/WingServer.h b/Plugins/UEWingman/Source/UEWingman/Public/WingServer.h index 34169251..bdcad4ef 100644 --- a/Plugins/UEWingman/Source/UEWingman/Public/WingServer.h +++ b/Plugins/UEWingman/Source/UEWingman/Public/WingServer.h @@ -11,6 +11,7 @@ #include "WingServer.generated.h" class FSocket; +class FJsonObject; /** * UWingServer — editor subsystem that listens on a TCP socket and dispatches @@ -61,6 +62,9 @@ public: static void AddHandler(UObject* Obj, const FString& Name, UObject* Config, EWingHandlerKind Kind, UClass* FactoryClass, const FString& Documentation); static const TArray& AllHandlers() { return GWingServer->WingHandlerRegistry; } + /** Package a list of response texts into a single serialized JSON content-block array. */ + static FString PackageResponses(const TArray& Responses); + private: static UWingServer* GWingServer; @@ -78,7 +82,8 @@ private: // Handle a complete JSON line and return the response JSON FString HandleRequest(const FString& Line); - void TryCallHandler(const FString &Line); + FString HandleJsonRequest(TSharedPtr Request); + void TryCallHandler(TSharedPtr Request); // ----- TCP server ----- FSocket* ListenSocket = nullptr;