/** * CDP Client — Antigravity IDE와 Chrome DevTools Protocol로 통신 * * 채팅 DOM 스크래핑, 메시지 전송, 상태 모니터링 * * Antigravity DOM 구조 (v1.107.0 기준): * .antigravity-agent-side-panel * └ #conversation ← 대화 스크롤 영역 * ├ .overflow-y-auto ← 메시지 스크롤 컨테이너 * │ └ (메시지 블록들) * └ .text-ide-message-block-bot-color ← 입력 영역 래퍼 * └ #antigravity.agentSidePanelInputBox * └ [contenteditable][role="textbox"] ← 입력창 */ const CDP = require('chrome-remote-interface'); class CDPClient { constructor(host = 'localhost', port = 9000) { this.host = host; this.port = port; this.client = null; this.connected = false; this.lastChatHTML = ''; this.pollInterval = null; this.onChatUpdate = null; // callback(html) this.onDisconnect = null; // callback() this.onScreencastFrame = null; // callback({data, metadata}) this.screencastRunning = false; } /** * CDP 연결 수립 */ async connect() { try { // CDP 타겟 목록에서 올바른 페이지 찾기 const targets = await CDP.List({ host: this.host, port: this.port }); // Antigravity 메인 워크벤치 타겟 (jetski-agent 런치패드 제외) const pageTarget = targets.find(t => t.type === 'page' && t.url.includes('workbench.html') && !t.url.includes('jetski') ) || targets.find(t => t.type === 'page' && !t.url.includes('devtools')); if (!pageTarget) { throw new Error('Antigravity 페이지 타겟을 찾을 수 없습니다'); } this.client = await CDP({ host: this.host, port: this.port, target: pageTarget, }); const { Runtime, DOM, Page, Input } = this.client; await Promise.all([ Runtime.enable(), DOM.enable(), Page.enable(), ]); this.connected = true; // 연결 끊김 감지 this.client.on('disconnect', () => { this.connected = false; this.stopPolling(); if (this.onDisconnect) this.onDisconnect(); }); console.log(`[CDP] 연결 성공: ${this.host}:${this.port} → ${pageTarget.title}`); return { success: true, title: pageTarget.title }; } catch (err) { this.connected = false; console.error(`[CDP] 연결 실패: ${this.host}:${this.port} — ${err.message}`); return { success: false, error: err.message }; } } /** * 연결 해제 */ async disconnect() { this.stopPolling(); if (this.client) { try { await this.client.close(); } catch (e) { /* ignore */ } this.client = null; } this.connected = false; } /** * 채팅 영역의 DOM을 스크래핑 * * Antigravity의 에이전트 사이드 패널에서 대화 내용을 추출 */ async scrapeChatDOM() { if (!this.connected || !this.client) return null; try { const { result } = await this.client.Runtime.evaluate({ expression: ` (function() { // Antigravity 실제 DOM 셀렉터 (우선순위 순) const selectors = [ '#conversation', '.antigravity-agent-side-panel', ]; for (const sel of selectors) { const el = document.querySelector(sel); if (el) { return el.innerHTML; } } return ''; })() `, returnByValue: true, }); return result.value || null; } catch (err) { console.error('[CDP] 채팅 스크래핑 오류:', err.message); return null; } } /** * Antigravity 채팅 입력창에 메시지를 전송 * * Antigravity는 textarea가 아닌 contenteditable div를 사용함 */ async sendMessage(text) { if (!this.connected || !this.client) { return { success: false, error: 'CDP 연결 없음' }; } try { // 1) contenteditable 입력창 찾기 및 포커스 const { result: focusResult } = await this.client.Runtime.evaluate({ expression: ` (function() { // Antigravity 입력창 셀렉터 (우선순위 순) const selectors = [ '#antigravity\\\\.agentSidePanelInputBox [contenteditable="true"][role="textbox"]', '.antigravity-agent-side-panel [contenteditable="true"][role="textbox"]', '#conversation [contenteditable="true"]', '[contenteditable="true"][role="textbox"]', ]; for (const sel of selectors) { const el = document.querySelector(sel); if (el) { el.focus(); return { found: true, sel: sel }; } } return { found: false }; })() `, returnByValue: true, }); if (!focusResult.value?.found) { return { success: false, error: '채팅 입력창을 찾을 수 없습니다' }; } // 2) 텍스트 입력 (contenteditable용 — execCommand 방식) await this.client.Runtime.evaluate({ expression: ` (function() { const el = document.querySelector('${focusResult.value.sel}'); if (!el) return; el.focus(); // 기존 내용 선택 후 삭제 const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); selection.removeAllRanges(); selection.addRange(range); // 텍스트 삽입 (React/Preact 호환) document.execCommand('insertText', false, ${JSON.stringify(text)}); })() `, returnByValue: true, }); // 3) Enter 키 전송 await this.client.Input.dispatchKeyEvent({ type: 'keyDown', key: 'Enter', code: 'Enter', nativeVirtualKeyCode: 13, windowsVirtualKeyCode: 13, }); await this.client.Input.dispatchKeyEvent({ type: 'keyUp', key: 'Enter', code: 'Enter', nativeVirtualKeyCode: 13, windowsVirtualKeyCode: 13, }); console.log(`[CDP] 메시지 전송: "${text.substring(0, 50)}..."`); return { success: true }; } catch (err) { console.error('[CDP] 메시지 전송 오류:', err.message); return { success: false, error: err.message }; } } /** * 채팅 폴링 시작 (1초 간격) */ startPolling(intervalMs = 1000) { this.stopPolling(); this.pollInterval = setInterval(async () => { if (!this.connected) { this.stopPolling(); return; } const html = await this.scrapeChatDOM(); if (html && html !== this.lastChatHTML) { this.lastChatHTML = html; if (this.onChatUpdate) { this.onChatUpdate(html); } } }, intervalMs); console.log(`[CDP] 폴링 시작 (${intervalMs}ms 간격)`); } /** * 채팅 폴링 중지 */ stopPolling() { if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } } /** * 현재 페이지 타이틀 가져오기 */ async getTitle() { if (!this.connected || !this.client) return null; try { const { result } = await this.client.Runtime.evaluate({ expression: 'document.title', returnByValue: true, }); return result.value; } catch { return null; } } /** * 스크린샷 (미리보기용) */ async captureScreenshot() { if (!this.connected || !this.client) return null; try { const { data } = await this.client.Page.captureScreenshot({ format: 'jpeg', quality: 60 }); return data; // base64 } catch (err) { console.error('[CDP] 스크린샷 오류:', err.message); return null; } } // ─── Phase 2: Screencast & Remote Input ──────────────── /** * 실시간 화면 스트리밍 시작 * Page.startScreencast로 프레임을 수신하고 onScreencastFrame 콜백으로 전달 */ async startScreencast(options = {}) { if (!this.connected || !this.client) return false; if (this.screencastRunning) return true; const { format = 'jpeg', quality = 60, maxWidth = 1280, maxHeight = 900, everyNthFrame = 2, } = options; try { // 프레임 이벤트 리스너 등록 this.client.Page.screencastFrame(async (params) => { // ACK — 이걸 보내야 다음 프레임이 옴 try { await this.client.Page.screencastFrameAck({ sessionId: params.sessionId }); } catch { /* ignore */ } if (this.onScreencastFrame) { this.onScreencastFrame({ data: params.data, // base64 JPEG metadata: params.metadata, // {offsetTop, pageScaleFactor, deviceWidth, deviceHeight, ...} sessionId: params.sessionId, }); } }); await this.client.Page.startScreencast({ format, quality, maxWidth, maxHeight, everyNthFrame, }); this.screencastRunning = true; console.log(`[CDP] Screencast 시작 (${maxWidth}x${maxHeight}, q${quality}, every ${everyNthFrame}th frame)`); return true; } catch (err) { console.error('[CDP] Screencast 시작 오류:', err.message); return false; } } /** * 실시간 화면 스트리밍 중지 */ async stopScreencast() { if (!this.connected || !this.client || !this.screencastRunning) return; try { await this.client.Page.stopScreencast(); } catch { /* ignore */ } this.screencastRunning = false; this.onScreencastFrame = null; console.log('[CDP] Screencast 중지'); } /** * 원격 입력 이벤트 디스패치 * @param {Object} evt — { type: 'mouse'|'key'|'scroll', ... } */ async dispatchInput(evt) { if (!this.connected || !this.client) return; try { switch (evt.type) { case 'mouse': await this.client.Input.dispatchMouseEvent({ type: evt.action, // mousePressed, mouseReleased, mouseMoved x: evt.x, y: evt.y, button: evt.button || 'left', clickCount: evt.clickCount || 1, modifiers: evt.modifiers || 0, }); break; case 'scroll': await this.client.Input.dispatchMouseEvent({ type: 'mouseWheel', x: evt.x, y: evt.y, deltaX: evt.deltaX || 0, deltaY: evt.deltaY || 0, }); break; case 'key': await this.client.Input.dispatchKeyEvent({ type: evt.action, // keyDown, keyUp, char key: evt.key, code: evt.code || '', text: evt.text || '', nativeVirtualKeyCode: evt.keyCode || 0, windowsVirtualKeyCode: evt.keyCode || 0, modifiers: evt.modifiers || 0, }); break; } } catch (err) { // 입력 이벤트 오류는 빈번할 수 있으므로 조용히 처리 } } } module.exports = CDPClient;