[Add] Synaptic AI Pro
https://assetstore.unity.com/packages/tools/generative-ai/synaptic-ai-pro-natural-language-control-for-unity-336030
This commit is contained in:
@@ -0,0 +1,923 @@
|
||||
using System;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using Newtonsoft.Json;
|
||||
using System.Collections.Generic;
|
||||
using SynapticAIPro;
|
||||
|
||||
namespace SynapticPro
|
||||
{
|
||||
/// <summary>
|
||||
/// WebSocket client for editor mode
|
||||
/// Manages communication with MCP server
|
||||
/// </summary>
|
||||
public class NexusWebSocketClient
|
||||
{
|
||||
private ClientWebSocket webSocket;
|
||||
private CancellationTokenSource cancellationTokenSource;
|
||||
private bool isConnected = false;
|
||||
private Queue<string> messageQueue = new Queue<string>();
|
||||
private bool shouldReconnect = true;
|
||||
private int reconnectAttempts = 0;
|
||||
private const int maxReconnectAttempts = 30;
|
||||
private const int reconnectDelay = 2000; // 2 seconds between attempts
|
||||
private string serverUrl = "ws://127.0.0.1:8090";
|
||||
private const int CONNECT_TIMEOUT_SECONDS = 5;
|
||||
private readonly List<Task> backgroundTasks = new List<Task>();
|
||||
|
||||
public bool IsConnected => isConnected;
|
||||
public event Action<string> OnMessageReceived;
|
||||
public event Action OnConnected;
|
||||
public event Action OnDisconnected;
|
||||
|
||||
private static NexusWebSocketClient instance;
|
||||
public static NexusWebSocketClient Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (instance == null)
|
||||
{
|
||||
instance = new NexusWebSocketClient();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
private static void LogTaskFault(Task t, string label)
|
||||
{
|
||||
t.ContinueWith(
|
||||
x => SynLog.Warn($"[Nexus WebSocket] {label} faulted: {x.Exception?.GetBaseException().Message}"),
|
||||
TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
public async Task<bool> Connect(string url = "ws://127.0.0.1:8090")
|
||||
{
|
||||
shouldReconnect = true;
|
||||
reconnectAttempts = 0;
|
||||
|
||||
while (shouldReconnect && reconnectAttempts < maxReconnectAttempts)
|
||||
{
|
||||
try
|
||||
{
|
||||
SynLog.Info($"[Nexus WebSocket] Connecting to {url}... (Attempt {reconnectAttempts + 1})");
|
||||
|
||||
webSocket = new ClientWebSocket();
|
||||
cancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
using (var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationTokenSource.Token))
|
||||
{
|
||||
connectCts.CancelAfter(TimeSpan.FromSeconds(CONNECT_TIMEOUT_SECONDS));
|
||||
await webSocket.ConnectAsync(new Uri(url), connectCts.Token);
|
||||
}
|
||||
isConnected = true;
|
||||
reconnectAttempts = 0; // Reset on success
|
||||
|
||||
SynLog.Info("[Nexus WebSocket] Connected successfully!");
|
||||
OnConnected?.Invoke();
|
||||
|
||||
// Start message receive loop (tracked so Disconnect can await)
|
||||
var receiveTask = Task.Run(async () => await ReceiveLoop());
|
||||
LogTaskFault(receiveTask, "ReceiveLoop");
|
||||
backgroundTasks.Add(receiveTask);
|
||||
|
||||
// Start heartbeat
|
||||
var heartbeatTask = Task.Run(async () => await HeartbeatLoop());
|
||||
LogTaskFault(heartbeatTask, "HeartbeatLoop");
|
||||
backgroundTasks.Add(heartbeatTask);
|
||||
|
||||
// Notify Unity is ready
|
||||
await SendMessage(new { type = "unity_ready", version = NexusVersion.Current });
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[Nexus WebSocket] Connection failed: {e.Message}");
|
||||
isConnected = false;
|
||||
reconnectAttempts++;
|
||||
|
||||
if (reconnectAttempts < maxReconnectAttempts && shouldReconnect)
|
||||
{
|
||||
SynLog.Info($"[Nexus WebSocket] Retrying in {reconnectDelay / 1000} seconds...");
|
||||
await Task.Delay(reconnectDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task ReceiveLoop()
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
var messageBuilder = new StringBuilder();
|
||||
|
||||
try
|
||||
{
|
||||
while (webSocket.State == WebSocketState.Open)
|
||||
{
|
||||
var result = await webSocket.ReceiveAsync(
|
||||
new ArraySegment<byte>(buffer),
|
||||
cancellationTokenSource.Token
|
||||
);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Text)
|
||||
{
|
||||
// Accumulate fragments until EndOfMessage. Without this,
|
||||
// any message larger than 4096B (e.g. tool args / scene
|
||||
// dumps) gets truncated mid-chunk and fails JSON parse —
|
||||
// root cause of ESC-0102 (Win + Unity 6.3 MCP receive
|
||||
// appears to "drop" large messages).
|
||||
messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
|
||||
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
var message = messageBuilder.ToString();
|
||||
messageBuilder.Clear();
|
||||
|
||||
SynLog.Info($"[Nexus WebSocket] Received ({message.Length} chars): {message.Substring(0, System.Math.Min(message.Length, 200))}");
|
||||
|
||||
lock (messageQueue)
|
||||
{
|
||||
messageQueue.Enqueue(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[Nexus WebSocket] Receive error: {e.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isConnected = false;
|
||||
OnDisconnected?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public void ProcessMessages()
|
||||
{
|
||||
lock (messageQueue)
|
||||
{
|
||||
while (messageQueue.Count > 0)
|
||||
{
|
||||
var message = messageQueue.Dequeue();
|
||||
OnMessageReceived?.Invoke(message);
|
||||
|
||||
try
|
||||
{
|
||||
var data = JsonConvert.DeserializeObject<Dictionary<string, object>>(message);
|
||||
if (data != null && data.ContainsKey("type"))
|
||||
{
|
||||
ProcessUnityCommand(data);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[Nexus WebSocket] Message processing error: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<string, string> OnClaudeResponse; // sessionId, message
|
||||
public event Action<string> OnChatStatusUpdate; // status message
|
||||
|
||||
private void ProcessUnityCommand(Dictionary<string, object> data)
|
||||
{
|
||||
var type = data["type"].ToString();
|
||||
|
||||
if (type == "unity_operation")
|
||||
{
|
||||
var command = data.ContainsKey("command") ? data["command"].ToString() : "";
|
||||
var parameters = data.ContainsKey("parameters") ? data["parameters"] as Newtonsoft.Json.Linq.JObject : null;
|
||||
|
||||
SynLog.Info($"[Nexus WebSocket] Executing Unity command: {command}");
|
||||
|
||||
// Execute Unity operation in editor mode
|
||||
EditorApplication.delayCall += () =>
|
||||
{
|
||||
ExecuteUnityOperation(command, parameters);
|
||||
};
|
||||
}
|
||||
else if (type == "claude_response")
|
||||
{
|
||||
// Real-time response from Claude Desktop
|
||||
var responseData = data.ContainsKey("data") ? data["data"] as Newtonsoft.Json.Linq.JObject : null;
|
||||
if (responseData != null)
|
||||
{
|
||||
var message = responseData.Value<string>("message") ?? "";
|
||||
var sessionId = responseData.Value<string>("sessionId") ?? "";
|
||||
var responseType = responseData.Value<string>("responseType") ?? "response";
|
||||
|
||||
SynLog.Info($"[Nexus WebSocket] Claude response received: {message}");
|
||||
|
||||
// Fire event on main thread
|
||||
EditorApplication.delayCall += () =>
|
||||
{
|
||||
OnClaudeResponse?.Invoke(sessionId, message);
|
||||
};
|
||||
}
|
||||
}
|
||||
else if (type == "chat_initiated")
|
||||
{
|
||||
// Chat initiated notification
|
||||
var chatData = data.ContainsKey("data") ? data["data"] as Newtonsoft.Json.Linq.JObject : null;
|
||||
if (chatData != null)
|
||||
{
|
||||
var status = chatData.Value<string>("status") ?? "Processing...";
|
||||
|
||||
SynLog.Info($"[Nexus WebSocket] Chat initiated: {status}");
|
||||
|
||||
EditorApplication.delayCall += () =>
|
||||
{
|
||||
OnChatStatusUpdate?.Invoke(status);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void ExecuteUnityOperation(string command, Newtonsoft.Json.Linq.JObject parameters)
|
||||
{
|
||||
SynLog.Info($"[Nexus WebSocket] Executing Unity operation: {command}");
|
||||
SynLog.Info($"[Nexus WebSocket] Parameters: {parameters?.ToString()}");
|
||||
|
||||
var operationId = parameters?.Value<string>("operationId") ?? Guid.NewGuid().ToString();
|
||||
|
||||
try
|
||||
{
|
||||
var operation = new NexusUnityOperation
|
||||
{
|
||||
type = ConvertCommandToOperationType(command),
|
||||
parameters = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
// Convert parameters
|
||||
if (parameters != null)
|
||||
{
|
||||
foreach (var prop in parameters.Properties())
|
||||
{
|
||||
if (prop.Name == "operationId") continue;
|
||||
|
||||
var value = prop.Value;
|
||||
|
||||
if (value is Newtonsoft.Json.Linq.JObject jObj)
|
||||
{
|
||||
// Process nested objects (Vector3, etc.)
|
||||
if (jObj.ContainsKey("x") && jObj.ContainsKey("y") && jObj.ContainsKey("z"))
|
||||
{
|
||||
operation.parameters[prop.Name] = $"{jObj["x"]},{jObj["y"]},{jObj["z"]}";
|
||||
}
|
||||
else if (jObj.ContainsKey("x") && jObj.ContainsKey("y"))
|
||||
{
|
||||
operation.parameters[prop.Name] = $"{jObj["x"]},{jObj["y"]}";
|
||||
}
|
||||
else
|
||||
{
|
||||
operation.parameters[prop.Name] = value.ToString();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
operation.parameters[prop.Name] = value.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute
|
||||
string result = "";
|
||||
bool success = true;
|
||||
|
||||
// Process information retrieval commands
|
||||
switch (operation.type)
|
||||
{
|
||||
case "GET_SCENE_INFO":
|
||||
result = NexusStateInspector.GetSceneInformation();
|
||||
break;
|
||||
case "GET_CAMERA_INFO":
|
||||
result = NexusStateInspector.GetCameraInformation();
|
||||
break;
|
||||
case "GET_TERRAIN_INFO":
|
||||
result = NexusStateInspector.GetTerrainInformation();
|
||||
break;
|
||||
case "GET_LIGHTING_INFO":
|
||||
result = NexusStateInspector.GetLightingInformation();
|
||||
break;
|
||||
case "GET_MATERIAL_INFO":
|
||||
result = NexusStateInspector.GetMaterialInformation();
|
||||
break;
|
||||
case "GET_UI_INFO":
|
||||
result = NexusStateInspector.GetUIInformation();
|
||||
break;
|
||||
case "GET_PHYSICS_INFO":
|
||||
result = NexusStateInspector.GetPhysicsInformation();
|
||||
break;
|
||||
case "GET_GAMEOBJECT_DETAILS":
|
||||
var name = operation.parameters.GetValueOrDefault("name", "");
|
||||
result = NexusStateInspector.GetGameObjectDetails(name);
|
||||
break;
|
||||
case "GET_PROJECT_STATS":
|
||||
result = NexusStateInspector.GetProjectStatistics();
|
||||
break;
|
||||
default:
|
||||
// Normal operation.
|
||||
// ESC-0107 fix: previously used `.Result` which blocks the
|
||||
// main thread (delayCall) on a Task whose continuation may
|
||||
// need to repost via UnitySynchronizationContext → classic
|
||||
// SyncContext deadlock. ConfigureAwait(false) drops the
|
||||
// captured context so the continuation can run on any
|
||||
// thread; the body of ExecuteOperation is itself sync
|
||||
// for most cases (e.g. RUN_CSHARP returns immediately).
|
||||
var executor = new NexusUnityExecutor();
|
||||
result = await executor.ExecuteOperation(operation).ConfigureAwait(false);
|
||||
|
||||
// Error check
|
||||
if (result.StartsWith("Error") || result.Contains("not found") || result.Contains("failed"))
|
||||
{
|
||||
success = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 既存のMCP通信と同じフォーマットを使用
|
||||
var response = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = "operation_result",
|
||||
["id"] = operationId,
|
||||
["content"] = result,
|
||||
["data"] = new Dictionary<string, object> { ["success"] = success }
|
||||
};
|
||||
|
||||
SynLog.Info($"[Nexus WebSocket] Operation result: {result}");
|
||||
|
||||
// Send result to MCP server
|
||||
await SendMessage(response);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
var errorResponse = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = "operation_result",
|
||||
["id"] = operationId,
|
||||
["content"] = e.Message,
|
||||
["data"] = new Dictionary<string, object> { ["success"] = false }
|
||||
};
|
||||
|
||||
Debug.LogError($"[Nexus WebSocket] Operation execution error: {e.Message}\n{e.StackTrace}");
|
||||
|
||||
// Send error response to MCP server
|
||||
await SendMessage(errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
private string ConvertCommandToOperationType(string command)
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case "create_ui":
|
||||
return "CREATE_UI";
|
||||
case "create_gameobject":
|
||||
return "CREATE_GAMEOBJECT";
|
||||
case "instantiate_prefab":
|
||||
return "INSTANTIATE_PREFAB";
|
||||
case "set_transform":
|
||||
return "SET_PROPERTY";
|
||||
case "setup_camera":
|
||||
return "SETUP_CAMERA";
|
||||
case "create_particle_system":
|
||||
return "CREATE_PARTICLE_SYSTEM";
|
||||
case "setup_navmesh":
|
||||
return "SETUP_NAVMESH";
|
||||
case "create_audio_mixer":
|
||||
return "CREATE_AUDIO_MIXER";
|
||||
case "undo":
|
||||
return "UNDO";
|
||||
case "redo":
|
||||
return "REDO";
|
||||
case "get_history":
|
||||
return "GET_HISTORY";
|
||||
|
||||
// Information retrieval
|
||||
case "get_scene_info":
|
||||
return "GET_SCENE_INFO";
|
||||
case "get_camera_info":
|
||||
return "GET_CAMERA_INFO";
|
||||
case "get_terrain_info":
|
||||
return "GET_TERRAIN_INFO";
|
||||
case "get_lighting_info":
|
||||
return "GET_LIGHTING_INFO";
|
||||
case "get_material_info":
|
||||
return "GET_MATERIAL_INFO";
|
||||
case "get_ui_info":
|
||||
return "GET_UI_INFO";
|
||||
case "get_physics_info":
|
||||
return "GET_PHYSICS_INFO";
|
||||
case "get_gameobject_details":
|
||||
return "GET_GAMEOBJECT_DETAILS";
|
||||
case "list_assets":
|
||||
return "LIST_ASSETS";
|
||||
case "get_project_stats":
|
||||
return "GET_PROJECT_STATS";
|
||||
|
||||
default:
|
||||
return command.ToUpper();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendMessage(object data)
|
||||
{
|
||||
if (!isConnected || webSocket.State != WebSocketState.Open)
|
||||
{
|
||||
SynLog.Warn("[Nexus WebSocket] Cannot send message: not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(data);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
await webSocket.SendAsync(
|
||||
new ArraySegment<byte>(bytes),
|
||||
WebSocketMessageType.Text,
|
||||
true,
|
||||
cancellationTokenSource.Token
|
||||
);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[Nexus WebSocket] Send error: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HeartbeatLoop()
|
||||
{
|
||||
while (isConnected && !cancellationTokenSource.Token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Send heartbeat every 30 seconds
|
||||
await Task.Delay(30000, cancellationTokenSource.Token);
|
||||
|
||||
if (webSocket.State == WebSocketState.Open)
|
||||
{
|
||||
await SendMessage(new { type = "heartbeat", timestamp = DateTime.Now.Ticks });
|
||||
}
|
||||
else
|
||||
{
|
||||
SynLog.Warn("[Nexus WebSocket] Connection lost during heartbeat");
|
||||
isConnected = false;
|
||||
OnDisconnected?.Invoke();
|
||||
|
||||
// Attempt reconnection
|
||||
if (shouldReconnect)
|
||||
{
|
||||
var reconnectTask = Task.Run(async () => await Connect());
|
||||
LogTaskFault(reconnectTask, "HeartbeatReconnect");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (!cancellationTokenSource.Token.IsCancellationRequested)
|
||||
{
|
||||
Debug.LogError($"[Nexus WebSocket] Heartbeat error: {e.Message}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Disconnect()
|
||||
{
|
||||
shouldReconnect = false; // Disable auto-reconnect
|
||||
|
||||
if (webSocket != null && webSocket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await webSocket.CloseAsync(
|
||||
WebSocketCloseStatus.NormalClosure,
|
||||
"Closing",
|
||||
cancellationTokenSource.Token
|
||||
);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[Nexus WebSocket] Disconnect error: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
cancellationTokenSource?.Cancel();
|
||||
cancellationTokenSource?.Dispose();
|
||||
isConnected = false;
|
||||
|
||||
// Give background tasks a bounded window to unwind before disposing resources.
|
||||
if (backgroundTasks.Count > 0)
|
||||
{
|
||||
try { Task.WhenAll(backgroundTasks).Wait(TimeSpan.FromSeconds(2)); } catch { }
|
||||
backgroundTasks.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set server URL
|
||||
/// </summary>
|
||||
public void SetServerUrl(string url)
|
||||
{
|
||||
serverUrl = url;
|
||||
SynLog.Info($"[Nexus WebSocket] Server URL changed to: {url}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages WebSocket client updates
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class NexusWebSocketUpdater
|
||||
{
|
||||
static NexusWebSocketUpdater()
|
||||
{
|
||||
EditorApplication.update += Update;
|
||||
}
|
||||
|
||||
private static void Update()
|
||||
{
|
||||
NexusWebSocketClient.Instance?.ProcessMessages();
|
||||
NexusHTTPWebSocketClient.Instance?.ProcessMessages();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket client for HTTP Server connection
|
||||
/// Separate from MCP WebSocket to allow both to run simultaneously
|
||||
/// </summary>
|
||||
public class NexusHTTPWebSocketClient
|
||||
{
|
||||
private ClientWebSocket webSocket;
|
||||
private CancellationTokenSource cancellationTokenSource;
|
||||
private bool isConnected = false;
|
||||
private Queue<string> messageQueue = new Queue<string>();
|
||||
private bool shouldReconnect = true;
|
||||
private int reconnectAttempts = 0;
|
||||
private const int maxReconnectAttempts = 10;
|
||||
private const int reconnectDelay = 1000;
|
||||
private const int CONNECT_TIMEOUT_SECONDS = 5;
|
||||
private readonly List<Task> backgroundTasks = new List<Task>();
|
||||
|
||||
// Reentrancy guard. Connect() can be invoked from three paths (UI button,
|
||||
// NexusEditorMCPService HTTP auto-connect, and ReceiveLoop's finally auto-reconnect);
|
||||
// without this gate, concurrent calls clobber `webSocket`/`cancellationTokenSource`
|
||||
// and produce duplicate "Connecting/Connected/Disconnected" log spam.
|
||||
private volatile bool connectInFlight = false;
|
||||
private string serverUrl = "ws://127.0.0.1:8086";
|
||||
private int port = 8086;
|
||||
|
||||
public bool IsConnected => isConnected;
|
||||
public int Port => port;
|
||||
|
||||
private static NexusHTTPWebSocketClient instance;
|
||||
public static NexusHTTPWebSocketClient Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (instance == null)
|
||||
{
|
||||
instance = new NexusHTTPWebSocketClient();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
private static void LogTaskFault(Task t, string label)
|
||||
{
|
||||
t.ContinueWith(
|
||||
x => SynLog.Warn($"[HTTP WebSocket] {label} faulted: {x.Exception?.GetBaseException().Message}"),
|
||||
TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
public async Task<bool> Connect(int httpPort)
|
||||
{
|
||||
// Reentrancy guard — silently drop concurrent Connect calls.
|
||||
if (connectInFlight)
|
||||
{
|
||||
return isConnected;
|
||||
}
|
||||
if (isConnected && port == httpPort)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
connectInFlight = true;
|
||||
try
|
||||
{
|
||||
port = httpPort;
|
||||
serverUrl = $"ws://127.0.0.1:{port}";
|
||||
shouldReconnect = true;
|
||||
reconnectAttempts = 0;
|
||||
|
||||
while (shouldReconnect && reconnectAttempts < maxReconnectAttempts)
|
||||
{
|
||||
try
|
||||
{
|
||||
SynLog.Info($"[HTTP WebSocket] Connecting to {serverUrl}... (Attempt {reconnectAttempts + 1})");
|
||||
|
||||
var ws = new ClientWebSocket();
|
||||
ws.Options.SetRequestHeader("X-Client-Type", "unity");
|
||||
var cts = new CancellationTokenSource();
|
||||
webSocket = ws;
|
||||
cancellationTokenSource = cts;
|
||||
|
||||
using (var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token))
|
||||
{
|
||||
connectCts.CancelAfter(TimeSpan.FromSeconds(CONNECT_TIMEOUT_SECONDS));
|
||||
await ws.ConnectAsync(new Uri(serverUrl), connectCts.Token);
|
||||
}
|
||||
isConnected = true;
|
||||
reconnectAttempts = 0;
|
||||
|
||||
SynLog.Info($"[HTTP WebSocket] Connected to HTTP Server on port {port}!");
|
||||
|
||||
// Start message receive loop — snapshot ws+cts so a stale loop whose
|
||||
// fields were overwritten by a subsequent Connect bails out cleanly.
|
||||
var receiveTask = Task.Run(async () => await ReceiveLoop(ws, cts));
|
||||
LogTaskFault(receiveTask, "ReceiveLoop");
|
||||
backgroundTasks.Add(receiveTask);
|
||||
|
||||
// Notify Unity is ready
|
||||
await SendMessage(new { type = "unity_ready", version = NexusVersion.Current });
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
SynLog.Warn($"[HTTP WebSocket] Connection failed: {e.Message}");
|
||||
isConnected = false;
|
||||
reconnectAttempts++;
|
||||
|
||||
if (reconnectAttempts < maxReconnectAttempts && shouldReconnect)
|
||||
{
|
||||
await Task.Delay(reconnectDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SynLog.Warn($"[HTTP WebSocket] Could not connect after {maxReconnectAttempts} attempts");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
connectInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoop(ClientWebSocket ws, CancellationTokenSource cts)
|
||||
{
|
||||
var buffer = new byte[8192];
|
||||
var messageBuilder = new StringBuilder();
|
||||
|
||||
try
|
||||
{
|
||||
while (ws.State == WebSocketState.Open && !cts.IsCancellationRequested)
|
||||
{
|
||||
var result = await ws.ReceiveAsync(
|
||||
new ArraySegment<byte>(buffer),
|
||||
cts.Token
|
||||
);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Text)
|
||||
{
|
||||
messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
|
||||
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
var message = messageBuilder.ToString();
|
||||
messageBuilder.Clear();
|
||||
|
||||
lock (messageQueue)
|
||||
{
|
||||
messageQueue.Enqueue(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (!cts.IsCancellationRequested)
|
||||
{
|
||||
SynLog.Warn($"[HTTP WebSocket] Receive error: {e.Message}");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Only mutate shared state + auto-reconnect if this loop is STILL the active
|
||||
// session. A later Connect() overwrites webSocket/cancellationTokenSource, so
|
||||
// reference-equality on the snapshots tells us whether we're stale.
|
||||
bool stillActive = ReferenceEquals(webSocket, ws)
|
||||
&& ReferenceEquals(cancellationTokenSource, cts);
|
||||
|
||||
if (stillActive)
|
||||
{
|
||||
isConnected = false;
|
||||
}
|
||||
|
||||
if (shouldReconnect
|
||||
&& stillActive
|
||||
&& !cts.IsCancellationRequested
|
||||
&& !connectInFlight)
|
||||
{
|
||||
var reconnectTask = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(reconnectDelay);
|
||||
if (shouldReconnect && !connectInFlight)
|
||||
await Connect(port);
|
||||
});
|
||||
LogTaskFault(reconnectTask, "AutoReconnect");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ProcessMessages()
|
||||
{
|
||||
if (!isConnected) return;
|
||||
|
||||
lock (messageQueue)
|
||||
{
|
||||
while (messageQueue.Count > 0)
|
||||
{
|
||||
var message = messageQueue.Dequeue();
|
||||
|
||||
try
|
||||
{
|
||||
var data = JsonConvert.DeserializeObject<Dictionary<string, object>>(message);
|
||||
if (data != null && data.ContainsKey("type"))
|
||||
{
|
||||
ProcessOperation(data);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[HTTP WebSocket] Message processing error: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessOperation(Dictionary<string, object> data)
|
||||
{
|
||||
var type = data["type"].ToString();
|
||||
|
||||
if (type == "operation")
|
||||
{
|
||||
var operationType = data.ContainsKey("operationType") ? data["operationType"].ToString() : "";
|
||||
var operationId = data.ContainsKey("operationId") ? data["operationId"].ToString() : "";
|
||||
var parameters = data.ContainsKey("parameters") ? data["parameters"] as Newtonsoft.Json.Linq.JObject : null;
|
||||
|
||||
// Execute immediately (ProcessMessages is called from main thread via EditorApplication.update)
|
||||
_ = ExecuteOperation(operationType, operationId, parameters);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteOperation(string operationType, string operationId, Newtonsoft.Json.Linq.JObject parameters)
|
||||
{
|
||||
try
|
||||
{
|
||||
var operation = new NexusUnityOperation
|
||||
{
|
||||
type = operationType,
|
||||
parameters = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
// Convert parameters
|
||||
if (parameters != null)
|
||||
{
|
||||
foreach (var prop in parameters.Properties())
|
||||
{
|
||||
var value = prop.Value;
|
||||
if (value is Newtonsoft.Json.Linq.JObject jObj)
|
||||
{
|
||||
if (jObj.ContainsKey("x") && jObj.ContainsKey("y") && jObj.ContainsKey("z"))
|
||||
{
|
||||
operation.parameters[prop.Name] = $"{jObj["x"]},{jObj["y"]},{jObj["z"]}";
|
||||
}
|
||||
else
|
||||
{
|
||||
operation.parameters[prop.Name] = value.ToString();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
operation.parameters[prop.Name] = value?.ToString() ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute
|
||||
var executor = new NexusUnityExecutor();
|
||||
var result = await executor.ExecuteOperation(operation);
|
||||
var success = !result.StartsWith("Error") && !result.Contains("failed");
|
||||
|
||||
// Send response
|
||||
var response = new Dictionary<string, object>
|
||||
{
|
||||
["operationId"] = operationId,
|
||||
["success"] = success,
|
||||
["result"] = result
|
||||
};
|
||||
|
||||
await SendMessage(response);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
var errorResponse = new Dictionary<string, object>
|
||||
{
|
||||
["operationId"] = operationId,
|
||||
["success"] = false,
|
||||
["result"] = $"Error: {e.Message}"
|
||||
};
|
||||
|
||||
await SendMessage(errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendMessage(object data)
|
||||
{
|
||||
if (!isConnected || webSocket?.State != WebSocketState.Open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(data);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
await webSocket.SendAsync(
|
||||
new ArraySegment<byte>(bytes),
|
||||
WebSocketMessageType.Text,
|
||||
true,
|
||||
cancellationTokenSource.Token
|
||||
);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[HTTP WebSocket] Send error: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Disconnect()
|
||||
{
|
||||
shouldReconnect = false;
|
||||
isConnected = false;
|
||||
|
||||
try
|
||||
{
|
||||
if (webSocket != null && webSocket.State == WebSocketState.Open)
|
||||
{
|
||||
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)))
|
||||
{
|
||||
await webSocket.CloseAsync(
|
||||
WebSocketCloseStatus.NormalClosure,
|
||||
"Closing",
|
||||
cts.Token
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
cancellationTokenSource?.Cancel();
|
||||
cancellationTokenSource?.Dispose();
|
||||
}
|
||||
catch { }
|
||||
|
||||
cancellationTokenSource = null;
|
||||
webSocket = null;
|
||||
|
||||
if (backgroundTasks.Count > 0)
|
||||
{
|
||||
try { Task.WhenAll(backgroundTasks).Wait(TimeSpan.FromSeconds(2)); } catch { }
|
||||
backgroundTasks.Clear();
|
||||
}
|
||||
|
||||
SynLog.Info("[HTTP WebSocket] Disconnected");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user