@adiholick/nvstudio-mcp 2.2.1 → 2.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +775 -141
- package/dist/roblox-bridge.js +442 -86
- package/dist/task-queue.js +57 -6
- package/dist/ws-gateway.js +579 -0
- package/package.json +4 -2
- package/public/index.html +16 -2
- package/studio-plugin/Main.server.lua +484 -53
- package/studio-plugin/get_properties.lua +25 -2
- package/studio-plugin/nvstudio_mcp.rbxmx +513 -57
- package/studio-plugin/script_grep.lua +4 -2
|
@@ -407,9 +407,32 @@ end
|
|
|
407
407
|
tryProp(prop)
|
|
408
408
|
end
|
|
409
409
|
|
|
410
|
-
-- Attributes & Tags
|
|
410
|
+
-- Attributes & Tags (sanitasi tipe non-JSON agar tidak throw error di JSONEncode)
|
|
411
411
|
pcall(function()
|
|
412
|
-
|
|
412
|
+
local rawAttrs = targetInstance:GetAttributes()
|
|
413
|
+
local safeAttrs = {}
|
|
414
|
+
for k, val in pairs(rawAttrs) do
|
|
415
|
+
if typeof(val) == "Vector3" then
|
|
416
|
+
safeAttrs[k] = {math.round(val.X*100)/100, math.round(val.Y*100)/100, math.round(val.Z*100)/100}
|
|
417
|
+
elseif typeof(val) == "Color3" then
|
|
418
|
+
safeAttrs[k] = {math.round(val.R * 255), math.round(val.G * 255), math.round(val.B * 255)}
|
|
419
|
+
elseif typeof(val) == "CFrame" then
|
|
420
|
+
safeAttrs[k] = tostring(val)
|
|
421
|
+
elseif typeof(val) == "EnumItem" then
|
|
422
|
+
safeAttrs[k] = val.Name
|
|
423
|
+
elseif typeof(val) == "UDim2" then
|
|
424
|
+
safeAttrs[k] = {val.X.Scale, val.X.Offset, val.Y.Scale, val.Y.Offset}
|
|
425
|
+
elseif typeof(val) == "Instance" then
|
|
426
|
+
safeAttrs[k] = val:GetFullName()
|
|
427
|
+
elseif typeof(val) == "DateTime" then
|
|
428
|
+
safeAttrs[k] = val:ToIsoDate()
|
|
429
|
+
elseif typeof(val) == "string" or typeof(val) == "number" or typeof(val) == "boolean" then
|
|
430
|
+
safeAttrs[k] = val
|
|
431
|
+
else
|
|
432
|
+
safeAttrs[k] = tostring(val)
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
props.Attributes = safeAttrs
|
|
413
436
|
end)
|
|
414
437
|
pcall(function()
|
|
415
438
|
props.Tags = CollectionService:GetTags(targetInstance)
|
|
@@ -488,10 +511,10 @@ end
|
|
|
488
511
|
<BinaryString name="Tags"></BinaryString>
|
|
489
512
|
<token name="RunContext">0</token>
|
|
490
513
|
<ProtectedString name="Source"><![CDATA[-- ══════════════════════════════════════════════════════════════════════
|
|
491
|
-
-- NVStudio MCP Plugin v2.
|
|
492
|
-
-- Arsitektur:
|
|
493
|
-
-- Plugin auto-connect saat dimuat,
|
|
494
|
-
--
|
|
514
|
+
-- NVStudio MCP Plugin v2.2.2
|
|
515
|
+
-- Arsitektur: Dual-Mode WebSocket Duplex (Port 3055/ws) + Fallback Long-Polling
|
|
516
|
+
-- Plugin auto-connect saat dimuat via WebSocket native, dengan fallback otomatis
|
|
517
|
+
-- ke HTTP stream jika WebSocket tidak tersedia di environment Roblox Studio ini.
|
|
495
518
|
-- ══════════════════════════════════════════════════════════════════════
|
|
496
519
|
|
|
497
520
|
local HttpService = game:GetService("HttpService")
|
|
@@ -502,6 +525,8 @@ local BASE_URL = "http://localhost:3055"
|
|
|
502
525
|
local STREAM_URL = BASE_URL .. "/api/stream" -- SSE long-poll (blocking GET)
|
|
503
526
|
local RESPONSE_URL = BASE_URL .. "/api/response"
|
|
504
527
|
local PING_URL = BASE_URL .. "/api/ping"
|
|
528
|
+
local WS_URL = "ws://localhost:3055/ws"
|
|
529
|
+
local WS_URL_IP = "ws://127.0.0.1:3055/ws"
|
|
505
530
|
|
|
506
531
|
-- ID unik per sesi Studio
|
|
507
532
|
local STUDIO_ID = HttpService:GenerateGUID(false)
|
|
@@ -523,18 +548,55 @@ local ctx = {
|
|
|
523
548
|
getInstanceFromPath = nil -- akan di-bind di bawah
|
|
524
549
|
}
|
|
525
550
|
local MAX_LOGS = 50
|
|
526
|
-
local isRunning = false -- apakah loop aktif
|
|
551
|
+
local isRunning = false -- apakah loop/koneksi aktif
|
|
527
552
|
local isManualDisconnect = false
|
|
528
553
|
local isClickDebounce = false
|
|
529
554
|
local mcpAgentActive = false -- apakah Antigravity sedang konek ke MCP
|
|
530
555
|
local serverReachable = false -- apakah server Node.js reachable
|
|
556
|
+
local activeWsWrapper = nil
|
|
557
|
+
local currentTransport = "none" -- "websocket" | "longpoll" | "none"
|
|
531
558
|
|
|
532
|
-
-- Reconnect config
|
|
559
|
+
-- Reconnect config untuk Long-Polling
|
|
533
560
|
local RECONNECT_DELAY_INITIAL = 1.5
|
|
534
561
|
local RECONNECT_DELAY_MAX = 1.5
|
|
535
562
|
local reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
536
563
|
local currentLoopId = nil
|
|
537
564
|
|
|
565
|
+
-- Task deduplication state (Bug G)
|
|
566
|
+
local runningTasks = {} -- taskId -> true
|
|
567
|
+
local completedTasks = {} -- taskId -> os.clock()
|
|
568
|
+
|
|
569
|
+
local function shouldProcessTask(taskId)
|
|
570
|
+
if not taskId or taskId == "" then return false end
|
|
571
|
+
if runningTasks[taskId] then
|
|
572
|
+
return false
|
|
573
|
+
end
|
|
574
|
+
local completedAt = completedTasks[taskId]
|
|
575
|
+
if completedAt and (os.clock() - completedAt < 60) then
|
|
576
|
+
return false
|
|
577
|
+
end
|
|
578
|
+
runningTasks[taskId] = true
|
|
579
|
+
return true
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
local function markTaskDone(taskId)
|
|
583
|
+
if not taskId then return end
|
|
584
|
+
runningTasks[taskId] = nil
|
|
585
|
+
completedTasks[taskId] = os.clock()
|
|
586
|
+
|
|
587
|
+
-- Bersihkan entri kedaluwarsa (>60 detik)
|
|
588
|
+
local now = os.clock()
|
|
589
|
+
for id, t in pairs(completedTasks) do
|
|
590
|
+
if now - t > 60 then
|
|
591
|
+
completedTasks[id] = nil
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
-- Forward declaration
|
|
597
|
+
local startConnectionManager = nil
|
|
598
|
+
local startLongPollingLoop = nil
|
|
599
|
+
|
|
538
600
|
-- ── Skills (ModuleScript) ─────────────────────────────────────────────────────
|
|
539
601
|
local skills = {}
|
|
540
602
|
for _, module in ipairs(script.Parent:GetChildren()) do
|
|
@@ -577,12 +639,17 @@ if uiModule then
|
|
|
577
639
|
reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
578
640
|
ui:setStatus("waiting")
|
|
579
641
|
ui:addLog("[System] Menghubungkan ulang ke Server MCP...", Color3.fromRGB(255, 220, 100))
|
|
580
|
-
task.spawn(
|
|
642
|
+
task.spawn(startConnectionManager)
|
|
581
643
|
else
|
|
582
644
|
isManualDisconnect = true
|
|
583
645
|
isRunning = false
|
|
584
646
|
serverReachable = false
|
|
585
647
|
mcpAgentActive = false
|
|
648
|
+
if activeWsWrapper then
|
|
649
|
+
activeWsWrapper:Close()
|
|
650
|
+
activeWsWrapper = nil
|
|
651
|
+
end
|
|
652
|
+
currentTransport = "none"
|
|
586
653
|
ui:setStatus("disconnected")
|
|
587
654
|
ui:addLog("[System] Koneksi dihentikan oleh pengguna.", Color3.fromRGB(255, 100, 100))
|
|
588
655
|
end
|
|
@@ -689,31 +756,325 @@ local function processTask(taskData)
|
|
|
689
756
|
return result
|
|
690
757
|
end
|
|
691
758
|
|
|
692
|
-
-- ──
|
|
693
|
-
local function
|
|
759
|
+
-- ── Helper: Sanitize result sebelum JSONEncode (Bug H) ─────────────────────────
|
|
760
|
+
local function sanitizeForJson(val, depth)
|
|
761
|
+
depth = depth or 0
|
|
762
|
+
if depth > 5 then return tostring(val) end
|
|
763
|
+
local t = typeof(val)
|
|
764
|
+
if t == "string" or t == "number" or t == "boolean" then
|
|
765
|
+
return val
|
|
766
|
+
elseif t == "Instance" then
|
|
767
|
+
return val:GetFullName()
|
|
768
|
+
elseif t == "Vector3" then
|
|
769
|
+
return { X = val.X, Y = val.Y, Z = val.Z }
|
|
770
|
+
elseif t == "CFrame" then
|
|
771
|
+
return { Position = { X = val.Position.X, Y = val.Position.Y, Z = val.Position.Z } }
|
|
772
|
+
elseif t == "Color3" then
|
|
773
|
+
return { R = val.R, G = val.G, B = val.B }
|
|
774
|
+
elseif t == "table" then
|
|
775
|
+
local sanitized = {}
|
|
776
|
+
for k, v in pairs(val) do
|
|
777
|
+
sanitized[tostring(k)] = sanitizeForJson(v, depth + 1)
|
|
778
|
+
end
|
|
779
|
+
return sanitized
|
|
780
|
+
else
|
|
781
|
+
return tostring(val)
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
-- ── Kirim Respons ke Server (HTTP Fallback) (Bug H) ───────────────────────────
|
|
786
|
+
local function sendResponse(taskId, resultData, attempt)
|
|
694
787
|
task.spawn(function()
|
|
695
|
-
local
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
788
|
+
local payload = {
|
|
789
|
+
id = taskId,
|
|
790
|
+
taskId = taskId,
|
|
791
|
+
attempt = attempt,
|
|
792
|
+
status = resultData.status or "success",
|
|
793
|
+
result = sanitizeForJson(resultData.result),
|
|
794
|
+
error = resultData.error and tostring(resultData.error) or nil,
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
local encOk, json = pcall(function()
|
|
798
|
+
return HttpService:JSONEncode(payload)
|
|
799
|
+
end)
|
|
800
|
+
|
|
801
|
+
if not encOk then
|
|
802
|
+
payload.result = tostring(payload.result)
|
|
803
|
+
pcall(function() json = HttpService:JSONEncode(payload) end)
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
if not json then
|
|
807
|
+
json = string.format('{"id":"%s","taskId":"%s","status":"error","error":"Gagal encode payload JSON"}', taskId, taskId)
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
local postOk = pcall(function()
|
|
811
|
+
HttpService:PostAsync(
|
|
812
|
+
RESPONSE_URL .. "/" .. taskId,
|
|
813
|
+
json,
|
|
814
|
+
Enum.HttpContentType.ApplicationJson
|
|
815
|
+
)
|
|
816
|
+
end)
|
|
817
|
+
|
|
818
|
+
if not postOk then
|
|
819
|
+
-- Backoff sejenak sebelum fallback ke endpoint legacy (Bug H)
|
|
820
|
+
task.wait(0.2)
|
|
821
|
+
local fallbackOk = pcall(function()
|
|
705
822
|
HttpService:PostAsync(RESPONSE_URL, json, Enum.HttpContentType.ApplicationJson)
|
|
823
|
+
end)
|
|
824
|
+
if not fallbackOk and ui then
|
|
825
|
+
ui:addLog("[System] Gagal kirim response HTTP ke server: " .. tostring(taskId), Color3.fromRGB(255, 100, 100))
|
|
706
826
|
end
|
|
707
|
-
end)
|
|
708
|
-
if not ok and ui then
|
|
709
|
-
ui:addLog("[System] Gagal kirim response: " .. tostring(err), Color3.fromRGB(255, 100, 100))
|
|
710
827
|
end
|
|
711
828
|
end)
|
|
712
829
|
end
|
|
713
830
|
|
|
714
|
-
-- ──
|
|
831
|
+
-- ── WebSocket API Abstraction ────────────────────────────────────────────────
|
|
832
|
+
local function detectWebSocketCapability()
|
|
833
|
+
-- 1. Check HttpService:CreateWebStreamClient (Roblox Studio native API)
|
|
834
|
+
local hasWebStream = false
|
|
835
|
+
pcall(function()
|
|
836
|
+
hasWebStream = (typeof(HttpService.CreateWebStreamClient) == "function")
|
|
837
|
+
and (Enum.WebStreamClientType and Enum.WebStreamClientType.WebSocket ~= nil)
|
|
838
|
+
end)
|
|
839
|
+
if hasWebStream then
|
|
840
|
+
return "WebStreamClient"
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
-- 2. Check global WebSocket API jika tersedia
|
|
844
|
+
local wsGlobal = rawget(_G, "WebSocket") or rawget(_G, "websocket")
|
|
845
|
+
if not wsGlobal then
|
|
846
|
+
pcall(function() wsGlobal = WebSocket end)
|
|
847
|
+
end
|
|
848
|
+
if wsGlobal and typeof(wsGlobal.connect) == "function" then
|
|
849
|
+
return "WebSocketGlobal"
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
return nil
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
local function createWsClient(url)
|
|
856
|
+
local cap = detectWebSocketCapability()
|
|
857
|
+
if not cap then
|
|
858
|
+
return nil, "WebSocket API not available"
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
if cap == "WebStreamClient" then
|
|
862
|
+
local ok, client = pcall(function()
|
|
863
|
+
return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, {
|
|
864
|
+
Url = url
|
|
865
|
+
})
|
|
866
|
+
end)
|
|
867
|
+
if not ok or not client then
|
|
868
|
+
return nil, tostring(client or "CreateWebStreamClient failed")
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
local wrapper = {
|
|
872
|
+
raw = client,
|
|
873
|
+
cap = "WebStreamClient",
|
|
874
|
+
closed = false,
|
|
875
|
+
connections = {},
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function wrapper:Send(data)
|
|
879
|
+
if self.closed then return false, "Closed" end
|
|
880
|
+
return pcall(function()
|
|
881
|
+
self.raw:Send(data)
|
|
882
|
+
end)
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
function wrapper:Close()
|
|
886
|
+
if self.closed then return end
|
|
887
|
+
self.closed = true
|
|
888
|
+
pcall(function() self.raw:Close() end)
|
|
889
|
+
for _, conn in ipairs(self.connections) do
|
|
890
|
+
pcall(function() conn:Disconnect() end)
|
|
891
|
+
end
|
|
892
|
+
table.clear(self.connections)
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
function wrapper:BindEvents(onMessage, onClose)
|
|
896
|
+
if self.raw.MessageReceived and typeof(self.raw.MessageReceived.Connect) == "function" then
|
|
897
|
+
local ok, conn = pcall(function()
|
|
898
|
+
return self.raw.MessageReceived:Connect(onMessage)
|
|
899
|
+
end)
|
|
900
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
if self.raw.Closed and typeof(self.raw.Closed.Connect) == "function" then
|
|
904
|
+
local ok, conn = pcall(function()
|
|
905
|
+
return self.raw.Closed:Connect(onClose)
|
|
906
|
+
end)
|
|
907
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
908
|
+
end
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
return wrapper, nil
|
|
912
|
+
|
|
913
|
+
elseif cap == "WebSocketGlobal" then
|
|
914
|
+
local wsGlobal = rawget(_G, "WebSocket") or rawget(_G, "websocket") or WebSocket
|
|
915
|
+
local ok, client = pcall(function()
|
|
916
|
+
return wsGlobal.connect(url)
|
|
917
|
+
end)
|
|
918
|
+
if not ok or not client then
|
|
919
|
+
return nil, tostring(client or "WebSocket.connect failed")
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
local wrapper = {
|
|
923
|
+
raw = client,
|
|
924
|
+
cap = "WebSocketGlobal",
|
|
925
|
+
closed = false,
|
|
926
|
+
connections = {},
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function wrapper:Send(data)
|
|
930
|
+
if self.closed then return false, "Closed" end
|
|
931
|
+
return pcall(function()
|
|
932
|
+
if typeof(self.raw.Send) == "function" then
|
|
933
|
+
self.raw:Send(data)
|
|
934
|
+
elseif typeof(self.raw.send) == "function" then
|
|
935
|
+
self.raw:send(data)
|
|
936
|
+
end
|
|
937
|
+
end)
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
function wrapper:Close()
|
|
941
|
+
if self.closed then return end
|
|
942
|
+
self.closed = true
|
|
943
|
+
pcall(function()
|
|
944
|
+
if typeof(self.raw.Close) == "function" then
|
|
945
|
+
self.raw:Close()
|
|
946
|
+
elseif typeof(self.raw.close) == "function" then
|
|
947
|
+
self.raw:close()
|
|
948
|
+
end
|
|
949
|
+
end)
|
|
950
|
+
for _, conn in ipairs(self.connections) do
|
|
951
|
+
pcall(function() conn:Disconnect() end)
|
|
952
|
+
end
|
|
953
|
+
table.clear(self.connections)
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
function wrapper:BindEvents(onMessage, onClose)
|
|
957
|
+
if self.raw.MessageReceived and typeof(self.raw.MessageReceived.Connect) == "function" then
|
|
958
|
+
local ok, conn = pcall(function() return self.raw.MessageReceived:Connect(onMessage) end)
|
|
959
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
960
|
+
elseif self.raw.OnMessage then
|
|
961
|
+
if typeof(self.raw.OnMessage) == "RBXScriptSignal" or (typeof(self.raw.OnMessage) == "userdata" and self.raw.OnMessage.Connect) then
|
|
962
|
+
local ok, conn = pcall(function() return self.raw.OnMessage:Connect(onMessage) end)
|
|
963
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
964
|
+
else
|
|
965
|
+
pcall(function() self.raw.OnMessage = onMessage end)
|
|
966
|
+
end
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
if self.raw.Closed and typeof(self.raw.Closed.Connect) == "function" then
|
|
970
|
+
local ok, conn = pcall(function() return self.raw.Closed:Connect(onClose) end)
|
|
971
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
972
|
+
elseif self.raw.OnClose then
|
|
973
|
+
if typeof(self.raw.OnClose) == "RBXScriptSignal" or (typeof(self.raw.OnClose) == "userdata" and self.raw.OnClose.Connect) then
|
|
974
|
+
local ok, conn = pcall(function() return self.raw.OnClose:Connect(onClose) end)
|
|
975
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
976
|
+
else
|
|
977
|
+
pcall(function() self.raw.OnClose = onClose end)
|
|
978
|
+
end
|
|
979
|
+
end
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
return wrapper, nil
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
return nil, "Unsupported"
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
-- ── WebSocket Message Dispatcher ─────────────────────────────────────────────
|
|
989
|
+
local function handleWsMessage(wrapper, rawMessage)
|
|
990
|
+
if type(rawMessage) ~= "string" or rawMessage == "" then return end
|
|
991
|
+
local ok, decoded = pcall(function() return HttpService:JSONDecode(rawMessage) end)
|
|
992
|
+
if not ok or type(decoded) ~= "table" then return end
|
|
993
|
+
|
|
994
|
+
local kind = decoded.kind
|
|
995
|
+
|
|
996
|
+
-- Heartbeat ping -> balas pong
|
|
997
|
+
if kind == "ping" then
|
|
998
|
+
pcall(function()
|
|
999
|
+
wrapper:Send(HttpService:JSONEncode({ kind = "pong", ts = os.time() }))
|
|
1000
|
+
end)
|
|
1001
|
+
return
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
if kind == "pong" then
|
|
1005
|
+
return
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
-- Update status koneksi MCP / Studio
|
|
1009
|
+
if kind == "status" then
|
|
1010
|
+
local newActive = decoded.mcpConnected == true
|
|
1011
|
+
local changed = (mcpAgentActive ~= newActive)
|
|
1012
|
+
mcpAgentActive = newActive
|
|
1013
|
+
if ui then
|
|
1014
|
+
if mcpAgentActive then
|
|
1015
|
+
ui:setStatus("connected")
|
|
1016
|
+
else
|
|
1017
|
+
ui:setStatus("server_connected")
|
|
1018
|
+
end
|
|
1019
|
+
if newActive and changed then
|
|
1020
|
+
ui:addLog("[System] ✅ IDE Agent (Antigravity) aktif ⚡", Color3.fromRGB(100, 255, 100))
|
|
1021
|
+
elseif not newActive and changed then
|
|
1022
|
+
ui:addLog("[System] ⚠️ IDE Agent terputus.", Color3.fromRGB(255, 150, 50))
|
|
1023
|
+
end
|
|
1024
|
+
end
|
|
1025
|
+
return
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
-- Eksekusi perintah AI
|
|
1029
|
+
if kind == "request" then
|
|
1030
|
+
local taskId = decoded.taskId or decoded.requestId
|
|
1031
|
+
local attempt = decoded.attempt
|
|
1032
|
+
local command = decoded.command
|
|
1033
|
+
local target = decoded.target
|
|
1034
|
+
local data = decoded.data
|
|
1035
|
+
|
|
1036
|
+
if not taskId or not command then return end
|
|
1037
|
+
if not shouldProcessTask(taskId) then return end -- Bug G: Dedup!
|
|
1038
|
+
|
|
1039
|
+
task.spawn(function()
|
|
1040
|
+
local resultData = processTask({
|
|
1041
|
+
id = taskId,
|
|
1042
|
+
command = command,
|
|
1043
|
+
target = target or "",
|
|
1044
|
+
data = data,
|
|
1045
|
+
})
|
|
1046
|
+
markTaskDone(taskId) -- Bug G: Tandai selesai
|
|
1047
|
+
if resultData then
|
|
1048
|
+
-- Push respons instan kembali via WebSocket (Zero-Delay)
|
|
1049
|
+
local responseEnvelope = {
|
|
1050
|
+
kind = "response",
|
|
1051
|
+
taskId = taskId,
|
|
1052
|
+
id = taskId,
|
|
1053
|
+
attempt = attempt,
|
|
1054
|
+
status = resultData.status,
|
|
1055
|
+
result = sanitizeForJson(resultData.result),
|
|
1056
|
+
error = resultData.error and tostring(resultData.error) or nil,
|
|
1057
|
+
}
|
|
1058
|
+
local encOk, encoded = pcall(function()
|
|
1059
|
+
return HttpService:JSONEncode(responseEnvelope)
|
|
1060
|
+
end)
|
|
1061
|
+
local sent = false
|
|
1062
|
+
if encOk then
|
|
1063
|
+
local sendOk, _ = wrapper:Send(encoded)
|
|
1064
|
+
sent = (sendOk == true)
|
|
1065
|
+
end
|
|
1066
|
+
if not sent then
|
|
1067
|
+
-- Jika serialisasi atau pengiriman via WS gagal, fallback ke HTTP POST
|
|
1068
|
+
sendResponse(taskId, resultData, attempt)
|
|
1069
|
+
end
|
|
1070
|
+
end
|
|
1071
|
+
end)
|
|
1072
|
+
return
|
|
1073
|
+
end
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
-- ── Proses satu event dari SSE stream (Long-Polling fallback) ────────────────
|
|
715
1077
|
local function handleSSELine(line)
|
|
716
|
-
-- Format SSE: "data: {...json...}"
|
|
717
1078
|
local payload = line
|
|
718
1079
|
if line:sub(1, 6) == "event:" then return end
|
|
719
1080
|
if line:sub(1, 5) == "data:" then
|
|
@@ -726,12 +1087,10 @@ local function handleSSELine(line)
|
|
|
726
1087
|
|
|
727
1088
|
local kind = decoded.kind
|
|
728
1089
|
|
|
729
|
-
-- heartbeat: server masih hidup
|
|
730
1090
|
if kind == "heartbeat" then
|
|
731
1091
|
return
|
|
732
1092
|
end
|
|
733
1093
|
|
|
734
|
-
-- status: update mcpConnected
|
|
735
1094
|
if kind == "status" then
|
|
736
1095
|
local newActive = decoded.mcpConnected == true
|
|
737
1096
|
local changed = (mcpAgentActive ~= newActive)
|
|
@@ -752,14 +1111,15 @@ local function handleSSELine(line)
|
|
|
752
1111
|
return
|
|
753
1112
|
end
|
|
754
1113
|
|
|
755
|
-
-- request: ada task dari AI agent
|
|
756
1114
|
if kind == "request" then
|
|
757
|
-
local taskId = decoded.requestId
|
|
1115
|
+
local taskId = decoded.requestId or decoded.taskId
|
|
1116
|
+
local attempt = decoded.attempt
|
|
758
1117
|
local command = decoded.command
|
|
759
1118
|
local target = decoded.target
|
|
760
1119
|
local data = decoded.data
|
|
761
1120
|
|
|
762
1121
|
if not taskId or not command then return end
|
|
1122
|
+
if not shouldProcessTask(taskId) then return end -- Bug G: Dedup!
|
|
763
1123
|
|
|
764
1124
|
task.spawn(function()
|
|
765
1125
|
local resultData = processTask({
|
|
@@ -768,39 +1128,34 @@ local function handleSSELine(line)
|
|
|
768
1128
|
target = target or "",
|
|
769
1129
|
data = data,
|
|
770
1130
|
})
|
|
1131
|
+
markTaskDone(taskId) -- Bug G: Tandai selesai
|
|
771
1132
|
if resultData then
|
|
772
|
-
sendResponse(taskId, resultData)
|
|
1133
|
+
sendResponse(taskId, resultData, attempt)
|
|
773
1134
|
end
|
|
774
1135
|
end)
|
|
775
1136
|
return
|
|
776
1137
|
end
|
|
777
1138
|
end
|
|
778
1139
|
|
|
779
|
-
-- ── Loop
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
-- Plugin reconnect langsung untuk sesi berikutnya.
|
|
784
|
-
function startConnectionLoop()
|
|
785
|
-
if isRunning then return end
|
|
786
|
-
isRunning = true
|
|
787
|
-
isManualDisconnect = false
|
|
1140
|
+
-- ── Fallback Loop: Long-Polling (/api/stream) ─────────────────────────────────
|
|
1141
|
+
function startLongPollingLoop()
|
|
1142
|
+
if not isRunning or isManualDisconnect then return end
|
|
1143
|
+
currentTransport = "longpoll"
|
|
788
1144
|
reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
789
1145
|
|
|
790
1146
|
local loopId = HttpService:GenerateGUID(false)
|
|
791
1147
|
currentLoopId = loopId
|
|
792
1148
|
|
|
793
1149
|
if ui then
|
|
794
|
-
ui:addLog("[System]
|
|
1150
|
+
ui:addLog("[System] Mode Long-Polling aktif (Port 3055)...", Color3.fromRGB(200, 200, 200))
|
|
795
1151
|
end
|
|
796
1152
|
|
|
797
1153
|
local consecutiveErrors = 0
|
|
798
1154
|
|
|
799
|
-
while isRunning and not isManualDisconnect and currentLoopId == loopId do
|
|
800
|
-
-- Stream / polling snapshot dari server
|
|
1155
|
+
while isRunning and not isManualDisconnect and currentLoopId == loopId and currentTransport == "longpoll" do
|
|
801
1156
|
local streamOk, streamRaw = pcall(function()
|
|
802
1157
|
return HttpService:GetAsync(
|
|
803
|
-
STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID),
|
|
1158
|
+
STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID) .. "&placeId=" .. tostring(game.PlaceId),
|
|
804
1159
|
false
|
|
805
1160
|
)
|
|
806
1161
|
end)
|
|
@@ -813,28 +1168,26 @@ function startConnectionLoop()
|
|
|
813
1168
|
serverReachable = true
|
|
814
1169
|
if ui then
|
|
815
1170
|
ui:setStatus(mcpAgentActive and "connected" or "server_connected")
|
|
816
|
-
ui:addLog("[System] ✅ Server MCP terhubung
|
|
1171
|
+
ui:addLog("[System] ✅ Server MCP terhubung via Long-Polling.", Color3.fromRGB(100, 255, 150))
|
|
817
1172
|
end
|
|
818
1173
|
end
|
|
819
1174
|
|
|
820
|
-
-- Parse baris-baris SSE
|
|
821
1175
|
for line in (streamRaw .. "\n"):gmatch("([^\n]*)\n") do
|
|
822
1176
|
line = line:gsub("\r", "")
|
|
823
1177
|
if line ~= "" then
|
|
824
1178
|
handleSSELine(line)
|
|
825
1179
|
end
|
|
826
1180
|
end
|
|
827
|
-
task.wait(
|
|
1181
|
+
task.wait()
|
|
828
1182
|
else
|
|
829
1183
|
consecutiveErrors = consecutiveErrors + 1
|
|
830
|
-
-- Toleransi 2x gagal sebelum menandai server unreachable
|
|
831
1184
|
if consecutiveErrors >= 2 then
|
|
832
1185
|
if serverReachable then
|
|
833
1186
|
serverReachable = false
|
|
834
1187
|
mcpAgentActive = false
|
|
835
1188
|
if ui then
|
|
836
1189
|
ui:setStatus("waiting")
|
|
837
|
-
ui:addLog("[System] ⚠️ Server MCP tidak dapat dijangkau.
|
|
1190
|
+
ui:addLog("[System] ⚠️ Server MCP tidak dapat dijangkau. Menghubungkan ulang...", Color3.fromRGB(255, 150, 50))
|
|
838
1191
|
end
|
|
839
1192
|
end
|
|
840
1193
|
end
|
|
@@ -844,24 +1197,125 @@ function startConnectionLoop()
|
|
|
844
1197
|
end
|
|
845
1198
|
end
|
|
846
1199
|
|
|
847
|
-
isRunning
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1200
|
+
if currentTransport == "longpoll" and (not isRunning or isManualDisconnect) then
|
|
1201
|
+
isRunning = false
|
|
1202
|
+
serverReachable = false
|
|
1203
|
+
mcpAgentActive = false
|
|
1204
|
+
if ui then ui:setStatus("disconnected") end
|
|
1205
|
+
end
|
|
1206
|
+
end
|
|
1207
|
+
|
|
1208
|
+
-- ── Connection Manager: WebSocket Utama + Fallback Long-Polling ──────────────
|
|
1209
|
+
function startConnectionManager()
|
|
1210
|
+
if isRunning then return end
|
|
1211
|
+
isRunning = true
|
|
1212
|
+
isManualDisconnect = false
|
|
1213
|
+
|
|
1214
|
+
local cap = detectWebSocketCapability()
|
|
1215
|
+
if cap then
|
|
1216
|
+
task.spawn(function()
|
|
1217
|
+
local wsFailures = 0
|
|
1218
|
+
local backoff = 0.5
|
|
1219
|
+
|
|
1220
|
+
while isRunning and not isManualDisconnect and wsFailures < 3 do
|
|
1221
|
+
currentTransport = "websocket"
|
|
1222
|
+
if ui then
|
|
1223
|
+
ui:setStatus("waiting")
|
|
1224
|
+
ui:addLog("[System] Menghubungkan via WebSocket (Port 3055)...", Color3.fromRGB(200, 200, 255))
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
local wrapper, err = createWsClient(WS_URL)
|
|
1228
|
+
if not wrapper then
|
|
1229
|
+
-- Coba fallback ke IP 127.0.0.1
|
|
1230
|
+
wrapper, err = createWsClient(WS_URL_IP)
|
|
1231
|
+
end
|
|
1232
|
+
|
|
1233
|
+
if wrapper then
|
|
1234
|
+
activeWsWrapper = wrapper
|
|
1235
|
+
serverReachable = true
|
|
1236
|
+
if ui then
|
|
1237
|
+
ui:setStatus(mcpAgentActive and "connected" or "server_connected")
|
|
1238
|
+
ui:addLog("[System] ⚡ WebSocket Terhubung! (Ultra Low-Latency <1ms)", Color3.fromRGB(100, 255, 150))
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
-- Kirim pesan registrasi awal
|
|
1242
|
+
local regOk = wrapper:Send(HttpService:JSONEncode({
|
|
1243
|
+
kind = "register",
|
|
1244
|
+
studioId = STUDIO_ID,
|
|
1245
|
+
placeId = game.PlaceId,
|
|
1246
|
+
pluginsVersion = 2,
|
|
1247
|
+
}))
|
|
1248
|
+
|
|
1249
|
+
if regOk then
|
|
1250
|
+
wsFailures = 0
|
|
1251
|
+
backoff = 0.5
|
|
1252
|
+
|
|
1253
|
+
local closedSignal = Instance.new("BindableEvent")
|
|
1254
|
+
wrapper:BindEvents(
|
|
1255
|
+
function(msg)
|
|
1256
|
+
handleWsMessage(wrapper, msg)
|
|
1257
|
+
end,
|
|
1258
|
+
function()
|
|
1259
|
+
closedSignal:Fire()
|
|
1260
|
+
end
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
closedSignal.Event:Wait()
|
|
1264
|
+
closedSignal:Destroy()
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
if activeWsWrapper == wrapper then
|
|
1268
|
+
activeWsWrapper = nil
|
|
1269
|
+
end
|
|
1270
|
+
wrapper:Close()
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1273
|
+
if isManualDisconnect or not isRunning then break end
|
|
1274
|
+
|
|
1275
|
+
wsFailures = wsFailures + 1
|
|
1276
|
+
if wsFailures < 3 then
|
|
1277
|
+
if ui then
|
|
1278
|
+
ui:setStatus("waiting")
|
|
1279
|
+
ui:addLog("[System] ⚠️ WebSocket terputus. Reconnect dalam " .. string.format("%.1f", backoff) .. "s...", Color3.fromRGB(255, 180, 50))
|
|
1280
|
+
end
|
|
1281
|
+
task.wait(backoff)
|
|
1282
|
+
backoff = math.min(backoff * 2, 5.0)
|
|
1283
|
+
end
|
|
1284
|
+
end
|
|
1285
|
+
|
|
1286
|
+
-- Jika WS gagal 3x berturut-turut dan plugin masih aktif -> Beralih ke Long-Polling!
|
|
1287
|
+
if isRunning and not isManualDisconnect then
|
|
1288
|
+
if ui then
|
|
1289
|
+
ui:addLog("[System] ℹ️ Mengalihkan ke Mode Fallback Long-Polling...", Color3.fromRGB(255, 200, 100))
|
|
1290
|
+
end
|
|
1291
|
+
startLongPollingLoop()
|
|
1292
|
+
end
|
|
1293
|
+
end)
|
|
1294
|
+
else
|
|
1295
|
+
-- WebSocket tidak didukung di environment Studio ini -> langsung Long-Polling
|
|
1296
|
+
if ui then
|
|
1297
|
+
ui:addLog("[System] ℹ️ WebSocket tidak terdeteksi. Menggunakan Long-Polling.", Color3.fromRGB(200, 200, 200))
|
|
1298
|
+
end
|
|
1299
|
+
startLongPollingLoop()
|
|
1300
|
+
end
|
|
851
1301
|
end
|
|
852
1302
|
|
|
853
1303
|
-- ── Plugin Unloading ──────────────────────────────────────────────────────────
|
|
854
1304
|
plugin.Unloading:Connect(function()
|
|
855
1305
|
isManualDisconnect = true
|
|
856
1306
|
isRunning = false
|
|
1307
|
+
if activeWsWrapper then
|
|
1308
|
+
activeWsWrapper:Close()
|
|
1309
|
+
activeWsWrapper = nil
|
|
1310
|
+
end
|
|
857
1311
|
end)
|
|
858
1312
|
|
|
859
1313
|
-- ══════════════════════════════════════════════════════════════════════
|
|
860
|
-
-- STARTUP: Langsung mulai connection
|
|
1314
|
+
-- STARTUP: Langsung mulai connection manager
|
|
861
1315
|
-- ══════════════════════════════════════════════════════════════════════
|
|
862
1316
|
task.spawn(function()
|
|
863
1317
|
task.wait(0.5) -- Tunggu UI siap
|
|
864
|
-
|
|
1318
|
+
startConnectionManager()
|
|
865
1319
|
end)
|
|
866
1320
|
]]></ProtectedString>
|
|
867
1321
|
</Properties>
|
|
@@ -1088,7 +1542,8 @@ end
|
|
|
1088
1542
|
if targetInstance and targetInstance ~= game then
|
|
1089
1543
|
local descendants = {}
|
|
1090
1544
|
pcall(function() descendants = targetInstance:GetDescendants() end)
|
|
1091
|
-
for
|
|
1545
|
+
for i, desc in ipairs(descendants) do
|
|
1546
|
+
if i % 300 == 0 then task.wait() end
|
|
1092
1547
|
if matchCount >= MAX_MATCHES then break end
|
|
1093
1548
|
if desc:IsA("LuaSourceContainer") then
|
|
1094
1549
|
if searchInScript(desc) then
|
|
@@ -1100,7 +1555,8 @@ end
|
|
|
1100
1555
|
for _, service in ipairs(searchableServices) do
|
|
1101
1556
|
local descendants = {}
|
|
1102
1557
|
pcall(function() descendants = service:GetDescendants() end)
|
|
1103
|
-
for
|
|
1558
|
+
for i, desc in ipairs(descendants) do
|
|
1559
|
+
if i % 300 == 0 then task.wait() end
|
|
1104
1560
|
if matchCount >= MAX_MATCHES then break end
|
|
1105
1561
|
if desc:IsA("LuaSourceContainer") then
|
|
1106
1562
|
if searchInScript(desc) then
|