@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
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
-- ══════════════════════════════════════════════════════════════════════
|
|
2
|
-
-- NVStudio MCP Plugin v2.
|
|
3
|
-
-- Arsitektur:
|
|
4
|
-
-- Plugin auto-connect saat dimuat,
|
|
5
|
-
--
|
|
2
|
+
-- NVStudio MCP Plugin v2.2.2
|
|
3
|
+
-- Arsitektur: Dual-Mode WebSocket Duplex (Port 3055/ws) + Fallback Long-Polling
|
|
4
|
+
-- Plugin auto-connect saat dimuat via WebSocket native, dengan fallback otomatis
|
|
5
|
+
-- ke HTTP stream jika WebSocket tidak tersedia di environment Roblox Studio ini.
|
|
6
6
|
-- ══════════════════════════════════════════════════════════════════════
|
|
7
7
|
|
|
8
8
|
local HttpService = game:GetService("HttpService")
|
|
@@ -13,6 +13,8 @@ local BASE_URL = "http://localhost:3055"
|
|
|
13
13
|
local STREAM_URL = BASE_URL .. "/api/stream" -- SSE long-poll (blocking GET)
|
|
14
14
|
local RESPONSE_URL = BASE_URL .. "/api/response"
|
|
15
15
|
local PING_URL = BASE_URL .. "/api/ping"
|
|
16
|
+
local WS_URL = "ws://localhost:3055/ws"
|
|
17
|
+
local WS_URL_IP = "ws://127.0.0.1:3055/ws"
|
|
16
18
|
|
|
17
19
|
-- ID unik per sesi Studio
|
|
18
20
|
local STUDIO_ID = HttpService:GenerateGUID(false)
|
|
@@ -34,18 +36,55 @@ local ctx = {
|
|
|
34
36
|
getInstanceFromPath = nil -- akan di-bind di bawah
|
|
35
37
|
}
|
|
36
38
|
local MAX_LOGS = 50
|
|
37
|
-
local isRunning = false -- apakah loop aktif
|
|
39
|
+
local isRunning = false -- apakah loop/koneksi aktif
|
|
38
40
|
local isManualDisconnect = false
|
|
39
41
|
local isClickDebounce = false
|
|
40
42
|
local mcpAgentActive = false -- apakah Antigravity sedang konek ke MCP
|
|
41
43
|
local serverReachable = false -- apakah server Node.js reachable
|
|
44
|
+
local activeWsWrapper = nil
|
|
45
|
+
local currentTransport = "none" -- "websocket" | "longpoll" | "none"
|
|
42
46
|
|
|
43
|
-
-- Reconnect config
|
|
47
|
+
-- Reconnect config untuk Long-Polling
|
|
44
48
|
local RECONNECT_DELAY_INITIAL = 1.5
|
|
45
49
|
local RECONNECT_DELAY_MAX = 1.5
|
|
46
50
|
local reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
47
51
|
local currentLoopId = nil
|
|
48
52
|
|
|
53
|
+
-- Task deduplication state (Bug G)
|
|
54
|
+
local runningTasks = {} -- taskId -> true
|
|
55
|
+
local completedTasks = {} -- taskId -> os.clock()
|
|
56
|
+
|
|
57
|
+
local function shouldProcessTask(taskId)
|
|
58
|
+
if not taskId or taskId == "" then return false end
|
|
59
|
+
if runningTasks[taskId] then
|
|
60
|
+
return false
|
|
61
|
+
end
|
|
62
|
+
local completedAt = completedTasks[taskId]
|
|
63
|
+
if completedAt and (os.clock() - completedAt < 60) then
|
|
64
|
+
return false
|
|
65
|
+
end
|
|
66
|
+
runningTasks[taskId] = true
|
|
67
|
+
return true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
local function markTaskDone(taskId)
|
|
71
|
+
if not taskId then return end
|
|
72
|
+
runningTasks[taskId] = nil
|
|
73
|
+
completedTasks[taskId] = os.clock()
|
|
74
|
+
|
|
75
|
+
-- Bersihkan entri kedaluwarsa (>60 detik)
|
|
76
|
+
local now = os.clock()
|
|
77
|
+
for id, t in pairs(completedTasks) do
|
|
78
|
+
if now - t > 60 then
|
|
79
|
+
completedTasks[id] = nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
-- Forward declaration
|
|
85
|
+
local startConnectionManager = nil
|
|
86
|
+
local startLongPollingLoop = nil
|
|
87
|
+
|
|
49
88
|
-- ── Skills (ModuleScript) ─────────────────────────────────────────────────────
|
|
50
89
|
local skills = {}
|
|
51
90
|
for _, module in ipairs(script.Parent:GetChildren()) do
|
|
@@ -88,12 +127,17 @@ if uiModule then
|
|
|
88
127
|
reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
89
128
|
ui:setStatus("waiting")
|
|
90
129
|
ui:addLog("[System] Menghubungkan ulang ke Server MCP...", Color3.fromRGB(255, 220, 100))
|
|
91
|
-
task.spawn(
|
|
130
|
+
task.spawn(startConnectionManager)
|
|
92
131
|
else
|
|
93
132
|
isManualDisconnect = true
|
|
94
133
|
isRunning = false
|
|
95
134
|
serverReachable = false
|
|
96
135
|
mcpAgentActive = false
|
|
136
|
+
if activeWsWrapper then
|
|
137
|
+
activeWsWrapper:Close()
|
|
138
|
+
activeWsWrapper = nil
|
|
139
|
+
end
|
|
140
|
+
currentTransport = "none"
|
|
97
141
|
ui:setStatus("disconnected")
|
|
98
142
|
ui:addLog("[System] Koneksi dihentikan oleh pengguna.", Color3.fromRGB(255, 100, 100))
|
|
99
143
|
end
|
|
@@ -200,31 +244,325 @@ local function processTask(taskData)
|
|
|
200
244
|
return result
|
|
201
245
|
end
|
|
202
246
|
|
|
203
|
-
-- ──
|
|
204
|
-
local function
|
|
247
|
+
-- ── Helper: Sanitize result sebelum JSONEncode (Bug H) ─────────────────────────
|
|
248
|
+
local function sanitizeForJson(val, depth)
|
|
249
|
+
depth = depth or 0
|
|
250
|
+
if depth > 5 then return tostring(val) end
|
|
251
|
+
local t = typeof(val)
|
|
252
|
+
if t == "string" or t == "number" or t == "boolean" then
|
|
253
|
+
return val
|
|
254
|
+
elseif t == "Instance" then
|
|
255
|
+
return val:GetFullName()
|
|
256
|
+
elseif t == "Vector3" then
|
|
257
|
+
return { X = val.X, Y = val.Y, Z = val.Z }
|
|
258
|
+
elseif t == "CFrame" then
|
|
259
|
+
return { Position = { X = val.Position.X, Y = val.Position.Y, Z = val.Position.Z } }
|
|
260
|
+
elseif t == "Color3" then
|
|
261
|
+
return { R = val.R, G = val.G, B = val.B }
|
|
262
|
+
elseif t == "table" then
|
|
263
|
+
local sanitized = {}
|
|
264
|
+
for k, v in pairs(val) do
|
|
265
|
+
sanitized[tostring(k)] = sanitizeForJson(v, depth + 1)
|
|
266
|
+
end
|
|
267
|
+
return sanitized
|
|
268
|
+
else
|
|
269
|
+
return tostring(val)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
-- ── Kirim Respons ke Server (HTTP Fallback) (Bug H) ───────────────────────────
|
|
274
|
+
local function sendResponse(taskId, resultData, attempt)
|
|
205
275
|
task.spawn(function()
|
|
206
|
-
local
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
276
|
+
local payload = {
|
|
277
|
+
id = taskId,
|
|
278
|
+
taskId = taskId,
|
|
279
|
+
attempt = attempt,
|
|
280
|
+
status = resultData.status or "success",
|
|
281
|
+
result = sanitizeForJson(resultData.result),
|
|
282
|
+
error = resultData.error and tostring(resultData.error) or nil,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
local encOk, json = pcall(function()
|
|
286
|
+
return HttpService:JSONEncode(payload)
|
|
287
|
+
end)
|
|
288
|
+
|
|
289
|
+
if not encOk then
|
|
290
|
+
payload.result = tostring(payload.result)
|
|
291
|
+
pcall(function() json = HttpService:JSONEncode(payload) end)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
if not json then
|
|
295
|
+
json = string.format('{"id":"%s","taskId":"%s","status":"error","error":"Gagal encode payload JSON"}', taskId, taskId)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
local postOk = pcall(function()
|
|
299
|
+
HttpService:PostAsync(
|
|
300
|
+
RESPONSE_URL .. "/" .. taskId,
|
|
301
|
+
json,
|
|
302
|
+
Enum.HttpContentType.ApplicationJson
|
|
303
|
+
)
|
|
304
|
+
end)
|
|
305
|
+
|
|
306
|
+
if not postOk then
|
|
307
|
+
-- Backoff sejenak sebelum fallback ke endpoint legacy (Bug H)
|
|
308
|
+
task.wait(0.2)
|
|
309
|
+
local fallbackOk = pcall(function()
|
|
216
310
|
HttpService:PostAsync(RESPONSE_URL, json, Enum.HttpContentType.ApplicationJson)
|
|
311
|
+
end)
|
|
312
|
+
if not fallbackOk and ui then
|
|
313
|
+
ui:addLog("[System] Gagal kirim response HTTP ke server: " .. tostring(taskId), Color3.fromRGB(255, 100, 100))
|
|
217
314
|
end
|
|
218
|
-
end)
|
|
219
|
-
if not ok and ui then
|
|
220
|
-
ui:addLog("[System] Gagal kirim response: " .. tostring(err), Color3.fromRGB(255, 100, 100))
|
|
221
315
|
end
|
|
222
316
|
end)
|
|
223
317
|
end
|
|
224
318
|
|
|
225
|
-
-- ──
|
|
319
|
+
-- ── WebSocket API Abstraction ────────────────────────────────────────────────
|
|
320
|
+
local function detectWebSocketCapability()
|
|
321
|
+
-- 1. Check HttpService:CreateWebStreamClient (Roblox Studio native API)
|
|
322
|
+
local hasWebStream = false
|
|
323
|
+
pcall(function()
|
|
324
|
+
hasWebStream = (typeof(HttpService.CreateWebStreamClient) == "function")
|
|
325
|
+
and (Enum.WebStreamClientType and Enum.WebStreamClientType.WebSocket ~= nil)
|
|
326
|
+
end)
|
|
327
|
+
if hasWebStream then
|
|
328
|
+
return "WebStreamClient"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
-- 2. Check global WebSocket API jika tersedia
|
|
332
|
+
local wsGlobal = rawget(_G, "WebSocket") or rawget(_G, "websocket")
|
|
333
|
+
if not wsGlobal then
|
|
334
|
+
pcall(function() wsGlobal = WebSocket end)
|
|
335
|
+
end
|
|
336
|
+
if wsGlobal and typeof(wsGlobal.connect) == "function" then
|
|
337
|
+
return "WebSocketGlobal"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
return nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
local function createWsClient(url)
|
|
344
|
+
local cap = detectWebSocketCapability()
|
|
345
|
+
if not cap then
|
|
346
|
+
return nil, "WebSocket API not available"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
if cap == "WebStreamClient" then
|
|
350
|
+
local ok, client = pcall(function()
|
|
351
|
+
return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, {
|
|
352
|
+
Url = url
|
|
353
|
+
})
|
|
354
|
+
end)
|
|
355
|
+
if not ok or not client then
|
|
356
|
+
return nil, tostring(client or "CreateWebStreamClient failed")
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
local wrapper = {
|
|
360
|
+
raw = client,
|
|
361
|
+
cap = "WebStreamClient",
|
|
362
|
+
closed = false,
|
|
363
|
+
connections = {},
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function wrapper:Send(data)
|
|
367
|
+
if self.closed then return false, "Closed" end
|
|
368
|
+
return pcall(function()
|
|
369
|
+
self.raw:Send(data)
|
|
370
|
+
end)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
function wrapper:Close()
|
|
374
|
+
if self.closed then return end
|
|
375
|
+
self.closed = true
|
|
376
|
+
pcall(function() self.raw:Close() end)
|
|
377
|
+
for _, conn in ipairs(self.connections) do
|
|
378
|
+
pcall(function() conn:Disconnect() end)
|
|
379
|
+
end
|
|
380
|
+
table.clear(self.connections)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
function wrapper:BindEvents(onMessage, onClose)
|
|
384
|
+
if self.raw.MessageReceived and typeof(self.raw.MessageReceived.Connect) == "function" then
|
|
385
|
+
local ok, conn = pcall(function()
|
|
386
|
+
return self.raw.MessageReceived:Connect(onMessage)
|
|
387
|
+
end)
|
|
388
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
if self.raw.Closed and typeof(self.raw.Closed.Connect) == "function" then
|
|
392
|
+
local ok, conn = pcall(function()
|
|
393
|
+
return self.raw.Closed:Connect(onClose)
|
|
394
|
+
end)
|
|
395
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
return wrapper, nil
|
|
400
|
+
|
|
401
|
+
elseif cap == "WebSocketGlobal" then
|
|
402
|
+
local wsGlobal = rawget(_G, "WebSocket") or rawget(_G, "websocket") or WebSocket
|
|
403
|
+
local ok, client = pcall(function()
|
|
404
|
+
return wsGlobal.connect(url)
|
|
405
|
+
end)
|
|
406
|
+
if not ok or not client then
|
|
407
|
+
return nil, tostring(client or "WebSocket.connect failed")
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
local wrapper = {
|
|
411
|
+
raw = client,
|
|
412
|
+
cap = "WebSocketGlobal",
|
|
413
|
+
closed = false,
|
|
414
|
+
connections = {},
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function wrapper:Send(data)
|
|
418
|
+
if self.closed then return false, "Closed" end
|
|
419
|
+
return pcall(function()
|
|
420
|
+
if typeof(self.raw.Send) == "function" then
|
|
421
|
+
self.raw:Send(data)
|
|
422
|
+
elseif typeof(self.raw.send) == "function" then
|
|
423
|
+
self.raw:send(data)
|
|
424
|
+
end
|
|
425
|
+
end)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
function wrapper:Close()
|
|
429
|
+
if self.closed then return end
|
|
430
|
+
self.closed = true
|
|
431
|
+
pcall(function()
|
|
432
|
+
if typeof(self.raw.Close) == "function" then
|
|
433
|
+
self.raw:Close()
|
|
434
|
+
elseif typeof(self.raw.close) == "function" then
|
|
435
|
+
self.raw:close()
|
|
436
|
+
end
|
|
437
|
+
end)
|
|
438
|
+
for _, conn in ipairs(self.connections) do
|
|
439
|
+
pcall(function() conn:Disconnect() end)
|
|
440
|
+
end
|
|
441
|
+
table.clear(self.connections)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
function wrapper:BindEvents(onMessage, onClose)
|
|
445
|
+
if self.raw.MessageReceived and typeof(self.raw.MessageReceived.Connect) == "function" then
|
|
446
|
+
local ok, conn = pcall(function() return self.raw.MessageReceived:Connect(onMessage) end)
|
|
447
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
448
|
+
elseif self.raw.OnMessage then
|
|
449
|
+
if typeof(self.raw.OnMessage) == "RBXScriptSignal" or (typeof(self.raw.OnMessage) == "userdata" and self.raw.OnMessage.Connect) then
|
|
450
|
+
local ok, conn = pcall(function() return self.raw.OnMessage:Connect(onMessage) end)
|
|
451
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
452
|
+
else
|
|
453
|
+
pcall(function() self.raw.OnMessage = onMessage end)
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
if self.raw.Closed and typeof(self.raw.Closed.Connect) == "function" then
|
|
458
|
+
local ok, conn = pcall(function() return self.raw.Closed:Connect(onClose) end)
|
|
459
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
460
|
+
elseif self.raw.OnClose then
|
|
461
|
+
if typeof(self.raw.OnClose) == "RBXScriptSignal" or (typeof(self.raw.OnClose) == "userdata" and self.raw.OnClose.Connect) then
|
|
462
|
+
local ok, conn = pcall(function() return self.raw.OnClose:Connect(onClose) end)
|
|
463
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
464
|
+
else
|
|
465
|
+
pcall(function() self.raw.OnClose = onClose end)
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
return wrapper, nil
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
return nil, "Unsupported"
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
-- ── WebSocket Message Dispatcher ─────────────────────────────────────────────
|
|
477
|
+
local function handleWsMessage(wrapper, rawMessage)
|
|
478
|
+
if type(rawMessage) ~= "string" or rawMessage == "" then return end
|
|
479
|
+
local ok, decoded = pcall(function() return HttpService:JSONDecode(rawMessage) end)
|
|
480
|
+
if not ok or type(decoded) ~= "table" then return end
|
|
481
|
+
|
|
482
|
+
local kind = decoded.kind
|
|
483
|
+
|
|
484
|
+
-- Heartbeat ping -> balas pong
|
|
485
|
+
if kind == "ping" then
|
|
486
|
+
pcall(function()
|
|
487
|
+
wrapper:Send(HttpService:JSONEncode({ kind = "pong", ts = os.time() }))
|
|
488
|
+
end)
|
|
489
|
+
return
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
if kind == "pong" then
|
|
493
|
+
return
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
-- Update status koneksi MCP / Studio
|
|
497
|
+
if kind == "status" then
|
|
498
|
+
local newActive = decoded.mcpConnected == true
|
|
499
|
+
local changed = (mcpAgentActive ~= newActive)
|
|
500
|
+
mcpAgentActive = newActive
|
|
501
|
+
if ui then
|
|
502
|
+
if mcpAgentActive then
|
|
503
|
+
ui:setStatus("connected")
|
|
504
|
+
else
|
|
505
|
+
ui:setStatus("server_connected")
|
|
506
|
+
end
|
|
507
|
+
if newActive and changed then
|
|
508
|
+
ui:addLog("[System] ✅ IDE Agent (Antigravity) aktif ⚡", Color3.fromRGB(100, 255, 100))
|
|
509
|
+
elseif not newActive and changed then
|
|
510
|
+
ui:addLog("[System] ⚠️ IDE Agent terputus.", Color3.fromRGB(255, 150, 50))
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
return
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
-- Eksekusi perintah AI
|
|
517
|
+
if kind == "request" then
|
|
518
|
+
local taskId = decoded.taskId or decoded.requestId
|
|
519
|
+
local attempt = decoded.attempt
|
|
520
|
+
local command = decoded.command
|
|
521
|
+
local target = decoded.target
|
|
522
|
+
local data = decoded.data
|
|
523
|
+
|
|
524
|
+
if not taskId or not command then return end
|
|
525
|
+
if not shouldProcessTask(taskId) then return end -- Bug G: Dedup!
|
|
526
|
+
|
|
527
|
+
task.spawn(function()
|
|
528
|
+
local resultData = processTask({
|
|
529
|
+
id = taskId,
|
|
530
|
+
command = command,
|
|
531
|
+
target = target or "",
|
|
532
|
+
data = data,
|
|
533
|
+
})
|
|
534
|
+
markTaskDone(taskId) -- Bug G: Tandai selesai
|
|
535
|
+
if resultData then
|
|
536
|
+
-- Push respons instan kembali via WebSocket (Zero-Delay)
|
|
537
|
+
local responseEnvelope = {
|
|
538
|
+
kind = "response",
|
|
539
|
+
taskId = taskId,
|
|
540
|
+
id = taskId,
|
|
541
|
+
attempt = attempt,
|
|
542
|
+
status = resultData.status,
|
|
543
|
+
result = sanitizeForJson(resultData.result),
|
|
544
|
+
error = resultData.error and tostring(resultData.error) or nil,
|
|
545
|
+
}
|
|
546
|
+
local encOk, encoded = pcall(function()
|
|
547
|
+
return HttpService:JSONEncode(responseEnvelope)
|
|
548
|
+
end)
|
|
549
|
+
local sent = false
|
|
550
|
+
if encOk then
|
|
551
|
+
local sendOk, _ = wrapper:Send(encoded)
|
|
552
|
+
sent = (sendOk == true)
|
|
553
|
+
end
|
|
554
|
+
if not sent then
|
|
555
|
+
-- Jika serialisasi atau pengiriman via WS gagal, fallback ke HTTP POST
|
|
556
|
+
sendResponse(taskId, resultData, attempt)
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
end)
|
|
560
|
+
return
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
-- ── Proses satu event dari SSE stream (Long-Polling fallback) ────────────────
|
|
226
565
|
local function handleSSELine(line)
|
|
227
|
-
-- Format SSE: "data: {...json...}"
|
|
228
566
|
local payload = line
|
|
229
567
|
if line:sub(1, 6) == "event:" then return end
|
|
230
568
|
if line:sub(1, 5) == "data:" then
|
|
@@ -237,12 +575,10 @@ local function handleSSELine(line)
|
|
|
237
575
|
|
|
238
576
|
local kind = decoded.kind
|
|
239
577
|
|
|
240
|
-
-- heartbeat: server masih hidup
|
|
241
578
|
if kind == "heartbeat" then
|
|
242
579
|
return
|
|
243
580
|
end
|
|
244
581
|
|
|
245
|
-
-- status: update mcpConnected
|
|
246
582
|
if kind == "status" then
|
|
247
583
|
local newActive = decoded.mcpConnected == true
|
|
248
584
|
local changed = (mcpAgentActive ~= newActive)
|
|
@@ -263,14 +599,15 @@ local function handleSSELine(line)
|
|
|
263
599
|
return
|
|
264
600
|
end
|
|
265
601
|
|
|
266
|
-
-- request: ada task dari AI agent
|
|
267
602
|
if kind == "request" then
|
|
268
|
-
local taskId = decoded.requestId
|
|
603
|
+
local taskId = decoded.requestId or decoded.taskId
|
|
604
|
+
local attempt = decoded.attempt
|
|
269
605
|
local command = decoded.command
|
|
270
606
|
local target = decoded.target
|
|
271
607
|
local data = decoded.data
|
|
272
608
|
|
|
273
609
|
if not taskId or not command then return end
|
|
610
|
+
if not shouldProcessTask(taskId) then return end -- Bug G: Dedup!
|
|
274
611
|
|
|
275
612
|
task.spawn(function()
|
|
276
613
|
local resultData = processTask({
|
|
@@ -279,39 +616,34 @@ local function handleSSELine(line)
|
|
|
279
616
|
target = target or "",
|
|
280
617
|
data = data,
|
|
281
618
|
})
|
|
619
|
+
markTaskDone(taskId) -- Bug G: Tandai selesai
|
|
282
620
|
if resultData then
|
|
283
|
-
sendResponse(taskId, resultData)
|
|
621
|
+
sendResponse(taskId, resultData, attempt)
|
|
284
622
|
end
|
|
285
623
|
end)
|
|
286
624
|
return
|
|
287
625
|
end
|
|
288
626
|
end
|
|
289
627
|
|
|
290
|
-
-- ── Loop
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
-- Plugin reconnect langsung untuk sesi berikutnya.
|
|
295
|
-
function startConnectionLoop()
|
|
296
|
-
if isRunning then return end
|
|
297
|
-
isRunning = true
|
|
298
|
-
isManualDisconnect = false
|
|
628
|
+
-- ── Fallback Loop: Long-Polling (/api/stream) ─────────────────────────────────
|
|
629
|
+
function startLongPollingLoop()
|
|
630
|
+
if not isRunning or isManualDisconnect then return end
|
|
631
|
+
currentTransport = "longpoll"
|
|
299
632
|
reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
300
633
|
|
|
301
634
|
local loopId = HttpService:GenerateGUID(false)
|
|
302
635
|
currentLoopId = loopId
|
|
303
636
|
|
|
304
637
|
if ui then
|
|
305
|
-
ui:addLog("[System]
|
|
638
|
+
ui:addLog("[System] Mode Long-Polling aktif (Port 3055)...", Color3.fromRGB(200, 200, 200))
|
|
306
639
|
end
|
|
307
640
|
|
|
308
641
|
local consecutiveErrors = 0
|
|
309
642
|
|
|
310
|
-
while isRunning and not isManualDisconnect and currentLoopId == loopId do
|
|
311
|
-
-- Stream / polling snapshot dari server
|
|
643
|
+
while isRunning and not isManualDisconnect and currentLoopId == loopId and currentTransport == "longpoll" do
|
|
312
644
|
local streamOk, streamRaw = pcall(function()
|
|
313
645
|
return HttpService:GetAsync(
|
|
314
|
-
STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID),
|
|
646
|
+
STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID) .. "&placeId=" .. tostring(game.PlaceId),
|
|
315
647
|
false
|
|
316
648
|
)
|
|
317
649
|
end)
|
|
@@ -324,28 +656,26 @@ function startConnectionLoop()
|
|
|
324
656
|
serverReachable = true
|
|
325
657
|
if ui then
|
|
326
658
|
ui:setStatus(mcpAgentActive and "connected" or "server_connected")
|
|
327
|
-
ui:addLog("[System] ✅ Server MCP terhubung
|
|
659
|
+
ui:addLog("[System] ✅ Server MCP terhubung via Long-Polling.", Color3.fromRGB(100, 255, 150))
|
|
328
660
|
end
|
|
329
661
|
end
|
|
330
662
|
|
|
331
|
-
-- Parse baris-baris SSE
|
|
332
663
|
for line in (streamRaw .. "\n"):gmatch("([^\n]*)\n") do
|
|
333
664
|
line = line:gsub("\r", "")
|
|
334
665
|
if line ~= "" then
|
|
335
666
|
handleSSELine(line)
|
|
336
667
|
end
|
|
337
668
|
end
|
|
338
|
-
task.wait(
|
|
669
|
+
task.wait()
|
|
339
670
|
else
|
|
340
671
|
consecutiveErrors = consecutiveErrors + 1
|
|
341
|
-
-- Toleransi 2x gagal sebelum menandai server unreachable
|
|
342
672
|
if consecutiveErrors >= 2 then
|
|
343
673
|
if serverReachable then
|
|
344
674
|
serverReachable = false
|
|
345
675
|
mcpAgentActive = false
|
|
346
676
|
if ui then
|
|
347
677
|
ui:setStatus("waiting")
|
|
348
|
-
ui:addLog("[System] ⚠️ Server MCP tidak dapat dijangkau.
|
|
678
|
+
ui:addLog("[System] ⚠️ Server MCP tidak dapat dijangkau. Menghubungkan ulang...", Color3.fromRGB(255, 150, 50))
|
|
349
679
|
end
|
|
350
680
|
end
|
|
351
681
|
end
|
|
@@ -355,22 +685,123 @@ function startConnectionLoop()
|
|
|
355
685
|
end
|
|
356
686
|
end
|
|
357
687
|
|
|
358
|
-
isRunning
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
688
|
+
if currentTransport == "longpoll" and (not isRunning or isManualDisconnect) then
|
|
689
|
+
isRunning = false
|
|
690
|
+
serverReachable = false
|
|
691
|
+
mcpAgentActive = false
|
|
692
|
+
if ui then ui:setStatus("disconnected") end
|
|
693
|
+
end
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
-- ── Connection Manager: WebSocket Utama + Fallback Long-Polling ──────────────
|
|
697
|
+
function startConnectionManager()
|
|
698
|
+
if isRunning then return end
|
|
699
|
+
isRunning = true
|
|
700
|
+
isManualDisconnect = false
|
|
701
|
+
|
|
702
|
+
local cap = detectWebSocketCapability()
|
|
703
|
+
if cap then
|
|
704
|
+
task.spawn(function()
|
|
705
|
+
local wsFailures = 0
|
|
706
|
+
local backoff = 0.5
|
|
707
|
+
|
|
708
|
+
while isRunning and not isManualDisconnect and wsFailures < 3 do
|
|
709
|
+
currentTransport = "websocket"
|
|
710
|
+
if ui then
|
|
711
|
+
ui:setStatus("waiting")
|
|
712
|
+
ui:addLog("[System] Menghubungkan via WebSocket (Port 3055)...", Color3.fromRGB(200, 200, 255))
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
local wrapper, err = createWsClient(WS_URL)
|
|
716
|
+
if not wrapper then
|
|
717
|
+
-- Coba fallback ke IP 127.0.0.1
|
|
718
|
+
wrapper, err = createWsClient(WS_URL_IP)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
if wrapper then
|
|
722
|
+
activeWsWrapper = wrapper
|
|
723
|
+
serverReachable = true
|
|
724
|
+
if ui then
|
|
725
|
+
ui:setStatus(mcpAgentActive and "connected" or "server_connected")
|
|
726
|
+
ui:addLog("[System] ⚡ WebSocket Terhubung! (Ultra Low-Latency <1ms)", Color3.fromRGB(100, 255, 150))
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
-- Kirim pesan registrasi awal
|
|
730
|
+
local regOk = wrapper:Send(HttpService:JSONEncode({
|
|
731
|
+
kind = "register",
|
|
732
|
+
studioId = STUDIO_ID,
|
|
733
|
+
placeId = game.PlaceId,
|
|
734
|
+
pluginsVersion = 2,
|
|
735
|
+
}))
|
|
736
|
+
|
|
737
|
+
if regOk then
|
|
738
|
+
wsFailures = 0
|
|
739
|
+
backoff = 0.5
|
|
740
|
+
|
|
741
|
+
local closedSignal = Instance.new("BindableEvent")
|
|
742
|
+
wrapper:BindEvents(
|
|
743
|
+
function(msg)
|
|
744
|
+
handleWsMessage(wrapper, msg)
|
|
745
|
+
end,
|
|
746
|
+
function()
|
|
747
|
+
closedSignal:Fire()
|
|
748
|
+
end
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
closedSignal.Event:Wait()
|
|
752
|
+
closedSignal:Destroy()
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
if activeWsWrapper == wrapper then
|
|
756
|
+
activeWsWrapper = nil
|
|
757
|
+
end
|
|
758
|
+
wrapper:Close()
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
if isManualDisconnect or not isRunning then break end
|
|
762
|
+
|
|
763
|
+
wsFailures = wsFailures + 1
|
|
764
|
+
if wsFailures < 3 then
|
|
765
|
+
if ui then
|
|
766
|
+
ui:setStatus("waiting")
|
|
767
|
+
ui:addLog("[System] ⚠️ WebSocket terputus. Reconnect dalam " .. string.format("%.1f", backoff) .. "s...", Color3.fromRGB(255, 180, 50))
|
|
768
|
+
end
|
|
769
|
+
task.wait(backoff)
|
|
770
|
+
backoff = math.min(backoff * 2, 5.0)
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
-- Jika WS gagal 3x berturut-turut dan plugin masih aktif -> Beralih ke Long-Polling!
|
|
775
|
+
if isRunning and not isManualDisconnect then
|
|
776
|
+
if ui then
|
|
777
|
+
ui:addLog("[System] ℹ️ Mengalihkan ke Mode Fallback Long-Polling...", Color3.fromRGB(255, 200, 100))
|
|
778
|
+
end
|
|
779
|
+
startLongPollingLoop()
|
|
780
|
+
end
|
|
781
|
+
end)
|
|
782
|
+
else
|
|
783
|
+
-- WebSocket tidak didukung di environment Studio ini -> langsung Long-Polling
|
|
784
|
+
if ui then
|
|
785
|
+
ui:addLog("[System] ℹ️ WebSocket tidak terdeteksi. Menggunakan Long-Polling.", Color3.fromRGB(200, 200, 200))
|
|
786
|
+
end
|
|
787
|
+
startLongPollingLoop()
|
|
788
|
+
end
|
|
362
789
|
end
|
|
363
790
|
|
|
364
791
|
-- ── Plugin Unloading ──────────────────────────────────────────────────────────
|
|
365
792
|
plugin.Unloading:Connect(function()
|
|
366
793
|
isManualDisconnect = true
|
|
367
794
|
isRunning = false
|
|
795
|
+
if activeWsWrapper then
|
|
796
|
+
activeWsWrapper:Close()
|
|
797
|
+
activeWsWrapper = nil
|
|
798
|
+
end
|
|
368
799
|
end)
|
|
369
800
|
|
|
370
801
|
-- ══════════════════════════════════════════════════════════════════════
|
|
371
|
-
-- STARTUP: Langsung mulai connection
|
|
802
|
+
-- STARTUP: Langsung mulai connection manager
|
|
372
803
|
-- ══════════════════════════════════════════════════════════════════════
|
|
373
804
|
task.spawn(function()
|
|
374
805
|
task.wait(0.5) -- Tunggu UI siap
|
|
375
|
-
|
|
806
|
+
startConnectionManager()
|
|
376
807
|
end)
|