@adiholick/nvstudio-mcp 2.0.4 → 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 +239 -81
- package/package.json +1 -1
- package/studio-plugin/Main.server.lua +290 -231
- package/studio-plugin/nvstudio_mcp.rbxmx +290 -231
|
@@ -1,176 +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
|
|
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"
|
|
14
|
+
local RESPONSE_URL = BASE_URL .. "/api/response" -- /api/response/:taskId via POST
|
|
15
|
+
local PING_URL = BASE_URL .. "/api/ping"
|
|
10
16
|
|
|
11
|
-
--
|
|
17
|
+
-- ID unik per sesi Studio (jika dua Studio terbuka, keduanya didaftarkan terpisah)
|
|
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
30
|
scriptHistory = {},
|
|
22
|
-
recentLogs
|
|
23
|
-
HttpService
|
|
24
|
-
studioId
|
|
31
|
+
recentLogs = {},
|
|
32
|
+
HttpService = HttpService,
|
|
33
|
+
studioId = STUDIO_ID,
|
|
25
34
|
}
|
|
26
|
-
local MAX_LOGS
|
|
27
|
-
local isConnected
|
|
28
|
-
local
|
|
29
|
-
local
|
|
30
|
-
local
|
|
31
|
-
local
|
|
32
|
-
local
|
|
33
|
-
|
|
34
|
-
--
|
|
35
|
-
local
|
|
36
|
-
local
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
47
|
+
|
|
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
|
|
60
|
+
|
|
61
|
+
-- ── Inisialisasi UI ──────────────────────────────────────────────────────────
|
|
39
62
|
if uiModule then
|
|
40
63
|
local UIManager = require(uiModule)
|
|
41
64
|
ui = UIManager.init(plugin, STUDIO_ID)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
ui:
|
|
45
|
-
|
|
46
|
-
ui:setStatus("waiting")
|
|
47
|
-
else
|
|
48
|
-
ui:setStatus("disconnected")
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
-- Sync state tombol toolbar dengan status widget
|
|
65
|
+
|
|
66
|
+
ui:setAutoConnectState(true)
|
|
67
|
+
ui:setStatus("waiting")
|
|
68
|
+
|
|
52
69
|
if ui.widget then
|
|
53
70
|
toggleBtn:SetActive(ui.widget.Enabled)
|
|
54
71
|
ui.widget:GetPropertyChangedSignal("Enabled"):Connect(function()
|
|
55
72
|
toggleBtn:SetActive(ui.widget.Enabled)
|
|
56
73
|
end)
|
|
57
74
|
end
|
|
58
|
-
|
|
59
|
-
-- Event Toggle GUI Panel
|
|
75
|
+
|
|
60
76
|
toggleBtn.Click:Connect(function()
|
|
61
77
|
ui:toggleVisibility()
|
|
62
78
|
end)
|
|
63
|
-
|
|
64
|
-
--
|
|
79
|
+
|
|
80
|
+
-- Tombol Connect / Disconnect
|
|
65
81
|
ui.connectBtn.MouseButton1Click:Connect(function()
|
|
66
82
|
if isClickDebounce then return end
|
|
67
83
|
isClickDebounce = true
|
|
68
|
-
|
|
84
|
+
|
|
69
85
|
if not isConnected then
|
|
70
|
-
-- CONNECT (manual) — reset manual disconnect flag agar auto-detect bisa bekerja
|
|
71
86
|
isManualDisconnect = false
|
|
87
|
+
reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
72
88
|
ui:setStatus("waiting")
|
|
73
|
-
ui:addLog("[System] Menghubungkan ke Server MCP...", Color3.fromRGB(255, 220, 100))
|
|
74
|
-
|
|
75
|
-
task.spawn(function()
|
|
76
|
-
local cacheBuster = "?t=" .. tostring(os.clock())
|
|
77
|
-
local success, _ = pcall(function()
|
|
78
|
-
return HttpService:GetAsync(PING_URL .. cacheBuster)
|
|
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)
|
|
89
|
+
ui:addLog("[System] Menghubungkan ulang ke Server MCP...", Color3.fromRGB(255, 220, 100))
|
|
90
|
+
task.spawn(connectSSE)
|
|
95
91
|
else
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
isManualDisconnect = true
|
|
93
|
+
disconnectSSE()
|
|
98
94
|
ui:setStatus("disconnected")
|
|
99
95
|
ui:addLog("[System] Koneksi dihentikan oleh pengguna.", Color3.fromRGB(255, 100, 100))
|
|
100
96
|
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))
|
|
97
|
+
|
|
98
|
+
task.delay(0.3, function() isClickDebounce = false end)
|
|
113
99
|
end)
|
|
114
100
|
else
|
|
115
|
-
-- Fallback aman jika pengguna lupa meng-copy ui_manager.lua
|
|
116
101
|
toggleBtn.Click:Connect(function()
|
|
117
|
-
print("[NVStudio MCP] ERROR: Modul 'ui_manager' tidak ditemukan
|
|
102
|
+
print("[NVStudio MCP] ERROR: Modul 'ui_manager' tidak ditemukan. GUI gagal dimuat!")
|
|
118
103
|
end)
|
|
119
104
|
end
|
|
120
105
|
|
|
121
|
-
--
|
|
106
|
+
-- ── Log Capture ──────────────────────────────────────────────────────────────
|
|
122
107
|
local function addSystemLog(msg, msgType)
|
|
123
|
-
local color
|
|
108
|
+
local color = Color3.fromRGB(200, 200, 200)
|
|
124
109
|
local prefix = "[INFO] "
|
|
125
110
|
if msgType == Enum.MessageType.MessageError then
|
|
126
|
-
prefix = "[ERROR] "
|
|
127
|
-
color = Color3.fromRGB(255, 100, 100)
|
|
111
|
+
prefix = "[ERROR] "; color = Color3.fromRGB(255, 100, 100)
|
|
128
112
|
elseif msgType == Enum.MessageType.MessageWarning then
|
|
129
|
-
prefix = "[WARN] "
|
|
130
|
-
color = Color3.fromRGB(255, 200, 100)
|
|
113
|
+
prefix = "[WARN] "; color = Color3.fromRGB(255, 200, 100)
|
|
131
114
|
end
|
|
132
|
-
|
|
133
115
|
local fullMsg = prefix .. msg
|
|
134
|
-
|
|
135
|
-
-- 1. Simpan di memori untuk disedot AI
|
|
136
116
|
table.insert(ctx.recentLogs, fullMsg)
|
|
137
|
-
if #ctx.recentLogs > MAX_LOGS then
|
|
138
|
-
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
-- 2. Cetak ke Terminal GUI Plugin
|
|
142
|
-
if ui then
|
|
143
|
-
ui:addLog(fullMsg, color)
|
|
144
|
-
end
|
|
117
|
+
if #ctx.recentLogs > MAX_LOGS then table.remove(ctx.recentLogs, 1) end
|
|
118
|
+
if ui then ui:addLog(fullMsg, color) end
|
|
145
119
|
end
|
|
146
|
-
|
|
147
|
-
-- Ikat listener output
|
|
148
120
|
LogService.MessageOut:Connect(addSystemLog)
|
|
149
121
|
|
|
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
|
|
122
|
+
-- ── Helper: Resolve path ke Instance ─────────────────────────────────────────
|
|
159
123
|
local function getInstanceFromPath(pathString)
|
|
160
|
-
local parts
|
|
124
|
+
local parts = string.split(pathString, ".")
|
|
161
125
|
local current = game
|
|
162
|
-
|
|
163
126
|
if parts[1] == "game" then table.remove(parts, 1) end
|
|
164
|
-
|
|
165
|
-
-- Handle common Services directly to ensure reliability
|
|
166
127
|
pcall(function()
|
|
167
128
|
local svc = game:GetService(parts[1])
|
|
168
|
-
if svc then
|
|
169
|
-
current = svc
|
|
170
|
-
table.remove(parts, 1)
|
|
171
|
-
end
|
|
129
|
+
if svc then current = svc; table.remove(parts, 1) end
|
|
172
130
|
end)
|
|
173
|
-
|
|
174
131
|
for _, part in ipairs(parts) do
|
|
175
132
|
local found = current:FindFirstChild(part)
|
|
176
133
|
if not found then return nil end
|
|
@@ -179,155 +136,257 @@ local function getInstanceFromPath(pathString)
|
|
|
179
136
|
return current
|
|
180
137
|
end
|
|
181
138
|
|
|
182
|
-
--
|
|
139
|
+
-- ── Proses Task dari Server ───────────────────────────────────────────────────
|
|
183
140
|
local function processTask(taskData)
|
|
184
141
|
if not taskData or not taskData.id or not taskData.command then return nil end
|
|
185
|
-
|
|
186
|
-
if ui then
|
|
187
|
-
ui:addLog("[AI Command]
|
|
142
|
+
|
|
143
|
+
if ui then
|
|
144
|
+
ui:addLog("[AI Command] " .. taskData.command, Color3.fromRGB(150, 200, 255))
|
|
188
145
|
end
|
|
189
|
-
|
|
146
|
+
|
|
190
147
|
local targetInstance = getInstanceFromPath(taskData.target)
|
|
191
148
|
if not targetInstance then
|
|
192
|
-
return {
|
|
193
|
-
id = taskData.id,
|
|
194
|
-
status = "error",
|
|
195
|
-
error = "Target path tidak ditemukan: " .. taskData.target
|
|
196
|
-
}
|
|
149
|
+
return { id = taskData.id, status = "error", error = "Target path tidak ditemukan: " .. tostring(taskData.target) }
|
|
197
150
|
end
|
|
198
151
|
|
|
199
152
|
local skillFunc = skills[taskData.command]
|
|
200
153
|
if not skillFunc then
|
|
201
|
-
return {
|
|
202
|
-
id = taskData.id,
|
|
203
|
-
status = "error",
|
|
204
|
-
error = "Command tidak dikenali oleh Studio: " .. taskData.command
|
|
205
|
-
}
|
|
154
|
+
return { id = taskData.id, status = "error", error = "Command tidak dikenali: " .. taskData.command }
|
|
206
155
|
end
|
|
207
156
|
|
|
208
|
-
-- Eksekusi skill AI
|
|
209
157
|
local result = skillFunc(targetInstance, taskData.data, ctx, taskData.target)
|
|
210
|
-
result.id
|
|
211
|
-
|
|
212
|
-
-- Cetak hasil eksekusi ke Terminal GUI
|
|
158
|
+
result.id = taskData.id
|
|
159
|
+
|
|
213
160
|
if ui then
|
|
214
161
|
local color = result.status == "success" and Color3.fromRGB(100, 255, 100) or Color3.fromRGB(255, 100, 100)
|
|
215
162
|
ui:addLog("[AI Result] " .. tostring(result.result or result.error), color)
|
|
216
163
|
end
|
|
217
|
-
|
|
218
|
-
return result
|
|
219
|
-
end
|
|
220
164
|
|
|
221
|
-
|
|
222
|
-
function stopPolling()
|
|
223
|
-
isConnected = false
|
|
224
|
-
isManualDisconnect = true
|
|
225
|
-
-- isPolling akan menjadi false secara otomatis saat loop keluar
|
|
165
|
+
return result
|
|
226
166
|
end
|
|
227
167
|
|
|
228
|
-
--
|
|
229
|
-
function
|
|
230
|
-
if isPolling then return end
|
|
231
|
-
isPolling = true
|
|
232
|
-
if ui and isConnected then ui:setStatus("connected") end
|
|
233
|
-
|
|
168
|
+
-- ── Kirim Respons ke Server ───────────────────────────────────────────────────
|
|
169
|
+
local function sendResponse(taskId, resultData)
|
|
234
170
|
task.spawn(function()
|
|
235
|
-
|
|
236
|
-
local
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
)
|
|
240
180
|
end)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if not isConnected then
|
|
244
|
-
break
|
|
181
|
+
if not success then
|
|
182
|
+
HttpService:PostAsync(RESPONSE_URL, json, Enum.HttpContentType.ApplicationJson)
|
|
245
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
|
|
246
190
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
250
200
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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))
|
|
263
224
|
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
225
|
end
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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)
|
|
277
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))
|
|
278
291
|
end
|
|
279
|
-
|
|
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)
|
|
280
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)
|
|
324
|
+
end
|
|
325
|
+
end)
|
|
326
|
+
table.insert(streamConnections, onClose)
|
|
281
327
|
end
|
|
282
328
|
|
|
283
|
-
--
|
|
284
|
-
--
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
+
|
|
291
337
|
if ui then
|
|
292
|
-
ui:
|
|
338
|
+
ui:setStatus("waiting")
|
|
339
|
+
ui:addLog("[System] Mode Polling aktif (WebStreamClient tidak didukung).", Color3.fromRGB(200, 200, 100))
|
|
293
340
|
end
|
|
294
|
-
|
|
295
|
-
while
|
|
296
|
-
--
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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")
|
|
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()))
|
|
354
|
+
end)
|
|
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)
|
|
326
362
|
end
|
|
327
363
|
end
|
|
328
364
|
end
|
|
365
|
+
task.wait(0.1)
|
|
366
|
+
else
|
|
367
|
+
if ui then ui:setStatus("waiting") end
|
|
368
|
+
task.wait(1.5)
|
|
329
369
|
end
|
|
330
|
-
|
|
331
|
-
task.wait(1.5)
|
|
332
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()
|
|
333
392
|
end)
|