@adiholick/nvstudio-mcp 2.1.0 → 2.1.2
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/roblox-bridge.js +96 -65
- package/package.json +1 -1
- package/studio-plugin/Main.server.lua +307 -317
- package/studio-plugin/nvstudio_mcp.rbxmx +321 -322
- package/studio-plugin/ui_manager.lua +14 -5
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
-- ══════════════════════════════════════════════════════════════════════
|
|
2
2
|
-- NVStudio MCP Plugin v2.1.0
|
|
3
|
-
-- Arsitektur: SSE
|
|
4
|
-
-- Plugin
|
|
5
|
-
--
|
|
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
6
|
-- ══════════════════════════════════════════════════════════════════════
|
|
7
7
|
|
|
8
8
|
local HttpService = game:GetService("HttpService")
|
|
@@ -10,11 +10,11 @@ local LogService = game:GetService("LogService")
|
|
|
10
10
|
|
|
11
11
|
-- URL Endpoints
|
|
12
12
|
local BASE_URL = "http://localhost:3055"
|
|
13
|
-
local STREAM_URL = BASE_URL .. "/api/stream"
|
|
14
|
-
local RESPONSE_URL = BASE_URL .. "/api/response"
|
|
13
|
+
local STREAM_URL = BASE_URL .. "/api/stream" -- SSE long-poll (blocking GET)
|
|
14
|
+
local RESPONSE_URL = BASE_URL .. "/api/response"
|
|
15
15
|
local PING_URL = BASE_URL .. "/api/ping"
|
|
16
16
|
|
|
17
|
-
-- ID unik per sesi Studio
|
|
17
|
+
-- ID unik per sesi Studio
|
|
18
18
|
local STUDIO_ID = HttpService:GenerateGUID(false)
|
|
19
19
|
|
|
20
20
|
-- ── UI Module ──────────────────────────────────────────────────────────────────
|
|
@@ -27,366 +27,356 @@ local toggleBtn = toolbar:CreateButton("Dashboard", "Buka Dashboard NVStudio MCP
|
|
|
27
27
|
|
|
28
28
|
-- ── State ─────────────────────────────────────────────────────────────────────
|
|
29
29
|
local ctx = {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
scriptHistory = {},
|
|
31
|
+
recentLogs = {},
|
|
32
|
+
HttpService = HttpService,
|
|
33
|
+
studioId = STUDIO_ID,
|
|
34
34
|
}
|
|
35
|
-
local MAX_LOGS
|
|
36
|
-
local
|
|
37
|
-
local mcpAgentActive = false -- apakah Antigravity/Cursor sedang konek ke MCP
|
|
35
|
+
local MAX_LOGS = 50
|
|
36
|
+
local isRunning = false -- apakah loop aktif
|
|
38
37
|
local isManualDisconnect = false
|
|
39
|
-
local isClickDebounce
|
|
40
|
-
local
|
|
41
|
-
local
|
|
38
|
+
local isClickDebounce = false
|
|
39
|
+
local mcpAgentActive = false -- apakah Antigravity sedang konek ke MCP
|
|
40
|
+
local serverReachable = false -- apakah server Node.js reachable
|
|
42
41
|
|
|
43
|
-
-- Reconnect config
|
|
44
|
-
local RECONNECT_DELAY_INITIAL =
|
|
45
|
-
local RECONNECT_DELAY_MAX =
|
|
42
|
+
-- Reconnect config
|
|
43
|
+
local RECONNECT_DELAY_INITIAL = 1
|
|
44
|
+
local RECONNECT_DELAY_MAX = 15
|
|
46
45
|
local reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
47
46
|
|
|
48
47
|
-- ── Skills (ModuleScript) ─────────────────────────────────────────────────────
|
|
49
48
|
local skills = {}
|
|
50
49
|
for _, module in ipairs(script.Parent:GetChildren()) do
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
59
58
|
end
|
|
60
59
|
|
|
61
60
|
-- ── Inisialisasi UI ──────────────────────────────────────────────────────────
|
|
62
61
|
if uiModule then
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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)
|
|
100
101
|
else
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
toggleBtn.Click:Connect(function()
|
|
103
|
+
print("[NVStudio MCP] ERROR: Modul 'ui_manager' tidak ditemukan. GUI gagal dimuat!")
|
|
104
|
+
end)
|
|
104
105
|
end
|
|
105
106
|
|
|
106
107
|
-- ── Log Capture ──────────────────────────────────────────────────────────────
|
|
107
108
|
local function addSystemLog(msg, msgType)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
119
120
|
end
|
|
120
121
|
LogService.MessageOut:Connect(addSystemLog)
|
|
121
122
|
|
|
122
123
|
-- ── Helper: Resolve path ke Instance ─────────────────────────────────────────
|
|
123
124
|
local function getInstanceFromPath(pathString)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
137
138
|
end
|
|
138
139
|
|
|
139
140
|
-- ── Proses Task dari Server ───────────────────────────────────────────────────
|
|
140
141
|
local function processTask(taskData)
|
|
141
|
-
|
|
142
|
+
if not taskData or not taskData.id or not taskData.command then return nil end
|
|
142
143
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
if ui then
|
|
145
|
+
ui:addLog("[AI Command] " .. taskData.command, Color3.fromRGB(150, 200, 255))
|
|
146
|
+
end
|
|
146
147
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
151
152
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
156
157
|
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
local result = skillFunc(targetInstance, taskData.data, ctx, taskData.target)
|
|
159
|
+
result.id = taskData.id
|
|
159
160
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
164
165
|
|
|
165
|
-
|
|
166
|
+
return result
|
|
166
167
|
end
|
|
167
168
|
|
|
168
169
|
-- ── Kirim Respons ke Server ───────────────────────────────────────────────────
|
|
169
170
|
local function sendResponse(taskId, resultData)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
end)
|
|
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)
|
|
189
189
|
end
|
|
190
190
|
|
|
191
|
-
-- ──
|
|
192
|
-
local function
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
local kind = decoded.kind
|
|
205
|
-
|
|
206
|
-
-- ── heartbeat ────────────────────────────────────────────────────
|
|
207
|
-
if kind == "heartbeat" then
|
|
208
|
-
-- Server masih hidup, tidak perlu tindakan khusus
|
|
209
|
-
return
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
-- ── status ───────────────────────────────────────────────────────
|
|
213
|
-
if kind == "status" then
|
|
214
|
-
local newMcpActive = decoded.mcpConnected == true
|
|
215
|
-
if newMcpActive ~= mcpAgentActive then
|
|
216
|
-
mcpAgentActive = newMcpActive
|
|
217
|
-
if ui then
|
|
218
|
-
if mcpAgentActive then
|
|
219
|
-
ui:setStatus("connected")
|
|
220
|
-
ui:addLog("[System] IDE Agent (Antigravity/Cursor) terhubung ke MCP.", Color3.fromRGB(100, 255, 100))
|
|
221
|
-
else
|
|
222
|
-
ui:setStatus("waiting")
|
|
223
|
-
ui:addLog("[System] IDE Agent terputus dari MCP.", Color3.fromRGB(255, 200, 100))
|
|
224
|
-
end
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
return
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
-- ── request (task dari AI agent) ─────────────────────────────────
|
|
231
|
-
if kind == "request" then
|
|
232
|
-
local taskId = decoded.requestId
|
|
233
|
-
local command = decoded.command
|
|
234
|
-
local target = decoded.target
|
|
235
|
-
local data = decoded.data
|
|
236
|
-
|
|
237
|
-
if not taskId or not command then return end
|
|
238
|
-
|
|
239
|
-
-- Proses task di coroutine terpisah agar tidak memblokir stream
|
|
240
|
-
task.spawn(function()
|
|
241
|
-
local resultData = processTask({
|
|
242
|
-
id = taskId,
|
|
243
|
-
command = command,
|
|
244
|
-
target = target or "",
|
|
245
|
-
data = data,
|
|
246
|
-
})
|
|
247
|
-
if resultData then
|
|
248
|
-
sendResponse(taskId, resultData)
|
|
249
|
-
end
|
|
250
|
-
end)
|
|
251
|
-
return
|
|
252
|
-
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
|
|
200
|
+
-- Tampilkan sebagai connected (karena server sse bridge aktif)
|
|
201
|
+
ui:setStatus("server_connected")
|
|
202
|
+
end
|
|
253
203
|
end
|
|
254
204
|
|
|
255
|
-
-- ──
|
|
256
|
-
function
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
266
264
|
end
|
|
267
265
|
|
|
268
|
-
-- ──
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
--
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
end
|
|
371
|
-
|
|
372
|
-
isPollingActive = false
|
|
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
|
+
-- No need to explicitly add log for IDE Agent wait every time it connects,
|
|
329
|
+
-- bridge connected log is enough.
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
elseif serverReachable and not mcpAgentActive then
|
|
333
|
+
-- Tidak ada perubahan tapi pastikan status benar
|
|
334
|
+
if ui then ui:setStatus("server_connected") end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
-- Langkah 2: Buka SSE stream (blocking hingga server menutup koneksi / ~10 detik)
|
|
339
|
+
-- Server akan mengirim events yang tertunda, heartbeat, lalu tutup.
|
|
340
|
+
if isRunning and not isManualDisconnect then
|
|
341
|
+
local streamOk, streamRaw = pcall(function()
|
|
342
|
+
return HttpService:GetAsync(
|
|
343
|
+
STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID),
|
|
344
|
+
false
|
|
345
|
+
)
|
|
346
|
+
end)
|
|
347
|
+
|
|
348
|
+
if streamOk and streamRaw and streamRaw ~= "" then
|
|
349
|
+
-- Parse baris-baris SSE dari response
|
|
350
|
+
for line in (streamRaw .. "\n"):gmatch("([^\n]*)\n") do
|
|
351
|
+
line = line:gsub("\r", "")
|
|
352
|
+
if line ~= "" then
|
|
353
|
+
handleSSELine(line)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
-- Jeda singkat sebelum cycle berikutnya
|
|
360
|
+
task.wait(0.5)
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
isRunning = false
|
|
365
|
+
serverReachable = false
|
|
366
|
+
mcpAgentActive = false
|
|
367
|
+
if ui then ui:setStatus("disconnected") end
|
|
373
368
|
end
|
|
374
369
|
|
|
375
370
|
-- ── Plugin Unloading ──────────────────────────────────────────────────────────
|
|
376
371
|
plugin.Unloading:Connect(function()
|
|
377
|
-
|
|
378
|
-
|
|
372
|
+
isManualDisconnect = true
|
|
373
|
+
isRunning = false
|
|
379
374
|
end)
|
|
380
375
|
|
|
381
376
|
-- ══════════════════════════════════════════════════════════════════════
|
|
382
|
-
-- STARTUP: Langsung
|
|
377
|
+
-- STARTUP: Langsung mulai connection loop
|
|
383
378
|
-- ══════════════════════════════════════════════════════════════════════
|
|
384
379
|
task.spawn(function()
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if ui then
|
|
388
|
-
ui:addLog("[System] NVStudio MCP v2.1.0 termuat. Membuka SSE stream...", Color3.fromRGB(200, 200, 200))
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
connectSSE()
|
|
380
|
+
task.wait(0.5) -- Tunggu UI siap
|
|
381
|
+
startConnectionLoop()
|
|
392
382
|
end)
|