@adiholick/nvstudio-mcp 2.0.3 → 2.1.0
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 +241 -64
- package/package.json +1 -1
- package/studio-plugin/Main.server.lua +287 -205
- package/studio-plugin/nvstudio_mcp.rbxmx +288 -206
|
@@ -1,150 +1,133 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
2
|
+
-- NVStudio MCP Plugin v2.1.0
|
|
3
|
+
-- Arsitektur: SSE (Server-Sent Events) via WebStreamClient
|
|
4
|
+
-- Plugin membuka koneksi stream persisten ke server Node.js.
|
|
5
|
+
-- Server PUSH task langsung — tidak ada polling loop.
|
|
6
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
3
7
|
|
|
4
|
-
local
|
|
5
|
-
local
|
|
8
|
+
local HttpService = game:GetService("HttpService")
|
|
9
|
+
local LogService = game:GetService("LogService")
|
|
6
10
|
|
|
7
|
-
--
|
|
8
|
-
local
|
|
11
|
+
-- URL Endpoints
|
|
12
|
+
local BASE_URL = "http://localhost:3055"
|
|
13
|
+
local STREAM_URL = BASE_URL .. "/api/stream"
|
|
14
|
+
local RESPONSE_URL = BASE_URL .. "/api/response" -- /api/response/:taskId via POST
|
|
15
|
+
local PING_URL = BASE_URL .. "/api/ping"
|
|
9
16
|
|
|
10
|
-
--
|
|
17
|
+
-- ID unik per sesi Studio (jika dua Studio terbuka, keduanya didaftarkan terpisah)
|
|
18
|
+
local STUDIO_ID = HttpService:GenerateGUID(false)
|
|
19
|
+
|
|
20
|
+
-- ── UI Module ──────────────────────────────────────────────────────────────────
|
|
11
21
|
local uiModule = script.Parent:FindFirstChild("ui_manager")
|
|
12
22
|
local ui = nil
|
|
13
23
|
|
|
14
|
-
--
|
|
15
|
-
local toolbar
|
|
24
|
+
-- ── Toolbar ───────────────────────────────────────────────────────────────────
|
|
25
|
+
local toolbar = plugin:CreateToolbar("NVStudio MCP")
|
|
16
26
|
local toggleBtn = toolbar:CreateButton("Dashboard", "Buka Dashboard NVStudio MCP", "rbxassetid://82696893597722", "NVStudio")
|
|
17
27
|
|
|
18
|
-
--
|
|
28
|
+
-- ── State ─────────────────────────────────────────────────────────────────────
|
|
19
29
|
local ctx = {
|
|
20
30
|
scriptHistory = {},
|
|
21
|
-
recentLogs
|
|
22
|
-
HttpService
|
|
23
|
-
studioId
|
|
31
|
+
recentLogs = {},
|
|
32
|
+
HttpService = HttpService,
|
|
33
|
+
studioId = STUDIO_ID,
|
|
24
34
|
}
|
|
25
|
-
local MAX_LOGS
|
|
26
|
-
local isConnected
|
|
27
|
-
local
|
|
28
|
-
local
|
|
29
|
-
local isClickDebounce
|
|
35
|
+
local MAX_LOGS = 50
|
|
36
|
+
local isConnected = false -- apakah SSE stream aktif
|
|
37
|
+
local mcpAgentActive = false -- apakah Antigravity/Cursor sedang konek ke MCP
|
|
38
|
+
local isManualDisconnect = false
|
|
39
|
+
local isClickDebounce = false
|
|
40
|
+
local currentStream = nil -- WebStreamClient aktif
|
|
41
|
+
local streamConnections = {} -- RBXScriptConnection SSE
|
|
42
|
+
|
|
43
|
+
-- Reconnect config (exponential backoff)
|
|
44
|
+
local RECONNECT_DELAY_INITIAL = 0.5
|
|
45
|
+
local RECONNECT_DELAY_MAX = 10
|
|
46
|
+
local reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
30
47
|
|
|
31
|
-
--
|
|
32
|
-
local
|
|
33
|
-
|
|
48
|
+
-- ── Skills (ModuleScript) ─────────────────────────────────────────────────────
|
|
49
|
+
local skills = {}
|
|
50
|
+
for _, module in ipairs(script.Parent:GetChildren()) do
|
|
51
|
+
if module:IsA("ModuleScript") and module.Name ~= "ui_manager" then
|
|
52
|
+
local ok, result = pcall(require, module)
|
|
53
|
+
if ok then
|
|
54
|
+
skills[module.Name] = result
|
|
55
|
+
else
|
|
56
|
+
warn("[NVStudio] Gagal load module '" .. module.Name .. "': " .. tostring(result))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
34
60
|
|
|
35
|
-
-- Inisialisasi UI
|
|
61
|
+
-- ── Inisialisasi UI ──────────────────────────────────────────────────────────
|
|
36
62
|
if uiModule then
|
|
37
63
|
local UIManager = require(uiModule)
|
|
38
64
|
ui = UIManager.init(plugin, STUDIO_ID)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
ui:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
-- Sync state tombol toolbar dengan status widget
|
|
65
|
+
|
|
66
|
+
ui:setAutoConnectState(true)
|
|
67
|
+
ui:setStatus("waiting")
|
|
68
|
+
|
|
45
69
|
if ui.widget then
|
|
46
70
|
toggleBtn:SetActive(ui.widget.Enabled)
|
|
47
71
|
ui.widget:GetPropertyChangedSignal("Enabled"):Connect(function()
|
|
48
72
|
toggleBtn:SetActive(ui.widget.Enabled)
|
|
49
73
|
end)
|
|
50
74
|
end
|
|
51
|
-
|
|
52
|
-
-- Event Toggle GUI Panel
|
|
75
|
+
|
|
53
76
|
toggleBtn.Click:Connect(function()
|
|
54
77
|
ui:toggleVisibility()
|
|
55
78
|
end)
|
|
56
|
-
|
|
57
|
-
--
|
|
79
|
+
|
|
80
|
+
-- Tombol Connect / Disconnect
|
|
58
81
|
ui.connectBtn.MouseButton1Click:Connect(function()
|
|
59
82
|
if isClickDebounce then return end
|
|
60
83
|
isClickDebounce = true
|
|
61
|
-
|
|
84
|
+
|
|
62
85
|
if not isConnected then
|
|
63
|
-
-- CONNECT (manual) — reset manual disconnect flag agar auto-detect bisa bekerja
|
|
64
86
|
isManualDisconnect = false
|
|
65
|
-
|
|
87
|
+
reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
66
88
|
ui:setStatus("waiting")
|
|
67
|
-
ui:addLog("[System]
|
|
68
|
-
|
|
89
|
+
ui:addLog("[System] Menghubungkan ulang ke Server MCP...", Color3.fromRGB(255, 220, 100))
|
|
90
|
+
task.spawn(connectSSE)
|
|
69
91
|
else
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
isManualDisconnect = true
|
|
93
|
+
disconnectSSE()
|
|
72
94
|
ui:setStatus("disconnected")
|
|
73
95
|
ui:addLog("[System] Koneksi dihentikan oleh pengguna.", Color3.fromRGB(255, 100, 100))
|
|
74
96
|
end
|
|
75
|
-
|
|
76
|
-
task.delay(0.3, function()
|
|
77
|
-
isClickDebounce = false
|
|
78
|
-
end)
|
|
79
|
-
end)
|
|
80
|
-
|
|
81
|
-
-- Event Custom Checkbox (Auto-Connect)
|
|
82
|
-
ui.autoConnectBtn.MouseButton1Click:Connect(function()
|
|
83
|
-
autoConnect = not autoConnect
|
|
84
|
-
plugin:SetSetting("AutoConnect", autoConnect)
|
|
85
|
-
ui:setAutoConnectState(autoConnect)
|
|
86
|
-
ui:addLog("[System] Preferensi Auto-Connect disimpan: " .. tostring(autoConnect), Color3.fromRGB(200, 200, 255))
|
|
97
|
+
|
|
98
|
+
task.delay(0.3, function() isClickDebounce = false end)
|
|
87
99
|
end)
|
|
88
100
|
else
|
|
89
|
-
-- Fallback aman jika pengguna lupa meng-copy ui_manager.lua
|
|
90
101
|
toggleBtn.Click:Connect(function()
|
|
91
|
-
print("[NVStudio MCP] ERROR: Modul 'ui_manager' tidak ditemukan
|
|
102
|
+
print("[NVStudio MCP] ERROR: Modul 'ui_manager' tidak ditemukan. GUI gagal dimuat!")
|
|
92
103
|
end)
|
|
93
104
|
end
|
|
94
105
|
|
|
95
|
-
--
|
|
106
|
+
-- ── Log Capture ──────────────────────────────────────────────────────────────
|
|
96
107
|
local function addSystemLog(msg, msgType)
|
|
97
|
-
local color
|
|
108
|
+
local color = Color3.fromRGB(200, 200, 200)
|
|
98
109
|
local prefix = "[INFO] "
|
|
99
110
|
if msgType == Enum.MessageType.MessageError then
|
|
100
|
-
prefix = "[ERROR] "
|
|
101
|
-
color = Color3.fromRGB(255, 100, 100)
|
|
111
|
+
prefix = "[ERROR] "; color = Color3.fromRGB(255, 100, 100)
|
|
102
112
|
elseif msgType == Enum.MessageType.MessageWarning then
|
|
103
|
-
prefix = "[WARN] "
|
|
104
|
-
color = Color3.fromRGB(255, 200, 100)
|
|
113
|
+
prefix = "[WARN] "; color = Color3.fromRGB(255, 200, 100)
|
|
105
114
|
end
|
|
106
|
-
|
|
107
115
|
local fullMsg = prefix .. msg
|
|
108
|
-
|
|
109
|
-
-- 1. Simpan di memori untuk disedot AI
|
|
110
116
|
table.insert(ctx.recentLogs, fullMsg)
|
|
111
|
-
if #ctx.recentLogs > MAX_LOGS then
|
|
112
|
-
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
-- 2. Cetak ke Terminal GUI Plugin
|
|
116
|
-
if ui then
|
|
117
|
-
ui:addLog(fullMsg, color)
|
|
118
|
-
end
|
|
117
|
+
if #ctx.recentLogs > MAX_LOGS then table.remove(ctx.recentLogs, 1) end
|
|
118
|
+
if ui then ui:addLog(fullMsg, color) end
|
|
119
119
|
end
|
|
120
|
-
|
|
121
|
-
-- Ikat listener output
|
|
122
120
|
LogService.MessageOut:Connect(addSystemLog)
|
|
123
121
|
|
|
124
|
-
--
|
|
125
|
-
local skills = {}
|
|
126
|
-
for _, module in ipairs(script.Parent:GetChildren()) do
|
|
127
|
-
if module:IsA("ModuleScript") and module.Name ~= "ui_manager" then
|
|
128
|
-
skills[module.Name] = require(module)
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
-- Fungsi Helper Pathing
|
|
122
|
+
-- ── Helper: Resolve path ke Instance ─────────────────────────────────────────
|
|
133
123
|
local function getInstanceFromPath(pathString)
|
|
134
|
-
local parts
|
|
124
|
+
local parts = string.split(pathString, ".")
|
|
135
125
|
local current = game
|
|
136
|
-
|
|
137
126
|
if parts[1] == "game" then table.remove(parts, 1) end
|
|
138
|
-
|
|
139
|
-
-- Handle common Services directly to ensure reliability
|
|
140
127
|
pcall(function()
|
|
141
128
|
local svc = game:GetService(parts[1])
|
|
142
|
-
if svc then
|
|
143
|
-
current = svc
|
|
144
|
-
table.remove(parts, 1)
|
|
145
|
-
end
|
|
129
|
+
if svc then current = svc; table.remove(parts, 1) end
|
|
146
130
|
end)
|
|
147
|
-
|
|
148
131
|
for _, part in ipairs(parts) do
|
|
149
132
|
local found = current:FindFirstChild(part)
|
|
150
133
|
if not found then return nil end
|
|
@@ -153,158 +136,257 @@ local function getInstanceFromPath(pathString)
|
|
|
153
136
|
return current
|
|
154
137
|
end
|
|
155
138
|
|
|
156
|
-
--
|
|
139
|
+
-- ── Proses Task dari Server ───────────────────────────────────────────────────
|
|
157
140
|
local function processTask(taskData)
|
|
158
141
|
if not taskData or not taskData.id or not taskData.command then return nil end
|
|
159
|
-
|
|
160
|
-
if ui then
|
|
161
|
-
ui:addLog("[AI Command]
|
|
142
|
+
|
|
143
|
+
if ui then
|
|
144
|
+
ui:addLog("[AI Command] " .. taskData.command, Color3.fromRGB(150, 200, 255))
|
|
162
145
|
end
|
|
163
|
-
|
|
146
|
+
|
|
164
147
|
local targetInstance = getInstanceFromPath(taskData.target)
|
|
165
148
|
if not targetInstance then
|
|
166
|
-
return {
|
|
167
|
-
id = taskData.id,
|
|
168
|
-
status = "error",
|
|
169
|
-
error = "Target path tidak ditemukan: " .. taskData.target
|
|
170
|
-
}
|
|
149
|
+
return { id = taskData.id, status = "error", error = "Target path tidak ditemukan: " .. tostring(taskData.target) }
|
|
171
150
|
end
|
|
172
151
|
|
|
173
152
|
local skillFunc = skills[taskData.command]
|
|
174
153
|
if not skillFunc then
|
|
175
|
-
return {
|
|
176
|
-
id = taskData.id,
|
|
177
|
-
status = "error",
|
|
178
|
-
error = "Command tidak dikenali oleh Studio: " .. taskData.command
|
|
179
|
-
}
|
|
154
|
+
return { id = taskData.id, status = "error", error = "Command tidak dikenali: " .. taskData.command }
|
|
180
155
|
end
|
|
181
156
|
|
|
182
|
-
-- Eksekusi skill AI
|
|
183
157
|
local result = skillFunc(targetInstance, taskData.data, ctx, taskData.target)
|
|
184
|
-
result.id
|
|
185
|
-
|
|
186
|
-
-- Cetak hasil eksekusi ke Terminal GUI
|
|
158
|
+
result.id = taskData.id
|
|
159
|
+
|
|
187
160
|
if ui then
|
|
188
161
|
local color = result.status == "success" and Color3.fromRGB(100, 255, 100) or Color3.fromRGB(255, 100, 100)
|
|
189
162
|
ui:addLog("[AI Result] " .. tostring(result.result or result.error), color)
|
|
190
163
|
end
|
|
191
|
-
|
|
192
|
-
return result
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
-- Menghentikan polling dengan bersih (dipanggil oleh tombol Disconnect)
|
|
196
|
-
local isManualDisconnect = false -- Mencegah auto-reconnect saat user sengaja disconnect
|
|
197
164
|
|
|
198
|
-
|
|
199
|
-
isConnected = false
|
|
200
|
-
isManualDisconnect = true
|
|
201
|
-
-- isPolling akan menjadi false secara otomatis saat loop keluar
|
|
165
|
+
return result
|
|
202
166
|
end
|
|
203
167
|
|
|
204
|
-
--
|
|
205
|
-
function
|
|
206
|
-
if isPolling then return end
|
|
207
|
-
isPolling = true
|
|
208
|
-
|
|
168
|
+
-- ── Kirim Respons ke Server ───────────────────────────────────────────────────
|
|
169
|
+
local function sendResponse(taskId, resultData)
|
|
209
170
|
task.spawn(function()
|
|
210
|
-
|
|
211
|
-
local
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
171
|
+
local ok, err = pcall(function()
|
|
172
|
+
local json = HttpService:JSONEncode(resultData)
|
|
173
|
+
-- Coba endpoint baru dulu (/api/response/:taskId), fallback ke lama
|
|
174
|
+
local success = pcall(function()
|
|
175
|
+
HttpService:PostAsync(
|
|
176
|
+
RESPONSE_URL .. "/" .. taskId,
|
|
177
|
+
json,
|
|
178
|
+
Enum.HttpContentType.ApplicationJson
|
|
179
|
+
)
|
|
215
180
|
end)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if not isConnected then
|
|
219
|
-
break
|
|
181
|
+
if not success then
|
|
182
|
+
HttpService:PostAsync(RESPONSE_URL, json, Enum.HttpContentType.ApplicationJson)
|
|
220
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
|
+
end
|
|
221
190
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
191
|
+
-- ── Proses satu baris data SSE ────────────────────────────────────────────────
|
|
192
|
+
local function handleSSEMessage(rawMessage)
|
|
193
|
+
-- Strip prefix "data: " jika ada (beberapa Studio version memberikannya)
|
|
194
|
+
local payload = rawMessage
|
|
195
|
+
local normalized = rawMessage:gsub("\r\n", "\n"):gsub("\r", "\n")
|
|
196
|
+
if normalized:sub(1, 5) == "data:" then
|
|
197
|
+
payload = normalized:sub(6):gsub("^%s+", ""):gsub("%s+$", "")
|
|
198
|
+
end
|
|
199
|
+
if payload == "" then return end
|
|
225
200
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
201
|
+
local ok, decoded = pcall(function() return HttpService:JSONDecode(payload) end)
|
|
202
|
+
if not ok or type(decoded) ~= "table" then return end
|
|
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))
|
|
238
224
|
end
|
|
239
|
-
|
|
240
|
-
-- Fast-polling (long-polling yield di server Node.js)
|
|
241
|
-
-- 50ms untuk safety mencegah Studio Engine freeze jika Node.js membalas instant
|
|
242
|
-
task.wait(0.05)
|
|
243
|
-
else
|
|
244
|
-
-- Fetch gagal (Node.js server mati karena IDE/Antigravity/Cursor ditutup)
|
|
245
|
-
if ui then ui:setStatus("waiting") end
|
|
246
|
-
task.wait(1)
|
|
247
225
|
end
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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)
|
|
252
249
|
end
|
|
250
|
+
end)
|
|
251
|
+
return
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
-- ── Tutup SSE stream yang aktif ───────────────────────────────────────────────
|
|
256
|
+
function disconnectSSE()
|
|
257
|
+
isConnected = false
|
|
258
|
+
for _, conn in ipairs(streamConnections) do
|
|
259
|
+
pcall(function() conn:Disconnect() end)
|
|
260
|
+
end
|
|
261
|
+
streamConnections = {}
|
|
262
|
+
if currentStream then
|
|
263
|
+
pcall(function() currentStream:Close() end)
|
|
264
|
+
currentStream = nil
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
-- ── Buka SSE stream baru ──────────────────────────────────────────────────────
|
|
269
|
+
function connectSSE()
|
|
270
|
+
if isConnected then return end
|
|
271
|
+
disconnectSSE() -- Bersihkan sisa koneksi lama
|
|
272
|
+
|
|
273
|
+
local url = STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID)
|
|
274
|
+
|
|
275
|
+
local ok, streamOrErr = pcall(function()
|
|
276
|
+
return HttpService:GetAsync(url, false) -- Gunakan non-cached request
|
|
277
|
+
end)
|
|
278
|
+
|
|
279
|
+
-- WebStreamClient hanya tersedia di Studio; GetAsync biasa tidak akan
|
|
280
|
+
-- mempertahankan koneksi. Kita butuh WebStreamClient khusus.
|
|
281
|
+
-- Coba via WebStreamClient API:
|
|
282
|
+
local streamOk, stream = pcall(function()
|
|
283
|
+
-- @ts-ignore – WebStreamClient mungkin belum ada di semua versi Studio
|
|
284
|
+
return (HttpService :: any):WebStream(url)
|
|
285
|
+
end)
|
|
286
|
+
|
|
287
|
+
if not streamOk or not stream then
|
|
288
|
+
-- Fallback: Studio tidak mendukung WebStreamClient → gunakan polling biasa
|
|
289
|
+
if ui then
|
|
290
|
+
ui:addLog("[System] WebStreamClient tidak tersedia. Menggunakan polling fallback.", Color3.fromRGB(255, 200, 100))
|
|
291
|
+
end
|
|
292
|
+
task.spawn(fallbackPolling)
|
|
293
|
+
return
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
currentStream = stream
|
|
297
|
+
isConnected = true
|
|
298
|
+
reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
299
|
+
|
|
300
|
+
if ui then
|
|
301
|
+
ui:setStatus("waiting") -- Menunggu status mcpConnected dari server
|
|
302
|
+
ui:addLog("[System] SSE stream terbuka. Menunggu IDE Agent...", Color3.fromRGB(100, 200, 255))
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
-- Event: pesan masuk dari SSE stream
|
|
306
|
+
local onMessage = stream.OnMessage:Connect(function(message)
|
|
307
|
+
handleSSEMessage(message)
|
|
308
|
+
end)
|
|
309
|
+
table.insert(streamConnections, onMessage)
|
|
310
|
+
|
|
311
|
+
-- Event: stream ditutup (server restart / network drop)
|
|
312
|
+
local onClose = stream.OnClose:Connect(function()
|
|
313
|
+
isConnected = false
|
|
314
|
+
mcpAgentActive = false
|
|
315
|
+
currentStream = nil
|
|
316
|
+
streamConnections = {}
|
|
317
|
+
if ui then
|
|
318
|
+
ui:setStatus("waiting")
|
|
319
|
+
ui:addLog("[System] SSE stream terputus. Reconnect dalam " .. reconnectDelay .. "s...", Color3.fromRGB(255, 200, 100))
|
|
320
|
+
end
|
|
321
|
+
if not isManualDisconnect then
|
|
322
|
+
task.delay(reconnectDelay, connectSSE)
|
|
323
|
+
reconnectDelay = math.min(reconnectDelay * 2, RECONNECT_DELAY_MAX)
|
|
253
324
|
end
|
|
254
|
-
isPolling = false
|
|
255
325
|
end)
|
|
326
|
+
table.insert(streamConnections, onClose)
|
|
256
327
|
end
|
|
257
328
|
|
|
258
|
-
--
|
|
259
|
-
--
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
329
|
+
-- ── Fallback: HTTP Polling (jika WebStreamClient tidak tersedia) ──────────────
|
|
330
|
+
-- Digunakan pada versi Studio lama yang belum mendukung WebStreamClient.
|
|
331
|
+
local isPollingActive = false
|
|
332
|
+
function fallbackPolling()
|
|
333
|
+
if isPollingActive then return end
|
|
334
|
+
isPollingActive = true
|
|
335
|
+
isConnected = true
|
|
336
|
+
|
|
267
337
|
if ui then
|
|
268
|
-
ui:
|
|
338
|
+
ui:setStatus("waiting")
|
|
339
|
+
ui:addLog("[System] Mode Polling aktif (WebStreamClient tidak didukung).", Color3.fromRGB(200, 200, 100))
|
|
269
340
|
end
|
|
270
|
-
|
|
271
|
-
while
|
|
272
|
-
--
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
341
|
+
|
|
342
|
+
while isConnected and not isManualDisconnect do
|
|
343
|
+
-- Ping
|
|
344
|
+
local pingOk, _ = pcall(function()
|
|
345
|
+
return HttpService:GetAsync(PING_URL .. "?studioId=" .. STUDIO_ID .. "&t=" .. tostring(os.clock()))
|
|
346
|
+
end)
|
|
347
|
+
|
|
348
|
+
if pingOk then
|
|
349
|
+
if ui then ui:setStatus("connected") end
|
|
350
|
+
|
|
351
|
+
-- Long-poll tasks
|
|
352
|
+
local taskOk, taskResponse = pcall(function()
|
|
353
|
+
return HttpService:GetAsync(BASE_URL .. "/api/tasks?t=" .. tostring(os.clock()))
|
|
277
354
|
end)
|
|
278
|
-
|
|
279
|
-
if
|
|
280
|
-
|
|
281
|
-
if
|
|
282
|
-
|
|
283
|
-
if
|
|
284
|
-
|
|
285
|
-
ui:addLog("[System] Server MCP terdeteksi! Auto-connected.", Color3.fromRGB(100, 255, 100))
|
|
286
|
-
end
|
|
287
|
-
startPolling()
|
|
288
|
-
end
|
|
289
|
-
else
|
|
290
|
-
-- Server tidak aktif
|
|
291
|
-
if isConnected then
|
|
292
|
-
-- Server baru saja mati (sebelumnya connected) — polling loop juga akan exit
|
|
293
|
-
isConnected = false
|
|
294
|
-
if ui then
|
|
295
|
-
ui:setStatus("waiting")
|
|
296
|
-
ui:addLog("[System] Server MCP terputus. Menunggu reconnect...", Color3.fromRGB(255, 200, 100))
|
|
297
|
-
end
|
|
298
|
-
elseif not isManualDisconnect then
|
|
299
|
-
if ui then
|
|
300
|
-
ui:setStatus("waiting")
|
|
301
|
-
-- HANYA print error jika belum terkoneksi, untuk debugging
|
|
302
|
-
ui:addLog("[Debug] Deteksi gagal: " .. tostring(errorMsg), Color3.fromRGB(150, 150, 150))
|
|
355
|
+
|
|
356
|
+
if taskOk and taskResponse then
|
|
357
|
+
local decodeOk, taskData = pcall(function() return HttpService:JSONDecode(taskResponse) end)
|
|
358
|
+
if decodeOk and taskData and taskData.id then
|
|
359
|
+
local resultData = processTask(taskData)
|
|
360
|
+
if resultData then
|
|
361
|
+
sendResponse(taskData.id, resultData)
|
|
303
362
|
end
|
|
304
363
|
end
|
|
305
364
|
end
|
|
365
|
+
task.wait(0.1)
|
|
366
|
+
else
|
|
367
|
+
if ui then ui:setStatus("waiting") end
|
|
368
|
+
task.wait(1.5)
|
|
306
369
|
end
|
|
307
|
-
|
|
308
|
-
task.wait(2)
|
|
309
370
|
end
|
|
371
|
+
|
|
372
|
+
isPollingActive = false
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
-- ── Plugin Unloading ──────────────────────────────────────────────────────────
|
|
376
|
+
plugin.Unloading:Connect(function()
|
|
377
|
+
isManualDisconnect = true
|
|
378
|
+
disconnectSSE()
|
|
379
|
+
end)
|
|
380
|
+
|
|
381
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
382
|
+
-- STARTUP: Langsung buka SSE stream
|
|
383
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
384
|
+
task.spawn(function()
|
|
385
|
+
task.wait(0.5) -- Tunggu UI siap
|
|
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()
|
|
310
392
|
end)
|