@adiholick/nvstudio-mcp 1.0.2 → 1.0.4

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 CHANGED
@@ -130,22 +130,24 @@ async function main() {
130
130
  const transport = new stdio_js_1.StdioServerTransport();
131
131
  await server.connect(transport);
132
132
  console.error("[nvstudio-mcp] MCP Server aktif melalui StdioServerTransport.");
133
+ let isShuttingDown = false;
133
134
  const shutdown = () => {
135
+ if (isShuttingDown)
136
+ return;
137
+ isShuttingDown = true;
134
138
  console.error("[nvstudio-mcp] Koneksi ditutup oleh IDE. Mematikan server...");
135
139
  process.exit(0);
136
140
  };
137
- // Tangani penutupan koneksi
141
+ // Tangani penutupan koneksi stdin (ketika IDE menutup pipe)
138
142
  process.stdin.on('close', shutdown);
139
143
  process.stdin.on('end', shutdown);
140
- // Fallback: Cek apakah parent process (IDE) masih hidup secara berkala
144
+ process.stdin.on('error', shutdown);
145
+ // Fallback khusus Windows: cek apakah stdin masih readable secara berkala
146
+ // process.kill(ppid, 0) tidak reliable di Windows, jadi kita pakai cara ini
141
147
  setInterval(() => {
142
- try {
143
- // process.kill dengan signal 0 hanya mengecek keberadaan proses tanpa membunuhnya
144
- process.kill(process.ppid, 0);
145
- }
146
- catch (e) {
147
- console.error("[nvstudio-mcp] Parent process (IDE) sudah mati. Mematikan server untuk menghindari zombie connection...");
148
- process.exit(0);
148
+ if (!process.stdin.readable) {
149
+ console.error("[nvstudio-mcp] stdin tidak readable lagi. IDE kemungkinan sudah ditutup.");
150
+ shutdown();
149
151
  }
150
152
  }, 3000);
151
153
  }
@@ -10,16 +10,92 @@ const socket_io_1 = require("socket.io");
10
10
  const path_1 = __importDefault(require("path"));
11
11
  const child_process_1 = require("child_process");
12
12
  const task_queue_1 = require("./task-queue");
13
+ const activityLogs = [];
14
+ let totalTasks = 0;
15
+ let successCount = 0;
16
+ let errorCount = 0;
17
+ const sessionStart = new Date().toISOString();
18
+ function addLog(io, type, message) {
19
+ const log = {
20
+ id: Date.now().toString(),
21
+ time: new Date().toLocaleTimeString('id-ID'),
22
+ type,
23
+ message,
24
+ };
25
+ activityLogs.unshift(log);
26
+ if (activityLogs.length > 200)
27
+ activityLogs.pop();
28
+ io.emit('log', log);
29
+ io.emit('stats', { totalTasks, successCount, errorCount, sessionStart });
30
+ }
31
+ /**
32
+ * Membunuh proses zombie yang masih menyandera port tertentu.
33
+ * Ini menyelesaikan masalah EADDRINUSE yang terjadi setelah restart IDE.
34
+ */
35
+ function killZombieOnPort(port) {
36
+ try {
37
+ if (process.platform === 'win32') {
38
+ const output = (0, child_process_1.execSync)(`netstat -ano | findstr :${port} | findstr LISTENING`, { encoding: 'utf8', timeout: 5000 });
39
+ const lines = output.trim().split('\n');
40
+ for (const line of lines) {
41
+ const parts = line.trim().split(/\s+/);
42
+ const pid = parts[parts.length - 1];
43
+ if (pid && pid !== '0' && pid !== String(process.pid)) {
44
+ try {
45
+ (0, child_process_1.execSync)(`taskkill /F /PID ${pid}`, { encoding: 'utf8', timeout: 5000 });
46
+ console.error(`[Bridge] Zombie process (PID ${pid}) pada port ${port} berhasil dihentikan.`);
47
+ }
48
+ catch { /* ignore if already dead */ }
49
+ }
50
+ }
51
+ return true;
52
+ }
53
+ else {
54
+ // macOS / Linux
55
+ const output = (0, child_process_1.execSync)(`lsof -ti :${port}`, { encoding: 'utf8', timeout: 5000 });
56
+ const pids = output.trim().split('\n');
57
+ for (const pid of pids) {
58
+ if (pid && pid !== String(process.pid)) {
59
+ try {
60
+ (0, child_process_1.execSync)(`kill -9 ${pid}`);
61
+ }
62
+ catch { /* ignore */ }
63
+ }
64
+ }
65
+ return true;
66
+ }
67
+ }
68
+ catch {
69
+ // Tidak ada proses yang menyandera port — ini normal
70
+ return false;
71
+ }
72
+ }
13
73
  function startBridgeServer(port = 3055) {
14
74
  const app = (0, express_1.default)();
15
75
  const httpServer = (0, http_1.createServer)(app);
16
76
  const io = new socket_io_1.Server(httpServer, { cors: { origin: "*" } });
17
- // FIX: Menaikkan limit ke 50mb agar file .Source (Lua) yang besar tidak melempar error 413 Payload Too Large
18
77
  app.use(express_1.default.json({ limit: '50mb' }));
19
78
  app.use(express_1.default.static(path_1.default.join(__dirname, '../public')));
20
79
  let dashboardOpened = false;
80
+ let studioConnected = false;
81
+ // Provide initial state to newly connected dashboard clients
82
+ io.on('connection', (socket) => {
83
+ socket.emit('init', {
84
+ logs: activityLogs,
85
+ stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected, port },
86
+ });
87
+ });
88
+ // API endpoint to get current stats
89
+ app.get('/api/stats', (req, res) => {
90
+ res.json({ totalTasks, successCount, errorCount, sessionStart, studioConnected, port });
91
+ });
21
92
  // Endpoint Studio Polling
22
93
  app.get('/api/tasks', (req, res) => {
94
+ if (!studioConnected) {
95
+ studioConnected = true;
96
+ io.emit('studio-status', { connected: true });
97
+ addLog(io, 'system', 'Roblox Studio terhubung ke Bridge Server.');
98
+ }
23
99
  if (!dashboardOpened) {
24
100
  dashboardOpened = true;
25
101
  const url = `http://localhost:${port}`;
@@ -32,7 +108,8 @@ function startBridgeServer(port = 3055) {
32
108
  }
33
109
  if (task_queue_1.taskQueue.length > 0) {
34
110
  const task = task_queue_1.taskQueue.shift();
35
- io.emit('log', `[AI Task] Mengirim perintah '${task?.command}' ke Studio.`);
111
+ totalTasks++;
112
+ addLog(io, 'task', `Mengirim perintah '${task?.command}' ke Studio${task?.target ? ` → ${task.target}` : ''}.`);
36
113
  res.json(task);
37
114
  }
38
115
  else {
@@ -42,14 +119,36 @@ function startBridgeServer(port = 3055) {
42
119
  // Endpoint Studio Response
43
120
  app.post('/api/response', (req, res) => {
44
121
  const { id, status, result, error } = req.body;
45
- io.emit('log', `[Studio Response] Status: ${status}`);
46
- if (error)
47
- io.emit('log', `[Error] ${error}`);
48
- // Memanggil fungsi resolve secara sinkron (menghindari promise import circular palsu)
122
+ if (status === 'success') {
123
+ successCount++;
124
+ addLog(io, 'response', `Perintah selesai: ${String(result).substring(0, 120)}`);
125
+ }
126
+ else {
127
+ errorCount++;
128
+ addLog(io, 'error', `Gagal: ${error}`);
129
+ }
49
130
  (0, task_queue_1.resolvePendingTask)(id, status, result, error);
50
131
  res.json({ message: "Response diterima" });
51
132
  });
52
- httpServer.listen(port, () => {
53
- console.error(`[Bridge] Dashboard visual & server aktif di http://localhost:${port}`);
54
- });
133
+ // ── ANTI-ZOMBIE: Coba listen, jika gagal bunuh zombie lalu retry ──
134
+ function tryListen(retriesLeft = 2) {
135
+ const server = httpServer.listen(port, () => {
136
+ console.error(`[Bridge] Dashboard visual & server aktif di http://localhost:${port}`);
137
+ });
138
+ server.on('error', (err) => {
139
+ if (err.code === 'EADDRINUSE' && retriesLeft > 0) {
140
+ console.error(`[Bridge] Port ${port} sedang digunakan oleh proses zombie. Mencoba membunuh...`);
141
+ killZombieOnPort(port);
142
+ setTimeout(() => {
143
+ console.error(`[Bridge] Mencoba listen ulang pada port ${port}... (sisa ${retriesLeft - 1} percobaan)`);
144
+ tryListen(retriesLeft - 1);
145
+ }, 1500);
146
+ }
147
+ else {
148
+ console.error(`[Bridge] FATAL: Gagal mendengarkan port ${port}:`, err.message);
149
+ process.exit(1);
150
+ }
151
+ });
152
+ }
153
+ tryListen();
55
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adiholick/nvstudio-mcp",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Local Bridge Server MCP for Roblox Studio",
5
5
  "main": "dist/index.js",
6
6
  "bin": "dist/index.js",
package/public/index.html CHANGED
@@ -1,34 +1,819 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="id">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>nvstudio-mcp | Live Dashboard</title>
6
+ <title>nvstudio-mcp | Dashboard</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
7
10
  <style>
8
- body { font-family: 'Consolas', monospace; background-color: #0f172a; color: #38bdf8; margin: 0; padding: 20px; }
9
- h1 { color: #f8fafc; font-size: 1.5rem; border-bottom: 1px solid #334155; padding-bottom: 10px; }
10
- #log-container { background-color: #1e293b; padding: 15px; border-radius: 8px; height: 75vh; overflow-y: auto; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
11
- .log-entry { margin-bottom: 8px; border-bottom: 1px solid #334155; padding-bottom: 4px; }
12
- .log-time { color: #94a3b8; font-size: 0.85em; margin-right: 10px; }
11
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
12
+
13
+ :root {
14
+ --bg: #0a0a0a;
15
+ --surface: #111111;
16
+ --surface2: #1a1a1a;
17
+ --surface3: #222222;
18
+ --border: #2a2a2a;
19
+ --border2: #333333;
20
+ --text: #f0f0f0;
21
+ --text-muted: #888888;
22
+ --text-dim: #555555;
23
+ --white: #ffffff;
24
+ --accent: #ffffff;
25
+ --task: #a8a8a8;
26
+ --response: #d0d0d0;
27
+ --error: #ff5555;
28
+ --system: #888888;
29
+ --success: #888888;
30
+ --sidebar-w: 220px;
31
+ }
32
+
33
+ html, body {
34
+ height: 100%;
35
+ background: var(--bg);
36
+ color: var(--text);
37
+ font-family: 'Inter', sans-serif;
38
+ font-size: 14px;
39
+ overflow: hidden;
40
+ }
41
+
42
+ /* ── LAYOUT ── */
43
+ .layout {
44
+ display: flex;
45
+ height: 100vh;
46
+ }
47
+
48
+ /* ── SIDEBAR ── */
49
+ .sidebar {
50
+ width: var(--sidebar-w);
51
+ min-width: var(--sidebar-w);
52
+ background: var(--surface);
53
+ border-right: 1px solid var(--border);
54
+ display: flex;
55
+ flex-direction: column;
56
+ overflow: hidden;
57
+ }
58
+
59
+ .sidebar-logo {
60
+ padding: 24px 20px 20px;
61
+ border-bottom: 1px solid var(--border);
62
+ }
63
+ .sidebar-logo .logo-mark {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 10px;
67
+ }
68
+ .sidebar-logo .logo-icon {
69
+ width: 32px; height: 32px;
70
+ background: var(--white);
71
+ border-radius: 8px;
72
+ display: flex; align-items: center; justify-content: center;
73
+ flex-shrink: 0;
74
+ }
75
+ .sidebar-logo .logo-icon svg { color: #000; }
76
+ .sidebar-logo .logo-name {
77
+ font-size: 13px;
78
+ font-weight: 700;
79
+ color: var(--white);
80
+ letter-spacing: 0.02em;
81
+ }
82
+ .sidebar-logo .logo-sub {
83
+ font-size: 10px;
84
+ color: var(--text-dim);
85
+ margin-top: 2px;
86
+ letter-spacing: 0.08em;
87
+ text-transform: uppercase;
88
+ }
89
+
90
+ /* Status Indicator */
91
+ .sidebar-status {
92
+ padding: 14px 20px;
93
+ border-bottom: 1px solid var(--border);
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 8px;
97
+ }
98
+ .status-dot {
99
+ width: 7px; height: 7px;
100
+ border-radius: 50%;
101
+ background: var(--text-dim);
102
+ flex-shrink: 0;
103
+ transition: background 0.4s;
104
+ }
105
+ .status-dot.connected { background: var(--white); box-shadow: 0 0 8px rgba(255,255,255,0.4); }
106
+ .status-dot.waiting { background: #777; animation: pulse-dot 1.5s infinite; }
107
+ .status-label {
108
+ font-size: 11px;
109
+ color: var(--text-muted);
110
+ font-weight: 500;
111
+ letter-spacing: 0.06em;
112
+ text-transform: uppercase;
113
+ }
114
+
115
+ @keyframes pulse-dot {
116
+ 0%, 100% { opacity: 1; }
117
+ 50% { opacity: 0.3; }
118
+ }
119
+
120
+ /* Nav */
121
+ .sidebar-nav {
122
+ flex: 1;
123
+ padding: 12px 0;
124
+ overflow-y: auto;
125
+ }
126
+ .nav-group-label {
127
+ font-size: 10px;
128
+ font-weight: 600;
129
+ color: var(--text-dim);
130
+ letter-spacing: 0.1em;
131
+ text-transform: uppercase;
132
+ padding: 8px 20px 4px;
133
+ }
134
+ .nav-item {
135
+ display: flex;
136
+ align-items: center;
137
+ gap: 10px;
138
+ padding: 9px 20px;
139
+ cursor: pointer;
140
+ color: var(--text-muted);
141
+ font-size: 13px;
142
+ font-weight: 500;
143
+ transition: color 0.15s, background 0.15s;
144
+ border-left: 2px solid transparent;
145
+ user-select: none;
146
+ }
147
+ .nav-item:hover { color: var(--text); background: var(--surface2); }
148
+ .nav-item.active { color: var(--white); border-left-color: var(--white); background: var(--surface2); }
149
+ .nav-item svg { width: 15px; height: 15px; flex-shrink: 0; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
150
+
151
+ .sidebar-footer {
152
+ padding: 16px 20px;
153
+ border-top: 1px solid var(--border);
154
+ }
155
+ .sidebar-footer .version {
156
+ font-size: 11px;
157
+ color: var(--text-dim);
158
+ font-family: 'JetBrains Mono', monospace;
159
+ }
160
+
161
+ /* ── MAIN ── */
162
+ .main {
163
+ flex: 1;
164
+ display: flex;
165
+ flex-direction: column;
166
+ overflow: hidden;
167
+ background: var(--bg);
168
+ }
169
+
170
+ /* Topbar */
171
+ .topbar {
172
+ height: 56px;
173
+ display: flex;
174
+ align-items: center;
175
+ justify-content: space-between;
176
+ padding: 0 28px;
177
+ border-bottom: 1px solid var(--border);
178
+ flex-shrink: 0;
179
+ }
180
+ .page-title {
181
+ font-size: 15px;
182
+ font-weight: 600;
183
+ color: var(--white);
184
+ }
185
+ .topbar-right {
186
+ display: flex;
187
+ align-items: center;
188
+ gap: 12px;
189
+ }
190
+ .topbar-badge {
191
+ font-size: 11px;
192
+ font-weight: 500;
193
+ font-family: 'JetBrains Mono', monospace;
194
+ color: var(--text-muted);
195
+ background: var(--surface2);
196
+ border: 1px solid var(--border);
197
+ padding: 4px 10px;
198
+ border-radius: 20px;
199
+ }
200
+ .clear-btn {
201
+ font-size: 11px;
202
+ font-weight: 500;
203
+ color: var(--text-dim);
204
+ background: none;
205
+ border: 1px solid var(--border);
206
+ padding: 5px 12px;
207
+ border-radius: 6px;
208
+ cursor: pointer;
209
+ transition: color 0.15s, border-color 0.15s;
210
+ }
211
+ .clear-btn:hover { color: var(--white); border-color: var(--border2); }
212
+
213
+ /* Content */
214
+ .content {
215
+ flex: 1;
216
+ overflow: hidden;
217
+ position: relative;
218
+ }
219
+
220
+ /* ── PAGES ── */
221
+ .page {
222
+ display: none;
223
+ height: 100%;
224
+ overflow-y: auto;
225
+ padding: 24px 28px;
226
+ flex-direction: column;
227
+ gap: 20px;
228
+ }
229
+ .page.active { display: flex; }
230
+
231
+ /* ── ACTIVITY LOG PAGE ── */
232
+ #page-activity {
233
+ padding: 0;
234
+ }
235
+ #page-activity .log-toolbar {
236
+ display: flex;
237
+ align-items: center;
238
+ justify-content: space-between;
239
+ padding: 14px 28px;
240
+ border-bottom: 1px solid var(--border);
241
+ flex-shrink: 0;
242
+ gap: 12px;
243
+ }
244
+ .filter-btns {
245
+ display: flex;
246
+ gap: 4px;
247
+ }
248
+ .filter-btn {
249
+ font-size: 11px;
250
+ font-weight: 500;
251
+ padding: 5px 12px;
252
+ border-radius: 5px;
253
+ border: 1px solid var(--border);
254
+ background: none;
255
+ color: var(--text-muted);
256
+ cursor: pointer;
257
+ transition: all 0.15s;
258
+ }
259
+ .filter-btn:hover { color: var(--white); border-color: var(--border2); }
260
+ .filter-btn.active { background: var(--white); color: #000; border-color: var(--white); }
261
+
262
+ .scroll-lock-btn {
263
+ display: flex;
264
+ align-items: center;
265
+ gap: 6px;
266
+ font-size: 11px;
267
+ color: var(--text-dim);
268
+ background: none;
269
+ border: 1px solid var(--border);
270
+ padding: 5px 10px;
271
+ border-radius: 5px;
272
+ cursor: pointer;
273
+ transition: all 0.15s;
274
+ }
275
+ .scroll-lock-btn:hover { color: var(--text-muted); }
276
+ .scroll-lock-btn.locked { color: var(--white); border-color: var(--border2); }
277
+ .scroll-lock-btn svg { width: 12px; height: 12px; }
278
+
279
+ .log-list {
280
+ flex: 1;
281
+ overflow-y: auto;
282
+ padding: 0;
283
+ }
284
+ .log-entry {
285
+ display: flex;
286
+ align-items: flex-start;
287
+ gap: 0;
288
+ padding: 11px 28px;
289
+ border-bottom: 1px solid var(--border);
290
+ font-family: 'JetBrains Mono', monospace;
291
+ font-size: 12px;
292
+ line-height: 1.6;
293
+ transition: background 0.1s;
294
+ }
295
+ .log-entry:hover { background: var(--surface2); }
296
+ .log-entry-time {
297
+ color: var(--text-dim);
298
+ min-width: 80px;
299
+ flex-shrink: 0;
300
+ margin-right: 16px;
301
+ }
302
+ .log-entry-badge {
303
+ display: inline-flex;
304
+ align-items: center;
305
+ font-size: 9px;
306
+ font-weight: 700;
307
+ letter-spacing: 0.08em;
308
+ text-transform: uppercase;
309
+ padding: 2px 6px;
310
+ border-radius: 3px;
311
+ margin-right: 12px;
312
+ flex-shrink: 0;
313
+ min-width: 60px;
314
+ justify-content: center;
315
+ font-family: 'Inter', sans-serif;
316
+ }
317
+ .badge-task { background: rgba(255,255,255,0.08); color: var(--task); }
318
+ .badge-response { background: rgba(255,255,255,0.06); color: var(--response); }
319
+ .badge-error { background: rgba(255,85,85,0.12); color: var(--error); }
320
+ .badge-system { background: rgba(255,255,255,0.04); color: var(--system); }
321
+ .log-entry-msg { color: var(--text); word-break: break-word; }
322
+
323
+ .log-empty {
324
+ display: flex;
325
+ flex-direction: column;
326
+ align-items: center;
327
+ justify-content: center;
328
+ height: 100%;
329
+ gap: 12px;
330
+ color: var(--text-dim);
331
+ }
332
+ .log-empty svg { width: 40px; height: 40px; stroke: var(--text-dim); fill: none; stroke-width: 1; stroke-linecap: round; stroke-linejoin: round; }
333
+ .log-empty p { font-size: 13px; }
334
+
335
+ /* ── STATS PAGE ── */
336
+ .stats-grid {
337
+ display: grid;
338
+ grid-template-columns: repeat(2, 1fr);
339
+ gap: 16px;
340
+ }
341
+ .stat-card {
342
+ background: var(--surface);
343
+ border: 1px solid var(--border);
344
+ border-radius: 10px;
345
+ padding: 22px 24px;
346
+ display: flex;
347
+ flex-direction: column;
348
+ gap: 8px;
349
+ }
350
+ .stat-card.full { grid-column: 1 / -1; }
351
+ .stat-card-label {
352
+ font-size: 11px;
353
+ font-weight: 600;
354
+ color: var(--text-dim);
355
+ letter-spacing: 0.08em;
356
+ text-transform: uppercase;
357
+ }
358
+ .stat-card-value {
359
+ font-size: 36px;
360
+ font-weight: 700;
361
+ color: var(--white);
362
+ font-family: 'JetBrains Mono', monospace;
363
+ line-height: 1;
364
+ }
365
+ .stat-card-sub {
366
+ font-size: 12px;
367
+ color: var(--text-muted);
368
+ }
369
+
370
+ .progress-bar-wrap {
371
+ height: 4px;
372
+ background: var(--surface3);
373
+ border-radius: 2px;
374
+ overflow: hidden;
375
+ margin-top: 4px;
376
+ }
377
+ .progress-bar {
378
+ height: 100%;
379
+ background: var(--white);
380
+ border-radius: 2px;
381
+ transition: width 0.6s ease;
382
+ min-width: 2px;
383
+ }
384
+ .progress-bar.error { background: var(--error); }
385
+
386
+ /* Session Info card */
387
+ .info-list { display: flex; flex-direction: column; gap: 12px; }
388
+ .info-row {
389
+ display: flex;
390
+ justify-content: space-between;
391
+ align-items: center;
392
+ font-size: 13px;
393
+ }
394
+ .info-row-label { color: var(--text-muted); }
395
+ .info-row-value {
396
+ font-family: 'JetBrains Mono', monospace;
397
+ font-size: 12px;
398
+ color: var(--white);
399
+ background: var(--surface2);
400
+ padding: 3px 8px;
401
+ border-radius: 4px;
402
+ }
403
+
404
+ /* ── ABOUT PAGE ── */
405
+ .about-section { max-width: 580px; }
406
+ .about-hero {
407
+ background: var(--surface);
408
+ border: 1px solid var(--border);
409
+ border-radius: 12px;
410
+ padding: 32px;
411
+ display: flex;
412
+ flex-direction: column;
413
+ gap: 12px;
414
+ margin-bottom: 20px;
415
+ }
416
+ .about-hero h2 { font-size: 22px; color: var(--white); }
417
+ .about-hero p { font-size: 13px; color: var(--text-muted); line-height: 1.7; }
418
+ .about-links { display: flex; gap: 10px; margin-top: 4px; }
419
+ .about-link {
420
+ font-size: 12px;
421
+ font-weight: 500;
422
+ color: var(--text-muted);
423
+ background: var(--surface2);
424
+ border: 1px solid var(--border);
425
+ padding: 7px 14px;
426
+ border-radius: 6px;
427
+ text-decoration: none;
428
+ transition: all 0.15s;
429
+ display: inline-flex;
430
+ align-items: center;
431
+ gap: 6px;
432
+ }
433
+ .about-link:hover { color: var(--white); border-color: var(--border2); }
434
+
435
+ .about-card {
436
+ background: var(--surface);
437
+ border: 1px solid var(--border);
438
+ border-radius: 10px;
439
+ padding: 20px 24px;
440
+ margin-bottom: 16px;
441
+ }
442
+ .about-card h3 { font-size: 13px; font-weight: 600; color: var(--white); margin-bottom: 10px; }
443
+ .about-card ul { display: flex; flex-direction: column; gap: 7px; padding-left: 16px; }
444
+ .about-card li { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
445
+
446
+ /* Scrollbar */
447
+ ::-webkit-scrollbar { width: 4px; height: 4px; }
448
+ ::-webkit-scrollbar-track { background: transparent; }
449
+ ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
450
+ ::-webkit-scrollbar-thumb:hover { background: #555; }
451
+
452
+ /* Transitions */
453
+ .log-entry { animation: fadeIn 0.2s ease; }
454
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
13
455
  </style>
14
456
  </head>
15
457
  <body>
16
- <h1>nvstudio-mcp : Activity Dashboard (Port 3055)</h1>
17
- <div id="log-container"></div>
18
-
19
- <script src="/socket.io/socket.io.js"></script>
20
- <script>
21
- const socket = io();
22
- const container = document.getElementById('log-container');
23
-
24
- socket.on('log', (msg) => {
25
- const el = document.createElement('div');
26
- el.className = 'log-entry';
27
- const time = new Date().toLocaleTimeString();
28
- el.innerHTML = `<span class="log-time">[${time}]</span>${msg}`;
29
- container.appendChild(el);
30
- container.scrollTop = container.scrollHeight; // Auto-scroll
458
+ <div class="layout">
459
+
460
+ <!-- ── SIDEBAR ── -->
461
+ <aside class="sidebar">
462
+ <div class="sidebar-logo">
463
+ <div class="logo-mark">
464
+ <div class="logo-icon">
465
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
466
+ <path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
467
+ </svg>
468
+ </div>
469
+ <div>
470
+ <div class="logo-name">nvstudio-mcp</div>
471
+ <div class="logo-sub">Bridge Server</div>
472
+ </div>
473
+ </div>
474
+ </div>
475
+
476
+ <div class="sidebar-status">
477
+ <div class="status-dot waiting" id="status-dot"></div>
478
+ <span class="status-label" id="status-label">Waiting</span>
479
+ </div>
480
+
481
+ <nav class="sidebar-nav">
482
+ <div class="nav-group-label">Monitor</div>
483
+ <div class="nav-item active" data-page="activity">
484
+ <svg viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
485
+ Activity Log
486
+ </div>
487
+ <div class="nav-item" data-page="stats">
488
+ <svg viewBox="0 0 24 24"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
489
+ Statistics
490
+ </div>
491
+
492
+ <div class="nav-group-label" style="margin-top:12px;">Info</div>
493
+ <div class="nav-item" data-page="about">
494
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
495
+ About
496
+ </div>
497
+ </nav>
498
+
499
+ <div class="sidebar-footer">
500
+ <div class="version" id="pkg-version">v1.0.2</div>
501
+ </div>
502
+ </aside>
503
+
504
+ <!-- ── MAIN ── -->
505
+ <main class="main">
506
+ <div class="topbar">
507
+ <span class="page-title" id="topbar-title">Activity Log</span>
508
+ <div class="topbar-right">
509
+ <span class="topbar-badge" id="log-count">0 events</span>
510
+ <button class="clear-btn" id="clear-btn">Clear</button>
511
+ </div>
512
+ </div>
513
+
514
+ <div class="content">
515
+
516
+ <!-- Activity Log Page -->
517
+ <div class="page active" id="page-activity" style="padding:0; gap:0;">
518
+ <div class="log-toolbar">
519
+ <div class="filter-btns">
520
+ <button class="filter-btn active" data-filter="all">All</button>
521
+ <button class="filter-btn" data-filter="task">Task</button>
522
+ <button class="filter-btn" data-filter="response">Response</button>
523
+ <button class="filter-btn" data-filter="error">Error</button>
524
+ <button class="filter-btn" data-filter="system">System</button>
525
+ </div>
526
+ <button class="scroll-lock-btn locked" id="scroll-lock-btn">
527
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
528
+ <line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/>
529
+ </svg>
530
+ Auto Scroll
531
+ </button>
532
+ </div>
533
+ <div class="log-list" id="log-list">
534
+ <div class="log-empty" id="log-empty">
535
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>
536
+ <p>Menunggu aktivitas dari Roblox Studio...</p>
537
+ </div>
538
+ </div>
539
+ </div>
540
+
541
+ <!-- Statistics Page -->
542
+ <div class="page" id="page-stats">
543
+ <div class="stats-grid">
544
+ <div class="stat-card">
545
+ <div class="stat-card-label">Total Tasks</div>
546
+ <div class="stat-card-value" id="stat-total">0</div>
547
+ <div class="stat-card-sub">Sejak server dimulai</div>
548
+ </div>
549
+ <div class="stat-card">
550
+ <div class="stat-card-label">Success Rate</div>
551
+ <div class="stat-card-value" id="stat-rate">—</div>
552
+ <div class="progress-bar-wrap">
553
+ <div class="progress-bar" id="bar-success" style="width:0%"></div>
554
+ </div>
555
+ </div>
556
+ <div class="stat-card">
557
+ <div class="stat-card-label">Succeeded</div>
558
+ <div class="stat-card-value" id="stat-success">0</div>
559
+ <div class="stat-card-sub">Perintah berhasil</div>
560
+ </div>
561
+ <div class="stat-card">
562
+ <div class="stat-card-label">Failed</div>
563
+ <div class="stat-card-value" id="stat-error">0</div>
564
+ <div class="stat-card-sub">Perintah gagal</div>
565
+ <div class="progress-bar-wrap">
566
+ <div class="progress-bar error" id="bar-error" style="width:0%"></div>
567
+ </div>
568
+ </div>
569
+ <div class="stat-card full">
570
+ <div class="stat-card-label">Session Info</div>
571
+ <div class="info-list">
572
+ <div class="info-row">
573
+ <span class="info-row-label">Session dimulai</span>
574
+ <span class="info-row-value" id="info-session">—</span>
575
+ </div>
576
+ <div class="info-row">
577
+ <span class="info-row-label">Port aktif</span>
578
+ <span class="info-row-value">3055</span>
579
+ </div>
580
+ <div class="info-row">
581
+ <span class="info-row-label">Studio status</span>
582
+ <span class="info-row-value" id="info-studio">Disconnected</span>
583
+ </div>
584
+ <div class="info-row">
585
+ <span class="info-row-label">Uptime</span>
586
+ <span class="info-row-value" id="info-uptime">—</span>
587
+ </div>
588
+ </div>
589
+ </div>
590
+ </div>
591
+ </div>
592
+
593
+ <!-- About Page -->
594
+ <div class="page" id="page-about">
595
+ <div class="about-section">
596
+ <div class="about-hero">
597
+ <h2>nvstudio-mcp</h2>
598
+ <p>Jembatan pintar berbasis <strong>Model Context Protocol (MCP)</strong> yang menghubungkan AI Agent (Antigravity IDE, Cursor, Claude) secara langsung ke dalam <strong>Roblox Studio</strong>. AI Anda dapat membaca, menulis, dan mengeksekusi script layaknya Co-Pilot sejati.</p>
599
+ <div class="about-links">
600
+ <a class="about-link" href="https://github.com/Adiholick/nvstudio-mcp" target="_blank">
601
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
602
+ GitHub
603
+ </a>
604
+ <a class="about-link" href="https://www.npmjs.com/package/@adiholick/nvstudio-mcp" target="_blank">
605
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M0 0h24v24H0V0zm7.315 5.876H2.17v12.248H12v-9.6h-4.685V5.876zm9.515 0h-5.145v12.248h5.145V5.876zm-7.315 4.8H9.87v4.8H9.515v-4.8z"/></svg>
606
+ NPM Package
607
+ </a>
608
+ </div>
609
+ </div>
610
+
611
+ <div class="about-card">
612
+ <h3>Cara Kerja</h3>
613
+ <ul>
614
+ <li>Plugin Roblox Studio melakukan polling ke server MCP Bridge ini setiap detik.</li>
615
+ <li>AI Agent mengirimkan perintah melalui tool <code>call_mcp_tool</code>.</li>
616
+ <li>Bridge meneruskan perintah ke Studio dan mengembalikan hasilnya ke AI.</li>
617
+ <li>Dashboard ini menampilkan semua aktivitas secara real-time via WebSocket.</li>
618
+ </ul>
619
+ </div>
620
+
621
+ <div class="about-card">
622
+ <h3>Commands yang Tersedia</h3>
623
+ <ul>
624
+ <li><code>get_children</code> — Daftar children dari instance</li>
625
+ <li><code>get_script_source</code> — Baca source code script</li>
626
+ <li><code>update_script_source</code> — Tulis/perbarui source code</li>
627
+ <li><code>rollback_script</code> — Rollback ke versi sebelumnya</li>
628
+ <li><code>get_logs</code> — Ambil log output Studio</li>
629
+ </ul>
630
+ </div>
631
+ </div>
632
+ </div>
633
+
634
+ </div>
635
+ </main>
636
+ </div>
637
+
638
+ <script src="/socket.io/socket.io.js"></script>
639
+ <script>
640
+ const socket = io();
641
+ let allLogs = [];
642
+ let activeFilter = 'all';
643
+ let autoScroll = true;
644
+ let sessionStartTime = null;
645
+
646
+ const logList = document.getElementById('log-list');
647
+ const logEmpty = document.getElementById('log-empty');
648
+ const logCount = document.getElementById('log-count');
649
+ const statusDot = document.getElementById('status-dot');
650
+ const statusLabel = document.getElementById('status-label');
651
+
652
+ /* ── Navigation ── */
653
+ const navItems = document.querySelectorAll('.nav-item');
654
+ const pages = document.querySelectorAll('.page');
655
+ const topbarTitle = document.getElementById('topbar-title');
656
+ const clearBtn = document.getElementById('clear-btn');
657
+ const pageTitles = { activity: 'Activity Log', stats: 'Statistics', about: 'About' };
658
+
659
+ navItems.forEach(item => {
660
+ item.addEventListener('click', () => {
661
+ const page = item.dataset.page;
662
+ navItems.forEach(n => n.classList.remove('active'));
663
+ pages.forEach(p => p.classList.remove('active'));
664
+ item.classList.add('active');
665
+ document.getElementById('page-' + page).classList.add('active');
666
+ topbarTitle.textContent = pageTitles[page] || page;
667
+
668
+ const isActivity = page === 'activity';
669
+ clearBtn.style.display = isActivity ? '' : 'none';
670
+ logCount.style.display = isActivity ? '' : 'none';
31
671
  });
32
- </script>
672
+ });
673
+
674
+ /* ── Clear ── */
675
+ clearBtn.addEventListener('click', () => {
676
+ allLogs = [];
677
+ renderLogs();
678
+ });
679
+
680
+ /* ── Filter ── */
681
+ document.querySelectorAll('.filter-btn').forEach(btn => {
682
+ btn.addEventListener('click', () => {
683
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
684
+ btn.classList.add('active');
685
+ activeFilter = btn.dataset.filter;
686
+ renderLogs();
687
+ });
688
+ });
689
+
690
+ /* ── Scroll Lock ── */
691
+ const scrollLockBtn = document.getElementById('scroll-lock-btn');
692
+ scrollLockBtn.addEventListener('click', () => {
693
+ autoScroll = !autoScroll;
694
+ scrollLockBtn.classList.toggle('locked', autoScroll);
695
+ });
696
+
697
+ /* ── Render ── */
698
+ function renderLogs() {
699
+ const filtered = activeFilter === 'all' ? allLogs : allLogs.filter(l => l.type === activeFilter);
700
+ logCount.textContent = `${allLogs.length} event${allLogs.length !== 1 ? 's' : ''}`;
701
+
702
+ if (filtered.length === 0) {
703
+ logEmpty.style.display = 'flex';
704
+ // Remove all entries but keep empty
705
+ [...logList.children].forEach(c => { if (c !== logEmpty) c.remove(); });
706
+ return;
707
+ }
708
+ logEmpty.style.display = 'none';
709
+
710
+ // Full re-render
711
+ const frag = document.createDocumentFragment();
712
+ filtered.forEach(log => {
713
+ const el = createLogEl(log);
714
+ frag.appendChild(el);
715
+ });
716
+ logList.innerHTML = '';
717
+ logList.appendChild(logEmpty);
718
+ logEmpty.style.display = 'none';
719
+ logList.appendChild(frag);
720
+
721
+ if (autoScroll) logList.scrollTop = 0; // newest on top
722
+ }
723
+
724
+ function createLogEl(log) {
725
+ const el = document.createElement('div');
726
+ el.className = 'log-entry';
727
+ el.dataset.type = log.type;
728
+ const badges = { task: 'TASK', response: 'RESP', error: 'ERROR', system: 'SYS' };
729
+ el.innerHTML = `
730
+ <span class="log-entry-time">${log.time}</span>
731
+ <span class="log-entry-badge badge-${log.type}">${badges[log.type] || log.type}</span>
732
+ <span class="log-entry-msg">${escapeHtml(log.message)}</span>
733
+ `;
734
+ return el;
735
+ }
736
+
737
+ function escapeHtml(str) {
738
+ return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
739
+ }
740
+
741
+ function prependLog(log) {
742
+ allLogs.unshift(log);
743
+ if (allLogs.length > 500) allLogs.pop();
744
+ logCount.textContent = `${allLogs.length} event${allLogs.length !== 1 ? 's' : ''}`;
745
+
746
+ if (activeFilter !== 'all' && log.type !== activeFilter) return;
747
+
748
+ logEmpty.style.display = 'none';
749
+ const el = createLogEl(log);
750
+ const firstEntry = logList.querySelector('.log-entry');
751
+ if (firstEntry) logList.insertBefore(el, firstEntry);
752
+ else logList.appendChild(el);
753
+
754
+ if (autoScroll) logList.scrollTop = 0;
755
+ }
756
+
757
+ /* ── Stats ── */
758
+ function updateStats(s) {
759
+ document.getElementById('stat-total').textContent = s.totalTasks;
760
+ document.getElementById('stat-success').textContent = s.successCount;
761
+ document.getElementById('stat-error').textContent = s.errorCount;
762
+
763
+ const rate = s.totalTasks > 0 ? Math.round((s.successCount / s.totalTasks) * 100) : null;
764
+ document.getElementById('stat-rate').textContent = rate !== null ? rate + '%' : '—';
765
+ document.getElementById('bar-success').style.width = (rate || 0) + '%';
766
+ const errRate = s.totalTasks > 0 ? Math.round((s.errorCount / s.totalTasks) * 100) : 0;
767
+ document.getElementById('bar-error').style.width = errRate + '%';
768
+
769
+ if (s.sessionStart) {
770
+ sessionStartTime = new Date(s.sessionStart);
771
+ document.getElementById('info-session').textContent = sessionStartTime.toLocaleString('id-ID');
772
+ }
773
+ document.getElementById('info-studio').textContent = s.studioConnected ? 'Connected ✓' : 'Disconnected';
774
+ }
775
+
776
+ /* ── Uptime ── */
777
+ setInterval(() => {
778
+ if (!sessionStartTime) return;
779
+ const diff = Math.floor((Date.now() - sessionStartTime) / 1000);
780
+ const h = String(Math.floor(diff / 3600)).padStart(2, '0');
781
+ const m = String(Math.floor((diff % 3600) / 60)).padStart(2, '0');
782
+ const s = String(diff % 60).padStart(2, '0');
783
+ document.getElementById('info-uptime').textContent = `${h}:${m}:${s}`;
784
+ }, 1000);
785
+
786
+ /* ── Studio Status ── */
787
+ function setStudioStatus(connected) {
788
+ statusDot.className = 'status-dot ' + (connected ? 'connected' : 'waiting');
789
+ statusLabel.textContent = connected ? 'Studio Connected' : 'Waiting';
790
+ document.getElementById('info-studio').textContent = connected ? 'Connected ✓' : 'Disconnected';
791
+ }
792
+
793
+ /* ── Socket Events ── */
794
+ socket.on('connect', () => {});
795
+ socket.on('disconnect', () => setStudioStatus(false));
796
+
797
+ socket.on('init', ({ logs, stats }) => {
798
+ allLogs = logs || [];
799
+ renderLogs();
800
+ if (stats) {
801
+ updateStats(stats);
802
+ if (stats.studioConnected) setStudioStatus(true);
803
+ }
804
+ });
805
+
806
+ socket.on('log', (log) => {
807
+ prependLog(log);
808
+ });
809
+
810
+ socket.on('stats', (s) => {
811
+ updateStats(s);
812
+ });
813
+
814
+ socket.on('studio-status', ({ connected }) => {
815
+ setStudioStatus(connected);
816
+ });
817
+ </script>
33
818
  </body>
34
819
  </html>