@adiholick/nvstudio-mcp 2.0.4 → 2.1.1
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 +53 -9
- package/dist/roblox-bridge.js +272 -83
- package/package.json +1 -1
- package/studio-plugin/Main.server.lua +349 -301
- package/studio-plugin/nvstudio_mcp.rbxmx +357 -300
- package/studio-plugin/ui_manager.lua +12 -3
|
@@ -1,333 +1,381 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
2
|
+
-- NVStudio MCP Plugin v2.1.0
|
|
3
|
+
-- Arsitektur: SSE via Long-Poll (Roblox tidak punya native SSE client)
|
|
4
|
+
-- Plugin auto-connect saat dimuat, polling /api/ping lalu /api/stream
|
|
5
|
+
-- untuk mendapatkan events (status, request) dari server Node.js.
|
|
6
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
3
7
|
|
|
4
|
-
local
|
|
5
|
-
local
|
|
6
|
-
local RESPONSE_URL = "http://localhost:3055/api/response"
|
|
8
|
+
local HttpService = game:GetService("HttpService")
|
|
9
|
+
local LogService = game:GetService("LogService")
|
|
7
10
|
|
|
8
|
-
--
|
|
9
|
-
local
|
|
11
|
+
-- URL Endpoints
|
|
12
|
+
local BASE_URL = "http://localhost:3055"
|
|
13
|
+
local STREAM_URL = BASE_URL .. "/api/stream" -- SSE long-poll (blocking GET)
|
|
14
|
+
local RESPONSE_URL = BASE_URL .. "/api/response"
|
|
15
|
+
local PING_URL = BASE_URL .. "/api/ping"
|
|
10
16
|
|
|
11
|
-
--
|
|
17
|
+
-- ID unik per sesi Studio
|
|
18
|
+
local STUDIO_ID = HttpService:GenerateGUID(false)
|
|
19
|
+
|
|
20
|
+
-- ── UI Module ──────────────────────────────────────────────────────────────────
|
|
12
21
|
local uiModule = script.Parent:FindFirstChild("ui_manager")
|
|
13
22
|
local ui = nil
|
|
14
23
|
|
|
15
|
-
--
|
|
16
|
-
local toolbar
|
|
24
|
+
-- ── Toolbar ───────────────────────────────────────────────────────────────────
|
|
25
|
+
local toolbar = plugin:CreateToolbar("NVStudio MCP")
|
|
17
26
|
local toggleBtn = toolbar:CreateButton("Dashboard", "Buka Dashboard NVStudio MCP", "rbxassetid://82696893597722", "NVStudio")
|
|
18
27
|
|
|
19
|
-
--
|
|
28
|
+
-- ── State ─────────────────────────────────────────────────────────────────────
|
|
20
29
|
local ctx = {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
scriptHistory = {},
|
|
31
|
+
recentLogs = {},
|
|
32
|
+
HttpService = HttpService,
|
|
33
|
+
studioId = STUDIO_ID,
|
|
25
34
|
}
|
|
26
|
-
local MAX_LOGS
|
|
27
|
-
local
|
|
28
|
-
local
|
|
29
|
-
local
|
|
30
|
-
local
|
|
31
|
-
local
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
local
|
|
36
|
-
local
|
|
37
|
-
|
|
38
|
-
--
|
|
35
|
+
local MAX_LOGS = 50
|
|
36
|
+
local isRunning = false -- apakah loop aktif
|
|
37
|
+
local isManualDisconnect = false
|
|
38
|
+
local isClickDebounce = false
|
|
39
|
+
local mcpAgentActive = false -- apakah Antigravity sedang konek ke MCP
|
|
40
|
+
local serverReachable = false -- apakah server Node.js reachable
|
|
41
|
+
|
|
42
|
+
-- Reconnect config
|
|
43
|
+
local RECONNECT_DELAY_INITIAL = 1
|
|
44
|
+
local RECONNECT_DELAY_MAX = 15
|
|
45
|
+
local reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
46
|
+
|
|
47
|
+
-- ── Skills (ModuleScript) ─────────────────────────────────────────────────────
|
|
48
|
+
local skills = {}
|
|
49
|
+
for _, module in ipairs(script.Parent:GetChildren()) do
|
|
50
|
+
if module:IsA("ModuleScript") and module.Name ~= "ui_manager" then
|
|
51
|
+
local ok, result = pcall(require, module)
|
|
52
|
+
if ok then
|
|
53
|
+
skills[module.Name] = result
|
|
54
|
+
else
|
|
55
|
+
warn("[NVStudio] Gagal load module '" .. module.Name .. "': " .. tostring(result))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
-- ── Inisialisasi UI ──────────────────────────────────────────────────────────
|
|
39
61
|
if uiModule then
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
end)
|
|
80
|
-
|
|
81
|
-
isConnected = true
|
|
82
|
-
if success then
|
|
83
|
-
if ui then
|
|
84
|
-
ui:setStatus("connected")
|
|
85
|
-
ui:addLog("[System] Berhasil terhubung ke Server MCP!", Color3.fromRGB(100, 255, 100))
|
|
86
|
-
end
|
|
87
|
-
else
|
|
88
|
-
if ui then
|
|
89
|
-
ui:setStatus("waiting")
|
|
90
|
-
ui:addLog("[System] Server MCP belum aktif di port 3055. Menunggu koneksi...", Color3.fromRGB(255, 200, 100))
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
startPolling()
|
|
94
|
-
end)
|
|
95
|
-
else
|
|
96
|
-
-- DISCONNECT — hentikan polling dan tandai manual disconnect
|
|
97
|
-
stopPolling()
|
|
98
|
-
ui:setStatus("disconnected")
|
|
99
|
-
ui:addLog("[System] Koneksi dihentikan oleh pengguna.", Color3.fromRGB(255, 100, 100))
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
task.delay(0.3, function()
|
|
103
|
-
isClickDebounce = false
|
|
104
|
-
end)
|
|
105
|
-
end)
|
|
106
|
-
|
|
107
|
-
-- Event Custom Checkbox (Auto-Connect)
|
|
108
|
-
ui.autoConnectBtn.MouseButton1Click:Connect(function()
|
|
109
|
-
autoConnect = not autoConnect
|
|
110
|
-
plugin:SetSetting("AutoConnect", autoConnect)
|
|
111
|
-
ui:setAutoConnectState(autoConnect)
|
|
112
|
-
ui:addLog("[System] Preferensi Auto-Connect disimpan: " .. tostring(autoConnect), Color3.fromRGB(200, 200, 255))
|
|
113
|
-
end)
|
|
62
|
+
local UIManager = require(uiModule)
|
|
63
|
+
ui = UIManager.init(plugin, STUDIO_ID)
|
|
64
|
+
|
|
65
|
+
ui:setAutoConnectState(true)
|
|
66
|
+
ui:setStatus("waiting")
|
|
67
|
+
|
|
68
|
+
if ui.widget then
|
|
69
|
+
toggleBtn:SetActive(ui.widget.Enabled)
|
|
70
|
+
ui.widget:GetPropertyChangedSignal("Enabled"):Connect(function()
|
|
71
|
+
toggleBtn:SetActive(ui.widget.Enabled)
|
|
72
|
+
end)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
toggleBtn.Click:Connect(function()
|
|
76
|
+
ui:toggleVisibility()
|
|
77
|
+
end)
|
|
78
|
+
|
|
79
|
+
-- Tombol Connect / Disconnect
|
|
80
|
+
ui.connectBtn.MouseButton1Click:Connect(function()
|
|
81
|
+
if isClickDebounce then return end
|
|
82
|
+
isClickDebounce = true
|
|
83
|
+
|
|
84
|
+
if not isRunning then
|
|
85
|
+
isManualDisconnect = false
|
|
86
|
+
reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
87
|
+
ui:setStatus("waiting")
|
|
88
|
+
ui:addLog("[System] Menghubungkan ulang ke Server MCP...", Color3.fromRGB(255, 220, 100))
|
|
89
|
+
task.spawn(startConnectionLoop)
|
|
90
|
+
else
|
|
91
|
+
isManualDisconnect = true
|
|
92
|
+
isRunning = false
|
|
93
|
+
serverReachable = false
|
|
94
|
+
mcpAgentActive = false
|
|
95
|
+
ui:setStatus("disconnected")
|
|
96
|
+
ui:addLog("[System] Koneksi dihentikan oleh pengguna.", Color3.fromRGB(255, 100, 100))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
task.delay(0.3, function() isClickDebounce = false end)
|
|
100
|
+
end)
|
|
114
101
|
else
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
end)
|
|
102
|
+
toggleBtn.Click:Connect(function()
|
|
103
|
+
print("[NVStudio MCP] ERROR: Modul 'ui_manager' tidak ditemukan. GUI gagal dimuat!")
|
|
104
|
+
end)
|
|
119
105
|
end
|
|
120
106
|
|
|
121
|
-
--
|
|
107
|
+
-- ── Log Capture ──────────────────────────────────────────────────────────────
|
|
122
108
|
local function addSystemLog(msg, msgType)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
-- 1. Simpan di memori untuk disedot AI
|
|
136
|
-
table.insert(ctx.recentLogs, fullMsg)
|
|
137
|
-
if #ctx.recentLogs > MAX_LOGS then
|
|
138
|
-
table.remove(ctx.recentLogs, 1)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
-- 2. Cetak ke Terminal GUI Plugin
|
|
142
|
-
if ui then
|
|
143
|
-
ui:addLog(fullMsg, color)
|
|
144
|
-
end
|
|
109
|
+
local color = Color3.fromRGB(200, 200, 200)
|
|
110
|
+
local prefix = "[INFO] "
|
|
111
|
+
if msgType == Enum.MessageType.MessageError then
|
|
112
|
+
prefix = "[ERROR] "; color = Color3.fromRGB(255, 100, 100)
|
|
113
|
+
elseif msgType == Enum.MessageType.MessageWarning then
|
|
114
|
+
prefix = "[WARN] "; color = Color3.fromRGB(255, 200, 100)
|
|
115
|
+
end
|
|
116
|
+
local fullMsg = prefix .. msg
|
|
117
|
+
table.insert(ctx.recentLogs, fullMsg)
|
|
118
|
+
if #ctx.recentLogs > MAX_LOGS then table.remove(ctx.recentLogs, 1) end
|
|
119
|
+
if ui then ui:addLog(fullMsg, color) end
|
|
145
120
|
end
|
|
146
|
-
|
|
147
|
-
-- Ikat listener output
|
|
148
121
|
LogService.MessageOut:Connect(addSystemLog)
|
|
149
122
|
|
|
150
|
-
--
|
|
151
|
-
local skills = {}
|
|
152
|
-
for _, module in ipairs(script.Parent:GetChildren()) do
|
|
153
|
-
if module:IsA("ModuleScript") and module.Name ~= "ui_manager" then
|
|
154
|
-
skills[module.Name] = require(module)
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
-- Fungsi Helper Pathing
|
|
123
|
+
-- ── Helper: Resolve path ke Instance ─────────────────────────────────────────
|
|
159
124
|
local function getInstanceFromPath(pathString)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
for _, part in ipairs(parts) do
|
|
175
|
-
local found = current:FindFirstChild(part)
|
|
176
|
-
if not found then return nil end
|
|
177
|
-
current = found
|
|
178
|
-
end
|
|
179
|
-
return current
|
|
125
|
+
local parts = string.split(pathString, ".")
|
|
126
|
+
local current = game
|
|
127
|
+
if parts[1] == "game" then table.remove(parts, 1) end
|
|
128
|
+
pcall(function()
|
|
129
|
+
local svc = game:GetService(parts[1])
|
|
130
|
+
if svc then current = svc; table.remove(parts, 1) end
|
|
131
|
+
end)
|
|
132
|
+
for _, part in ipairs(parts) do
|
|
133
|
+
local found = current:FindFirstChild(part)
|
|
134
|
+
if not found then return nil end
|
|
135
|
+
current = found
|
|
136
|
+
end
|
|
137
|
+
return current
|
|
180
138
|
end
|
|
181
139
|
|
|
182
|
-
--
|
|
140
|
+
-- ── Proses Task dari Server ───────────────────────────────────────────────────
|
|
183
141
|
local function processTask(taskData)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
local result = skillFunc(targetInstance, taskData.data, ctx, taskData.target)
|
|
210
|
-
result.id = taskData.id
|
|
211
|
-
|
|
212
|
-
-- Cetak hasil eksekusi ke Terminal GUI
|
|
213
|
-
if ui then
|
|
214
|
-
local color = result.status == "success" and Color3.fromRGB(100, 255, 100) or Color3.fromRGB(255, 100, 100)
|
|
215
|
-
ui:addLog("[AI Result] " .. tostring(result.result or result.error), color)
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
return result
|
|
142
|
+
if not taskData or not taskData.id or not taskData.command then return nil end
|
|
143
|
+
|
|
144
|
+
if ui then
|
|
145
|
+
ui:addLog("[AI Command] " .. taskData.command, Color3.fromRGB(150, 200, 255))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
local targetInstance = getInstanceFromPath(taskData.target)
|
|
149
|
+
if not targetInstance then
|
|
150
|
+
return { id = taskData.id, status = "error", error = "Target path tidak ditemukan: " .. tostring(taskData.target) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
local skillFunc = skills[taskData.command]
|
|
154
|
+
if not skillFunc then
|
|
155
|
+
return { id = taskData.id, status = "error", error = "Command tidak dikenali: " .. taskData.command }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
local result = skillFunc(targetInstance, taskData.data, ctx, taskData.target)
|
|
159
|
+
result.id = taskData.id
|
|
160
|
+
|
|
161
|
+
if ui then
|
|
162
|
+
local color = result.status == "success" and Color3.fromRGB(100, 255, 100) or Color3.fromRGB(255, 100, 100)
|
|
163
|
+
ui:addLog("[AI Result] " .. tostring(result.result or result.error), color)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
return result
|
|
219
167
|
end
|
|
220
168
|
|
|
221
|
-
--
|
|
222
|
-
function
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
169
|
+
-- ── Kirim Respons ke Server ───────────────────────────────────────────────────
|
|
170
|
+
local function sendResponse(taskId, resultData)
|
|
171
|
+
task.spawn(function()
|
|
172
|
+
local ok, err = pcall(function()
|
|
173
|
+
local json = HttpService:JSONEncode(resultData)
|
|
174
|
+
local success = pcall(function()
|
|
175
|
+
HttpService:PostAsync(
|
|
176
|
+
RESPONSE_URL .. "/" .. taskId,
|
|
177
|
+
json,
|
|
178
|
+
Enum.HttpContentType.ApplicationJson
|
|
179
|
+
)
|
|
180
|
+
end)
|
|
181
|
+
if not success then
|
|
182
|
+
HttpService:PostAsync(RESPONSE_URL, json, Enum.HttpContentType.ApplicationJson)
|
|
183
|
+
end
|
|
184
|
+
end)
|
|
185
|
+
if not ok and ui then
|
|
186
|
+
ui:addLog("[System] Gagal kirim response: " .. tostring(err), Color3.fromRGB(255, 100, 100))
|
|
187
|
+
end
|
|
188
|
+
end)
|
|
226
189
|
end
|
|
227
190
|
|
|
228
|
-
--
|
|
229
|
-
function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
end)
|
|
241
|
-
|
|
242
|
-
-- PENTING: Cek ulang isConnected SETELAH HTTP request selesai.
|
|
243
|
-
if not isConnected then
|
|
244
|
-
break
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
if success and response then
|
|
248
|
-
-- JIKA PING/POLL SUKSES, Node.js menyala (IDE / Agen Sedang Terbuka!)
|
|
249
|
-
if ui then ui:setStatus("connected") end
|
|
250
|
-
|
|
251
|
-
local decodeSuccess, taskData = pcall(function()
|
|
252
|
-
return HttpService:JSONDecode(response)
|
|
253
|
-
end)
|
|
254
|
-
|
|
255
|
-
if decodeSuccess and taskData and taskData.id then
|
|
256
|
-
local resultData = processTask(taskData)
|
|
257
|
-
if resultData then
|
|
258
|
-
pcall(function()
|
|
259
|
-
local jsonResult = HttpService:JSONEncode(resultData)
|
|
260
|
-
HttpService:PostAsync(RESPONSE_URL, jsonResult, Enum.HttpContentType.ApplicationJson)
|
|
261
|
-
end)
|
|
262
|
-
end
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
-- Fast-polling (long-polling yield di server Node.js)
|
|
266
|
-
-- 50ms untuk safety mencegah Studio Engine freeze jika Node.js membalas instant
|
|
267
|
-
task.wait(0.05)
|
|
268
|
-
else
|
|
269
|
-
-- Fetch gagal (Node.js server mati karena IDE/Antigravity/Cursor ditutup)
|
|
270
|
-
if ui then ui:setStatus("waiting") end
|
|
271
|
-
task.wait(1.5)
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
-- Cek lagi sebelum sleep (agar disconnect lebih responsif)
|
|
275
|
-
if not isConnected then
|
|
276
|
-
break
|
|
277
|
-
end
|
|
278
|
-
end
|
|
279
|
-
isPolling = false
|
|
280
|
-
end)
|
|
191
|
+
-- ── Update UI berdasarkan state ───────────────────────────────────────────────
|
|
192
|
+
local function updateUIStatus()
|
|
193
|
+
if not ui then return end
|
|
194
|
+
if not serverReachable then
|
|
195
|
+
ui:setStatus("waiting")
|
|
196
|
+
elseif mcpAgentActive then
|
|
197
|
+
ui:setStatus("connected")
|
|
198
|
+
else
|
|
199
|
+
-- Server reachable tapi tidak ada AI agent → "server connected, waiting for agent"
|
|
200
|
+
-- Gunakan status "waiting" dengan pesan yang berbeda
|
|
201
|
+
ui:setStatus("waiting")
|
|
202
|
+
end
|
|
281
203
|
end
|
|
282
204
|
|
|
205
|
+
-- ── Proses satu event dari SSE stream ────────────────────────────────────────
|
|
206
|
+
local function handleSSELine(line)
|
|
207
|
+
-- Format SSE: "data: {...json...}"
|
|
208
|
+
local payload = line
|
|
209
|
+
if line:sub(1, 5) == "data:" then
|
|
210
|
+
payload = line:sub(6):gsub("^%s+", ""):gsub("%s+$", "")
|
|
211
|
+
end
|
|
212
|
+
if payload == "" then return end
|
|
213
|
+
|
|
214
|
+
local ok, decoded = pcall(function() return HttpService:JSONDecode(payload) end)
|
|
215
|
+
if not ok or type(decoded) ~= "table" then return end
|
|
216
|
+
|
|
217
|
+
local kind = decoded.kind
|
|
218
|
+
|
|
219
|
+
-- heartbeat: server masih hidup
|
|
220
|
+
if kind == "heartbeat" then
|
|
221
|
+
return
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
-- status: update mcpConnected
|
|
225
|
+
if kind == "status" then
|
|
226
|
+
local newActive = decoded.mcpConnected == true
|
|
227
|
+
if newActive ~= mcpAgentActive then
|
|
228
|
+
mcpAgentActive = newActive
|
|
229
|
+
if ui then
|
|
230
|
+
if mcpAgentActive then
|
|
231
|
+
ui:setStatus("connected")
|
|
232
|
+
ui:addLog("[System] ✅ IDE Agent (Antigravity) terhubung ke MCP.", Color3.fromRGB(100, 255, 100))
|
|
233
|
+
else
|
|
234
|
+
ui:setStatus("waiting")
|
|
235
|
+
ui:addLog("[System] IDE Agent terputus dari MCP. Menunggu...", Color3.fromRGB(255, 200, 100))
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
return
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
-- request: ada task dari AI agent
|
|
243
|
+
if kind == "request" then
|
|
244
|
+
local taskId = decoded.requestId
|
|
245
|
+
local command = decoded.command
|
|
246
|
+
local target = decoded.target
|
|
247
|
+
local data = decoded.data
|
|
248
|
+
|
|
249
|
+
if not taskId or not command then return end
|
|
250
|
+
|
|
251
|
+
task.spawn(function()
|
|
252
|
+
local resultData = processTask({
|
|
253
|
+
id = taskId,
|
|
254
|
+
command = command,
|
|
255
|
+
target = target or "",
|
|
256
|
+
data = data,
|
|
257
|
+
})
|
|
258
|
+
if resultData then
|
|
259
|
+
sendResponse(taskId, resultData)
|
|
260
|
+
end
|
|
261
|
+
end)
|
|
262
|
+
return
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
-- ── Loop Utama: Ping → Stream → Parse SSE lines ───────────────────────────────
|
|
267
|
+
-- Roblox HttpService:GetAsync() tidak mendukung streaming, jadi kita
|
|
268
|
+
-- menggunakan strategi: GET /api/stream dengan waktu hold singkat (5 detik).
|
|
269
|
+
-- Server mengirim semua events yang tertunda lalu tutup koneksi.
|
|
270
|
+
-- Plugin reconnect langsung untuk sesi berikutnya.
|
|
271
|
+
function startConnectionLoop()
|
|
272
|
+
if isRunning then return end
|
|
273
|
+
isRunning = true
|
|
274
|
+
reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
275
|
+
|
|
276
|
+
if ui then
|
|
277
|
+
ui:addLog("[System] NVStudio MCP v2.1.0 — Menghubungkan ke server...", Color3.fromRGB(200, 200, 200))
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
while isRunning and not isManualDisconnect do
|
|
281
|
+
-- Langkah 1: Ping untuk cek apakah server hidup
|
|
282
|
+
local pingOk, pingRaw = pcall(function()
|
|
283
|
+
return HttpService:GetAsync(
|
|
284
|
+
PING_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID) .. "&t=" .. tostring(os.clock()),
|
|
285
|
+
false
|
|
286
|
+
)
|
|
287
|
+
end)
|
|
288
|
+
|
|
289
|
+
if not pingOk then
|
|
290
|
+
-- Server tidak reachable
|
|
291
|
+
if serverReachable then
|
|
292
|
+
serverReachable = false
|
|
293
|
+
mcpAgentActive = false
|
|
294
|
+
if ui then
|
|
295
|
+
ui:setStatus("waiting")
|
|
296
|
+
ui:addLog("[System] ⚠️ Server MCP tidak dapat dijangkau. Retry dalam " .. reconnectDelay .. "s...", Color3.fromRGB(255, 150, 50))
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
task.wait(reconnectDelay)
|
|
300
|
+
reconnectDelay = math.min(reconnectDelay * 2, RECONNECT_DELAY_MAX)
|
|
301
|
+
else
|
|
302
|
+
-- Server reachable → parse ping response
|
|
303
|
+
reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
304
|
+
|
|
305
|
+
local pingData = nil
|
|
306
|
+
pcall(function()
|
|
307
|
+
pingData = HttpService:JSONDecode(pingRaw)
|
|
308
|
+
end)
|
|
309
|
+
|
|
310
|
+
local wasReachable = serverReachable
|
|
311
|
+
serverReachable = true
|
|
312
|
+
|
|
313
|
+
if not wasReachable and ui then
|
|
314
|
+
ui:addLog("[System] ✅ Server MCP terhubung. Membuka event stream...", Color3.fromRGB(100, 255, 150))
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
-- Update mcpConnected dari ping response
|
|
318
|
+
if pingData then
|
|
319
|
+
local newActive = pingData.mcpConnected == true
|
|
320
|
+
if newActive ~= mcpAgentActive then
|
|
321
|
+
mcpAgentActive = newActive
|
|
322
|
+
if ui then
|
|
323
|
+
if mcpAgentActive then
|
|
324
|
+
ui:setStatus("connected")
|
|
325
|
+
ui:addLog("[System] ✅ IDE Agent (Antigravity) terhubung ke MCP.", Color3.fromRGB(100, 255, 100))
|
|
326
|
+
else
|
|
327
|
+
ui:setStatus("server_connected")
|
|
328
|
+
ui:addLog("[System] Menunggu IDE Agent (Antigravity/Cursor)...", Color3.fromRGB(100, 180, 255))
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
elseif serverReachable and not mcpAgentActive then
|
|
332
|
+
-- Tidak ada perubahan tapi pastikan status benar
|
|
333
|
+
if ui then ui:setStatus("server_connected") end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
-- Langkah 2: Buka SSE stream (blocking hingga server menutup koneksi / ~10 detik)
|
|
338
|
+
-- Server akan mengirim events yang tertunda, heartbeat, lalu tutup.
|
|
339
|
+
if isRunning and not isManualDisconnect then
|
|
340
|
+
local streamOk, streamRaw = pcall(function()
|
|
341
|
+
return HttpService:GetAsync(
|
|
342
|
+
STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID),
|
|
343
|
+
false
|
|
344
|
+
)
|
|
345
|
+
end)
|
|
346
|
+
|
|
347
|
+
if streamOk and streamRaw and streamRaw ~= "" then
|
|
348
|
+
-- Parse baris-baris SSE dari response
|
|
349
|
+
for line in (streamRaw .. "\n"):gmatch("([^\n]*)\n") do
|
|
350
|
+
line = line:gsub("\r", "")
|
|
351
|
+
if line ~= "" then
|
|
352
|
+
handleSSELine(line)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
-- Jeda singkat sebelum cycle berikutnya
|
|
359
|
+
task.wait(0.5)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
isRunning = false
|
|
364
|
+
serverReachable = false
|
|
365
|
+
mcpAgentActive = false
|
|
366
|
+
if ui then ui:setStatus("disconnected") end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
-- ── Plugin Unloading ──────────────────────────────────────────────────────────
|
|
370
|
+
plugin.Unloading:Connect(function()
|
|
371
|
+
isManualDisconnect = true
|
|
372
|
+
isRunning = false
|
|
373
|
+
end)
|
|
374
|
+
|
|
283
375
|
-- ══════════════════════════════════════════════════════════════════════
|
|
284
|
-
--
|
|
285
|
-
-- Mendeteksi apakah server MCP aktif setiap 1.5 detik via PING_URL.
|
|
286
|
-
-- Menggunakan /api/ping agar instan (tidak tertahan 20 detik oleh long-poll).
|
|
376
|
+
-- STARTUP: Langsung mulai connection loop
|
|
287
377
|
-- ══════════════════════════════════════════════════════════════════════
|
|
288
378
|
task.spawn(function()
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if ui then
|
|
292
|
-
ui:addLog("[System] Plugin termuat. Auto-detect server MCP aktif...", Color3.fromRGB(200, 200, 200))
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
while true do
|
|
296
|
-
-- Hanya jalankan auto-detect jika user tidak sengaja menekan Disconnect
|
|
297
|
-
if not isManualDisconnect and (autoConnect or isConnected) then
|
|
298
|
-
if not isPolling then
|
|
299
|
-
local success, _ = pcall(function()
|
|
300
|
-
local cacheBuster = "?t=" .. tostring(os.clock())
|
|
301
|
-
return HttpService:GetAsync(PING_URL .. cacheBuster)
|
|
302
|
-
end)
|
|
303
|
-
|
|
304
|
-
if success then
|
|
305
|
-
-- Server terdeteksi aktif via Ping!
|
|
306
|
-
if not isConnected then
|
|
307
|
-
isConnected = true
|
|
308
|
-
if ui then
|
|
309
|
-
ui:setStatus("connected")
|
|
310
|
-
ui:addLog("[System] Server MCP terdeteksi! Terhubung otomatis.", Color3.fromRGB(100, 255, 100))
|
|
311
|
-
end
|
|
312
|
-
startPolling()
|
|
313
|
-
else
|
|
314
|
-
startPolling()
|
|
315
|
-
end
|
|
316
|
-
else
|
|
317
|
-
-- Server tidak aktif / belum menyala
|
|
318
|
-
if isConnected then
|
|
319
|
-
isConnected = false
|
|
320
|
-
if ui then
|
|
321
|
-
ui:setStatus("waiting")
|
|
322
|
-
ui:addLog("[System] Server MCP terputus. Menunggu reconnect...", Color3.fromRGB(255, 200, 100))
|
|
323
|
-
end
|
|
324
|
-
elseif ui and ui.status ~= "waiting" and not isManualDisconnect then
|
|
325
|
-
ui:setStatus("waiting")
|
|
326
|
-
end
|
|
327
|
-
end
|
|
328
|
-
end
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
task.wait(1.5)
|
|
332
|
-
end
|
|
379
|
+
task.wait(0.5) -- Tunggu UI siap
|
|
380
|
+
startConnectionLoop()
|
|
333
381
|
end)
|