@myrialabs/clopen 0.2.7 → 0.2.9
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/backend/chat/stream-manager.ts +23 -12
- package/backend/mcp/project-context.ts +20 -0
- package/backend/mcp/servers/browser-automation/actions.ts +0 -2
- package/backend/mcp/servers/browser-automation/browser.ts +80 -143
- package/backend/mcp/servers/browser-automation/inspection.ts +5 -11
- package/backend/preview/browser/browser-mcp-control.ts +174 -195
- package/backend/preview/browser/browser-preview-service.ts +3 -3
- package/backend/preview/browser/browser-video-capture.ts +12 -14
- package/backend/preview/browser/scripts/video-stream.ts +14 -14
- package/backend/preview/browser/types.ts +7 -7
- package/backend/preview/index.ts +1 -1
- package/backend/terminal/stream-manager.ts +40 -26
- package/backend/ws/preview/index.ts +3 -3
- package/backend/ws/system/operations.ts +23 -0
- package/frontend/components/chat/message/MessageBubble.svelte +2 -2
- package/frontend/components/chat/tools/components/FileHeader.svelte +1 -1
- package/frontend/components/chat/tools/components/TerminalCommand.svelte +8 -1
- package/frontend/components/common/overlay/Dialog.svelte +1 -1
- package/frontend/components/common/overlay/Lightbox.svelte +2 -2
- package/frontend/components/common/overlay/Modal.svelte +2 -2
- package/frontend/components/common/xterm/XTerm.svelte +6 -1
- package/frontend/components/git/ConflictResolver.svelte +1 -1
- package/frontend/components/git/GitModal.svelte +2 -2
- package/frontend/components/preview/browser/BrowserPreview.svelte +1 -1
- package/frontend/components/preview/browser/components/Canvas.svelte +1 -1
- package/frontend/components/preview/browser/components/Toolbar.svelte +4 -4
- package/frontend/components/preview/browser/core/mcp-handlers.svelte.ts +58 -64
- package/frontend/components/settings/SettingsModal.svelte +1 -1
- package/frontend/components/settings/general/DataManagementSettings.svelte +5 -66
- package/frontend/components/terminal/Terminal.svelte +1 -29
- package/frontend/components/tunnel/TunnelInactive.svelte +7 -5
- package/frontend/components/workspace/DesktopNavigator.svelte +1 -1
- package/frontend/components/workspace/PanelHeader.svelte +22 -16
- package/frontend/components/workspace/panels/GitPanel.svelte +1 -6
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +2 -2
- package/frontend/services/project/status.service.ts +11 -1
- package/frontend/stores/core/sessions.svelte.ts +11 -1
- package/frontend/stores/features/terminal.svelte.ts +56 -26
- package/frontend/stores/ui/theme.svelte.ts +1 -1
- package/frontend/utils/ws.ts +42 -0
- package/index.html +2 -2
- package/package.json +1 -1
- package/shared/utils/ws-client.ts +21 -4
- package/static/manifest.json +2 -2
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Browser MCP Control
|
|
3
3
|
*
|
|
4
|
-
* Manages
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Manages MCP control over browser tabs with multi-tab, session-scoped ownership.
|
|
5
|
+
* Each chat session can control multiple tabs simultaneously.
|
|
6
|
+
* A tab can only be controlled by one chat session at a time.
|
|
7
|
+
* All tabs are released when the chat session ends (stream complete/error/cancel).
|
|
8
8
|
*
|
|
9
|
-
* ARCHITECTURE:
|
|
10
|
-
* - Control
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
13
|
-
* -
|
|
9
|
+
* ARCHITECTURE:
|
|
10
|
+
* - Control lifecycle follows chat sessions (no idle timeout)
|
|
11
|
+
* - Multiple tabs can be locked by one chat session (accumulated via switch/open)
|
|
12
|
+
* - Tab destroyed → auto-release that single tab from its owning session
|
|
13
|
+
* - Stream ends → releaseSession() releases all tabs owned by that session
|
|
14
|
+
* - Emits per-tab control-start/control-end events for frontend UI
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { EventEmitter } from 'events';
|
|
@@ -24,19 +25,17 @@ interface PendingTabRequest<T = any> {
|
|
|
24
25
|
timeout: NodeJS.Timeout;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
startedAt: number | null;
|
|
33
|
-
lastActionAt: number | null;
|
|
28
|
+
/** Ownership info for a single tab */
|
|
29
|
+
interface TabOwnershipInfo {
|
|
30
|
+
chatSessionId: string;
|
|
31
|
+
projectId: string;
|
|
32
|
+
acquiredAt: number;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
export interface McpControlEvent {
|
|
37
36
|
type: 'mcp:control-start' | 'mcp:control-end';
|
|
38
37
|
browserTabId: string;
|
|
39
|
-
|
|
38
|
+
chatSessionId?: string;
|
|
40
39
|
timestamp: number;
|
|
41
40
|
}
|
|
42
41
|
|
|
@@ -57,18 +56,11 @@ export interface McpClickEvent {
|
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
export class BrowserMcpControl extends EventEmitter {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
startedAt: null,
|
|
66
|
-
lastActionAt: null
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
// Auto-release configuration
|
|
70
|
-
private readonly IDLE_TIMEOUT_MS = 30000; // 30 seconds idle = auto release
|
|
71
|
-
private idleCheckInterval: NodeJS.Timeout | null = null;
|
|
59
|
+
/** Tab → ownership info (which chat session controls it) */
|
|
60
|
+
private tabOwnership = new Map<string, TabOwnershipInfo>();
|
|
61
|
+
|
|
62
|
+
/** Chat session → set of tab IDs it controls */
|
|
63
|
+
private sessionTabs = new Map<string, Set<string>>();
|
|
72
64
|
|
|
73
65
|
// Pending tab requests (keyed by request type + timestamp)
|
|
74
66
|
private pendingTabRequests = new Map<string, PendingTabRequest>();
|
|
@@ -98,16 +90,18 @@ export class BrowserMcpControl extends EventEmitter {
|
|
|
98
90
|
|
|
99
91
|
/**
|
|
100
92
|
* Handle tab destroyed event
|
|
101
|
-
* Auto-release control
|
|
102
|
-
* Uses the service's projectId to avoid cross-project false-positives.
|
|
93
|
+
* Auto-release control for the destroyed tab only
|
|
103
94
|
*/
|
|
104
95
|
private handleTabDestroyed(tabId: string): void {
|
|
105
|
-
|
|
106
|
-
|
|
96
|
+
const ownership = this.tabOwnership.get(tabId);
|
|
97
|
+
if (!ownership) return;
|
|
98
|
+
|
|
99
|
+
// Validate project to prevent cross-project collisions
|
|
107
100
|
const serviceProjectId = this.previewService?.getProjectId();
|
|
108
|
-
if (serviceProjectId &&
|
|
109
|
-
|
|
110
|
-
|
|
101
|
+
if (serviceProjectId && ownership.projectId !== serviceProjectId) return;
|
|
102
|
+
|
|
103
|
+
debug.warn('mcp', `⚠️ Controlled tab ${tabId} was destroyed - auto-releasing from session ${ownership.chatSessionId}`);
|
|
104
|
+
this.releaseTab(tabId);
|
|
111
105
|
}
|
|
112
106
|
|
|
113
107
|
/**
|
|
@@ -157,127 +151,190 @@ export class BrowserMcpControl extends EventEmitter {
|
|
|
157
151
|
return false;
|
|
158
152
|
}
|
|
159
153
|
|
|
154
|
+
// ============================================================================
|
|
155
|
+
// Control State Queries
|
|
156
|
+
// ============================================================================
|
|
157
|
+
|
|
160
158
|
/**
|
|
161
|
-
* Check if
|
|
159
|
+
* Check if any tab is being controlled
|
|
162
160
|
*/
|
|
163
161
|
isControlling(): boolean {
|
|
164
|
-
return this.
|
|
162
|
+
return this.tabOwnership.size > 0;
|
|
165
163
|
}
|
|
166
164
|
|
|
167
165
|
/**
|
|
168
|
-
*
|
|
166
|
+
* Check if a specific tab is being controlled (by any session)
|
|
169
167
|
*/
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
isTabControlled(browserTabId: string, projectId?: string): boolean {
|
|
169
|
+
const ownership = this.tabOwnership.get(browserTabId);
|
|
170
|
+
if (!ownership) return false;
|
|
171
|
+
if (projectId && ownership.projectId !== projectId) return false;
|
|
172
|
+
return true;
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
/**
|
|
175
|
-
* Check if a
|
|
176
|
-
* When projectId is provided, also validates the project to prevent cross-project
|
|
177
|
-
* false-positives (tab IDs are only unique per project, not globally).
|
|
176
|
+
* Check if a tab is controlled by a specific chat session
|
|
178
177
|
*/
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
if (projectId && this.controlState.projectId && this.controlState.projectId !== projectId) {
|
|
184
|
-
return false;
|
|
185
|
-
}
|
|
186
|
-
return true;
|
|
178
|
+
isTabControlledBySession(browserTabId: string, chatSessionId: string): boolean {
|
|
179
|
+
const ownership = this.tabOwnership.get(browserTabId);
|
|
180
|
+
return ownership?.chatSessionId === chatSessionId;
|
|
187
181
|
}
|
|
188
182
|
|
|
189
183
|
/**
|
|
190
|
-
*
|
|
191
|
-
* Returns true if control was acquired, false if already controlled by another MCP
|
|
184
|
+
* Get the chat session ID that controls a specific tab
|
|
192
185
|
*/
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
debug.warn('mcp', `❌ Cannot acquire control: tab ${browserTabId} does not exist`);
|
|
197
|
-
return false;
|
|
198
|
-
}
|
|
186
|
+
getTabOwner(browserTabId: string): string | null {
|
|
187
|
+
return this.tabOwnership.get(browserTabId)?.chatSessionId || null;
|
|
188
|
+
}
|
|
199
189
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Get all tab IDs controlled by a specific chat session
|
|
192
|
+
*/
|
|
193
|
+
getSessionTabs(chatSessionId: string): string[] {
|
|
194
|
+
const tabs = this.sessionTabs.get(chatSessionId);
|
|
195
|
+
return tabs ? Array.from(tabs) : [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get all controlled tab IDs (across all sessions)
|
|
200
|
+
*/
|
|
201
|
+
getAllControlledTabs(): Map<string, TabOwnershipInfo> {
|
|
202
|
+
return new Map(this.tabOwnership);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ============================================================================
|
|
206
|
+
// Control Acquisition
|
|
207
|
+
// ============================================================================
|
|
206
208
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Acquire control of a browser tab for a chat session.
|
|
211
|
+
*
|
|
212
|
+
* - If the tab is already owned by the same session → success (idempotent)
|
|
213
|
+
* - If the tab is owned by another session → denied
|
|
214
|
+
* - If the tab is free → acquire and add to session's controlled set
|
|
215
|
+
*/
|
|
216
|
+
acquireControl(browserTabId: string, chatSessionId: string, projectId: string): boolean {
|
|
217
|
+
// Check existing ownership
|
|
218
|
+
const existingOwner = this.tabOwnership.get(browserTabId);
|
|
219
|
+
|
|
220
|
+
if (existingOwner) {
|
|
221
|
+
// Same session already owns it → idempotent success
|
|
222
|
+
if (existingOwner.chatSessionId === chatSessionId) {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
// Different session owns it → denied
|
|
226
|
+
debug.warn('mcp', `❌ Tab ${browserTabId} is controlled by session ${existingOwner.chatSessionId}, denied for ${chatSessionId}`);
|
|
210
227
|
return false;
|
|
211
228
|
}
|
|
212
229
|
|
|
213
230
|
// Acquire control
|
|
214
231
|
const now = Date.now();
|
|
215
|
-
this.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
startedAt: now,
|
|
221
|
-
lastActionAt: now
|
|
222
|
-
};
|
|
232
|
+
this.tabOwnership.set(browserTabId, {
|
|
233
|
+
chatSessionId,
|
|
234
|
+
projectId,
|
|
235
|
+
acquiredAt: now
|
|
236
|
+
});
|
|
223
237
|
|
|
224
|
-
//
|
|
225
|
-
this.
|
|
238
|
+
// Add to session's tab set
|
|
239
|
+
let sessionSet = this.sessionTabs.get(chatSessionId);
|
|
240
|
+
if (!sessionSet) {
|
|
241
|
+
sessionSet = new Set();
|
|
242
|
+
this.sessionTabs.set(chatSessionId, sessionSet);
|
|
243
|
+
}
|
|
244
|
+
sessionSet.add(browserTabId);
|
|
226
245
|
|
|
227
|
-
//
|
|
228
|
-
this.
|
|
246
|
+
// Emit control start event to frontend
|
|
247
|
+
this.emitControlStart(browserTabId, chatSessionId);
|
|
229
248
|
|
|
230
|
-
debug.log('mcp', `🎮
|
|
249
|
+
debug.log('mcp', `🎮 Session ${chatSessionId.slice(0, 8)} acquired tab: ${browserTabId} (total: ${sessionSet.size} tabs)`);
|
|
231
250
|
return true;
|
|
232
251
|
}
|
|
233
252
|
|
|
253
|
+
// ============================================================================
|
|
254
|
+
// Control Release
|
|
255
|
+
// ============================================================================
|
|
256
|
+
|
|
234
257
|
/**
|
|
235
|
-
* Release
|
|
258
|
+
* Release a single tab from its owning session.
|
|
259
|
+
* Used when a tab is closed via close_tab or destroyed.
|
|
236
260
|
*/
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (
|
|
240
|
-
|
|
261
|
+
releaseTab(browserTabId: string): void {
|
|
262
|
+
const ownership = this.tabOwnership.get(browserTabId);
|
|
263
|
+
if (!ownership) return;
|
|
264
|
+
|
|
265
|
+
// Remove from tab ownership
|
|
266
|
+
this.tabOwnership.delete(browserTabId);
|
|
267
|
+
|
|
268
|
+
// Remove from session's tab set
|
|
269
|
+
const sessionSet = this.sessionTabs.get(ownership.chatSessionId);
|
|
270
|
+
if (sessionSet) {
|
|
271
|
+
sessionSet.delete(browserTabId);
|
|
272
|
+
if (sessionSet.size === 0) {
|
|
273
|
+
this.sessionTabs.delete(ownership.chatSessionId);
|
|
274
|
+
}
|
|
241
275
|
}
|
|
242
276
|
|
|
243
|
-
|
|
277
|
+
// Emit control end event to frontend
|
|
278
|
+
this.emitControlEnd(browserTabId);
|
|
279
|
+
|
|
280
|
+
debug.log('mcp', `🎮 Released tab: ${browserTabId} (was owned by session ${ownership.chatSessionId.slice(0, 8)})`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Release all tabs owned by a chat session.
|
|
285
|
+
* Called when chat stream ends (complete/error/cancel).
|
|
286
|
+
*/
|
|
287
|
+
releaseSession(chatSessionId: string): void {
|
|
288
|
+
const sessionSet = this.sessionTabs.get(chatSessionId);
|
|
289
|
+
if (!sessionSet || sessionSet.size === 0) {
|
|
290
|
+
this.sessionTabs.delete(chatSessionId);
|
|
244
291
|
return;
|
|
245
292
|
}
|
|
246
293
|
|
|
247
|
-
const
|
|
294
|
+
const tabIds = Array.from(sessionSet);
|
|
295
|
+
debug.log('mcp', `🎮 Releasing ${tabIds.length} tabs for session ${chatSessionId.slice(0, 8)}`);
|
|
248
296
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
browserTabId: null,
|
|
254
|
-
projectId: null,
|
|
255
|
-
startedAt: null,
|
|
256
|
-
lastActionAt: null
|
|
257
|
-
};
|
|
297
|
+
for (const tabId of tabIds) {
|
|
298
|
+
this.tabOwnership.delete(tabId);
|
|
299
|
+
this.emitControlEnd(tabId);
|
|
300
|
+
}
|
|
258
301
|
|
|
259
|
-
|
|
260
|
-
this.stopIdleCheck();
|
|
302
|
+
this.sessionTabs.delete(chatSessionId);
|
|
261
303
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
this.emitControlEnd(releasedTabId);
|
|
265
|
-
}
|
|
304
|
+
debug.log('mcp', `🎮 Session ${chatSessionId.slice(0, 8)} fully released`);
|
|
305
|
+
}
|
|
266
306
|
|
|
267
|
-
|
|
307
|
+
/**
|
|
308
|
+
* Auto-release control for a specific tab when it's closed.
|
|
309
|
+
* projectId is used to prevent accidental release across projects.
|
|
310
|
+
*/
|
|
311
|
+
autoReleaseForTab(browserTabId: string, projectId?: string): void {
|
|
312
|
+
const ownership = this.tabOwnership.get(browserTabId);
|
|
313
|
+
if (!ownership) return;
|
|
314
|
+
if (projectId && ownership.projectId !== projectId) return;
|
|
315
|
+
debug.log('mcp', `🗑️ Auto-releasing tab: ${browserTabId} (closed)`);
|
|
316
|
+
this.releaseTab(browserTabId);
|
|
268
317
|
}
|
|
269
318
|
|
|
270
319
|
/**
|
|
271
|
-
*
|
|
272
|
-
* NOTE: This does NOT affect control lifecycle - control is maintained
|
|
273
|
-
* as long as the session exists, regardless of action timestamps
|
|
320
|
+
* Force release all control (for cleanup)
|
|
274
321
|
*/
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
322
|
+
forceReleaseAll(): void {
|
|
323
|
+
// Emit control-end for all controlled tabs
|
|
324
|
+
for (const [tabId] of this.tabOwnership) {
|
|
325
|
+
this.emitControlEnd(tabId);
|
|
278
326
|
}
|
|
327
|
+
|
|
328
|
+
this.tabOwnership.clear();
|
|
329
|
+
this.sessionTabs.clear();
|
|
330
|
+
|
|
331
|
+
debug.log('mcp', '🧹 Force released all MCP control');
|
|
279
332
|
}
|
|
280
333
|
|
|
334
|
+
// ============================================================================
|
|
335
|
+
// Cursor Events
|
|
336
|
+
// ============================================================================
|
|
337
|
+
|
|
281
338
|
/**
|
|
282
339
|
* Emit cursor position event with MCP source
|
|
283
340
|
*/
|
|
@@ -319,14 +376,15 @@ export class BrowserMcpControl extends EventEmitter {
|
|
|
319
376
|
});
|
|
320
377
|
}
|
|
321
378
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
379
|
+
// ============================================================================
|
|
380
|
+
// Private Event Emitters
|
|
381
|
+
// ============================================================================
|
|
382
|
+
|
|
383
|
+
private emitControlStart(browserTabId: string, chatSessionId?: string): void {
|
|
326
384
|
const event: McpControlEvent = {
|
|
327
385
|
type: 'mcp:control-start',
|
|
328
386
|
browserTabId,
|
|
329
|
-
|
|
387
|
+
chatSessionId,
|
|
330
388
|
timestamp: Date.now()
|
|
331
389
|
};
|
|
332
390
|
|
|
@@ -335,9 +393,6 @@ export class BrowserMcpControl extends EventEmitter {
|
|
|
335
393
|
debug.log('mcp', `📢 Emitted mcp:control-start for tab: ${browserTabId}`);
|
|
336
394
|
}
|
|
337
395
|
|
|
338
|
-
/**
|
|
339
|
-
* Emit control end event to frontend
|
|
340
|
-
*/
|
|
341
396
|
private emitControlEnd(browserTabId: string): void {
|
|
342
397
|
const event: McpControlEvent = {
|
|
343
398
|
type: 'mcp:control-end',
|
|
@@ -349,82 +404,6 @@ export class BrowserMcpControl extends EventEmitter {
|
|
|
349
404
|
|
|
350
405
|
debug.log('mcp', `📢 Emitted mcp:control-end for tab: ${browserTabId}`);
|
|
351
406
|
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Start idle check interval
|
|
355
|
-
*/
|
|
356
|
-
private startIdleCheck(): void {
|
|
357
|
-
// Clear any existing interval
|
|
358
|
-
this.stopIdleCheck();
|
|
359
|
-
|
|
360
|
-
// Check every 10 seconds
|
|
361
|
-
this.idleCheckInterval = setInterval(() => {
|
|
362
|
-
this.checkAndReleaseIfIdle();
|
|
363
|
-
}, 10000);
|
|
364
|
-
|
|
365
|
-
debug.log('mcp', '⏰ Started idle check interval (30s timeout)');
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Stop idle check interval
|
|
370
|
-
*/
|
|
371
|
-
private stopIdleCheck(): void {
|
|
372
|
-
if (this.idleCheckInterval) {
|
|
373
|
-
clearInterval(this.idleCheckInterval);
|
|
374
|
-
this.idleCheckInterval = null;
|
|
375
|
-
debug.log('mcp', '⏰ Stopped idle check interval');
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Check if MCP control is idle and auto-release if timeout
|
|
381
|
-
*/
|
|
382
|
-
private checkAndReleaseIfIdle(): void {
|
|
383
|
-
if (!this.controlState.isControlling) {
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const now = Date.now();
|
|
388
|
-
const idleTime = now - (this.controlState.lastActionAt || this.controlState.startedAt || now);
|
|
389
|
-
|
|
390
|
-
if (idleTime >= this.IDLE_TIMEOUT_MS) {
|
|
391
|
-
debug.log('mcp', `⏰ MCP control idle for ${Math.round(idleTime / 1000)}s, auto-releasing...`);
|
|
392
|
-
this.releaseControl();
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* Auto-release control for a specific browser tab (called when tab closes).
|
|
398
|
-
* projectId is used to prevent accidental release across projects with same tab IDs.
|
|
399
|
-
*/
|
|
400
|
-
autoReleaseForTab(browserTabId: string, projectId?: string): void {
|
|
401
|
-
if (!this.controlState.isControlling || this.controlState.browserTabId !== browserTabId) return;
|
|
402
|
-
if (projectId && this.controlState.projectId && this.controlState.projectId !== projectId) return;
|
|
403
|
-
debug.log('mcp', `🗑️ Auto-releasing MCP control for closed tab: ${browserTabId}`);
|
|
404
|
-
this.releaseControl(browserTabId);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Force release all control (for cleanup)
|
|
409
|
-
*/
|
|
410
|
-
forceReleaseAll(): void {
|
|
411
|
-
this.stopIdleCheck();
|
|
412
|
-
|
|
413
|
-
if (this.controlState.isControlling && this.controlState.browserTabId) {
|
|
414
|
-
this.emitControlEnd(this.controlState.browserTabId);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
this.controlState = {
|
|
418
|
-
isControlling: false,
|
|
419
|
-
mcpSessionId: null,
|
|
420
|
-
browserTabId: null,
|
|
421
|
-
projectId: null,
|
|
422
|
-
startedAt: null,
|
|
423
|
-
lastActionAt: null
|
|
424
|
-
};
|
|
425
|
-
|
|
426
|
-
debug.log('mcp', '🧹 Force released all MCP control');
|
|
427
|
-
}
|
|
428
407
|
}
|
|
429
408
|
|
|
430
409
|
// Singleton instance
|
|
@@ -733,8 +733,8 @@ class BrowserPreviewServiceManager {
|
|
|
733
733
|
browserMcpControl.on('control-start', (data) => {
|
|
734
734
|
debug.log('preview', `🚀 Forwarding mcp-control-start to project ${projectId}:`, data);
|
|
735
735
|
ws.emit.project(projectId, 'preview:browser-mcp-control-start', {
|
|
736
|
-
|
|
737
|
-
|
|
736
|
+
browserTabId: data.browserTabId,
|
|
737
|
+
chatSessionId: data.chatSessionId,
|
|
738
738
|
timestamp: data.timestamp
|
|
739
739
|
});
|
|
740
740
|
});
|
|
@@ -742,7 +742,7 @@ class BrowserPreviewServiceManager {
|
|
|
742
742
|
browserMcpControl.on('control-end', (data) => {
|
|
743
743
|
debug.log('preview', `🚀 Forwarding mcp-control-end to project ${projectId}:`, data);
|
|
744
744
|
ws.emit.project(projectId, 'preview:browser-mcp-control-end', {
|
|
745
|
-
|
|
745
|
+
browserTabId: data.browserTabId,
|
|
746
746
|
timestamp: data.timestamp
|
|
747
747
|
});
|
|
748
748
|
});
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Handles WebCodecs-based video streaming with WebRTC DataChannel transport.
|
|
5
5
|
*
|
|
6
6
|
* Video Architecture:
|
|
7
|
-
* 1.
|
|
8
|
-
* 2.
|
|
9
|
-
* 3.
|
|
7
|
+
* 1. CDP captures JPEG frames via Page.screencastFrame
|
|
8
|
+
* 2. Direct CDP Runtime.evaluate sends base64 to page (bypasses Puppeteer IPC)
|
|
9
|
+
* 3. Page decodes JPEG via createImageBitmap, encodes with VP8 VideoEncoder
|
|
10
10
|
* 4. Send encoded chunks via RTCDataChannel
|
|
11
11
|
*
|
|
12
12
|
* Audio Architecture:
|
|
@@ -347,17 +347,15 @@ export class BrowserVideoCapture extends EventEmitter {
|
|
|
347
347
|
// ACK immediately
|
|
348
348
|
cdp.send('Page.screencastFrameAck', { sessionId: event.sessionId }).catch(() => {});
|
|
349
349
|
|
|
350
|
-
// Send frame to encoder
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
});
|
|
350
|
+
// Send frame to encoder via direct CDP (bypasses Puppeteer's
|
|
351
|
+
// ExecutionContext lookup, function serialization, Runtime.callFunctionOn
|
|
352
|
+
// overhead, and result deserialization). Base64 charset [A-Za-z0-9+/=]
|
|
353
|
+
// is safe to embed in a JS double-quoted string literal.
|
|
354
|
+
cdp.send('Runtime.evaluate', {
|
|
355
|
+
expression: `window.__webCodecsPeer?.encodeFrame("${event.data}")`,
|
|
356
|
+
awaitPromise: false,
|
|
357
|
+
returnByValue: false
|
|
358
|
+
}).catch(() => {});
|
|
361
359
|
});
|
|
362
360
|
|
|
363
361
|
// Start screencast with scaled dimensions
|
|
@@ -218,23 +218,24 @@ export function videoEncoderScript(config: StreamingConfig['video']) {
|
|
|
218
218
|
if (!videoEncoder || !isCapturing) return;
|
|
219
219
|
|
|
220
220
|
try {
|
|
221
|
-
//
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
type: 'image/jpeg',
|
|
229
|
-
});
|
|
221
|
+
// Direct base64 decode (avoids fetch() + data URL parsing overhead)
|
|
222
|
+
const binaryStr = atob(imageData);
|
|
223
|
+
const len = binaryStr.length;
|
|
224
|
+
const bytes = new Uint8Array(len);
|
|
225
|
+
for (let i = 0; i < len; i++) {
|
|
226
|
+
bytes[i] = binaryStr.charCodeAt(i);
|
|
227
|
+
}
|
|
230
228
|
|
|
231
|
-
|
|
229
|
+
// Decode JPEG via createImageBitmap (avoids per-frame ImageDecoder
|
|
230
|
+
// constructor/destructor overhead)
|
|
231
|
+
const blob = new Blob([bytes], { type: 'image/jpeg' });
|
|
232
|
+
const bitmap = await createImageBitmap(blob);
|
|
232
233
|
|
|
233
234
|
// Get aligned timestamp in microseconds
|
|
234
235
|
const timestamp = performance.now() * 1000;
|
|
235
236
|
|
|
236
|
-
// Create VideoFrame
|
|
237
|
-
const frame = new VideoFrame(
|
|
237
|
+
// Create VideoFrame from ImageBitmap
|
|
238
|
+
const frame = new VideoFrame(bitmap, {
|
|
238
239
|
timestamp,
|
|
239
240
|
alpha: 'discard'
|
|
240
241
|
});
|
|
@@ -253,8 +254,7 @@ export function videoEncoderScript(config: StreamingConfig['video']) {
|
|
|
253
254
|
|
|
254
255
|
// Close immediately to prevent memory leaks
|
|
255
256
|
frame.close();
|
|
256
|
-
|
|
257
|
-
decoder.close();
|
|
257
|
+
bitmap.close();
|
|
258
258
|
} catch (error) {}
|
|
259
259
|
}
|
|
260
260
|
|
|
@@ -229,10 +229,10 @@ export interface StreamingConfig {
|
|
|
229
229
|
/**
|
|
230
230
|
* Default streaming configuration
|
|
231
231
|
*
|
|
232
|
-
* Optimized for
|
|
233
|
-
* - Software encoding
|
|
234
|
-
* -
|
|
235
|
-
* - VP8
|
|
232
|
+
* Optimized for visual quality with reasonable bandwidth:
|
|
233
|
+
* - Software encoding (hardwareAcceleration: 'no-preference')
|
|
234
|
+
* - JPEG quality 70 preserves thin borders/text, VP8 handles bandwidth
|
|
235
|
+
* - VP8 at 1.2Mbps for crisp UI/text rendering
|
|
236
236
|
* - Opus for audio (efficient and widely supported)
|
|
237
237
|
*/
|
|
238
238
|
export const DEFAULT_STREAMING_CONFIG: StreamingConfig = {
|
|
@@ -241,9 +241,9 @@ export const DEFAULT_STREAMING_CONFIG: StreamingConfig = {
|
|
|
241
241
|
width: 0,
|
|
242
242
|
height: 0,
|
|
243
243
|
framerate: 24,
|
|
244
|
-
bitrate:
|
|
245
|
-
keyframeInterval:
|
|
246
|
-
screenshotQuality:
|
|
244
|
+
bitrate: 1_200_000,
|
|
245
|
+
keyframeInterval: 3,
|
|
246
|
+
screenshotQuality: 70,
|
|
247
247
|
hardwareAcceleration: 'no-preference',
|
|
248
248
|
latencyMode: 'realtime'
|
|
249
249
|
},
|
package/backend/preview/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export * from './browser/types';
|
|
3
3
|
|
|
4
4
|
// Export MCP control types
|
|
5
|
-
export type {
|
|
5
|
+
export type { McpControlEvent, McpCursorEvent, McpClickEvent } from './browser/browser-mcp-control';
|
|
6
6
|
|
|
7
7
|
// Export the main preview service class and manager
|
|
8
8
|
export {
|