/** * 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.pollInterval = null; this.onChatUpdate = null; // callback(messages[]) this.onDisconnect = null; // callback() this.onScreencastFrame = null; // callback({data, metadata}) this.screencastRunning = false; // 메시지 누적 시스템 this.messageHistory = []; // 전체 누적 메시지 this.seenHashes = new Set(); // 중복 제거용 해시 this.initialScrapeComplete = false; } /** * 메시지 해시 생성 (중복 제거용) */ _msgHash(msg) { if (msg.type === 'task') return `task:${msg.title}:${msg.summary?.substring(0, 50) || ''}`; if (msg.type === 'user') return `user:${msg.content?.substring(0, 100) || ''}`; if (msg.type === 'text') return `text:${msg.content?.substring(0, 100) || ''}`; if (msg.type === 'thought') return `thought:${msg.label || ''}`; if (msg.type === 'status') return `status:${msg.content || ''}`; return `${msg.type}:${JSON.stringify(msg).substring(0, 100)}`; } /** * 새 메시지들을 히스토리에 누적 (중복 제거) * @returns {boolean} 새 메시지가 추가되었는지 */ _accumulateMessages(newMessages) { if (!newMessages || newMessages.length === 0) return false; let added = false; // status 타입은 항상 마지막 것만 유지 (변경될 수 있음) const statusMsgs = newMessages.filter(m => m.type === 'status'); const nonStatusMsgs = newMessages.filter(m => m.type !== 'status'); for (const msg of nonStatusMsgs) { const hash = this._msgHash(msg); if (!this.seenHashes.has(hash)) { this.seenHashes.add(hash); // task 카드의 actions/steps는 업데이트될 수 있음 → 기존 것 교체 if (msg.type === 'task') { const existIdx = this.messageHistory.findIndex(m => m.type === 'task' && m.title === msg.title ); if (existIdx >= 0) { this.messageHistory[existIdx] = msg; added = true; continue; } } this.messageHistory.push(msg); added = true; } } // status는 마지막 것으로 교체 if (statusMsgs.length > 0) { const lastStatus = statusMsgs[statusMsgs.length - 1]; const existStatusIdx = this.messageHistory.findIndex(m => m.type === 'status'); if (existStatusIdx >= 0) { if (this.messageHistory[existStatusIdx].content !== lastStatus.content) { this.messageHistory[existStatusIdx] = lastStatus; added = true; } } else { this.messageHistory.push(lastStatus); added = true; } } return added; } /** * 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을 구조화된 JSON으로 추출 * * Antigravity DOM을 순회하여 메시지 블록을 타입별로 분류: * - task: 작업 카드 (접기/펼치기 가능한 그룹) * - text: 마크다운 텍스트 * - thought: Thought Process 블록 * - tool: 도구 호출/결과 * - code: 코드 블록 * - image: 이미지 * - user: 사용자 메시지 * - status: 상태 표시 (진행 중 등) */ async scrapeChatDOM() { if (!this.connected || !this.client) return null; try { const { result } = await this.client.Runtime.evaluate({ expression: ` (function() { const conv = document.querySelector('#conversation'); if (!conv) return JSON.stringify([]); const scrollEl = conv.querySelector('.overflow-y-auto'); if (!scrollEl) return JSON.stringify([]); const messages = []; const topContainer = scrollEl.querySelector('.mx-auto.w-full > div > div'); if (!topContainer) return JSON.stringify([]); const actionKeywords = ['Proceed','Cancel','Open','View','Review','Approve','Reject','Yes','No','Accept','Deny','Allow','Skip']; // 유틸: 액션 버튼 추출 function extractActions(container) { return Array.from(container.querySelectorAll('button')).map(b => { const label = b.textContent.trim(); const rect = b.getBoundingClientRect(); return { label, x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2), w: Math.round(rect.width), h: Math.round(rect.height), }; }).filter(b => b.label && b.w > 0 && actionKeywords.some(k => b.label.includes(k))); } // 유틸: 마크다운 영역에서 콘텐츠를 추출 function extractContentBlocks(container) { // select-text 또는 leading-relaxed 마크다운 렌더링 영역 찾기 const mkEls = container.querySelectorAll('.select-text .leading-relaxed, .leading-relaxed.select-text'); for (const mkEl of mkEls) { // style 태그 제거 const clone = mkEl.cloneNode(true); clone.querySelectorAll('style').forEach(s => s.remove()); const html = clone.innerHTML; const text = clone.textContent.trim(); if (!text || text.length < 2) continue; // CSS 필터 if (text.startsWith('/*') || text.includes('prefers-color-scheme')) continue; messages.push({ type: 'text', content: text.substring(0, 5000), html: html.substring(0, 10000), }); } } // 각 turn을 순회 const turns = topContainer.children; for (let i = 0; i < turns.length; i++) { const turn = turns[i]; // placeholder 건너뛰기 if (turn.children.length > 0 && Array.from(turn.children).every(c => c.classList.contains('rounded-lg') && c.classList.contains('bg-gray-500/10') && c.textContent.trim() === '' )) continue; // style 태그 미리 제거 turn.querySelectorAll('style').forEach(s => s.remove()); // --- 사용자 메시지 감지 (bg-gray-500/15 + select-text) --- const userMsgEl = turn.querySelector('.bg-gray-500\\\\/15.select-text, .bg-gray-500\\\\/15 .select-text'); if (userMsgEl) { const text = userMsgEl.textContent.trim(); if (text) { messages.push({ type: 'user', content: text.substring(0, 2000) }); } } // --- isolate 카드들 (task boundary) --- const isolates = turn.querySelectorAll('.isolate'); for (const card of isolates) { const titleEl = card.querySelector('.font-semibold'); const summaryEl = card.querySelector('.text-sm .leading-relaxed'); const expanded = card.querySelector('[aria-expanded]'); const steps = []; card.querySelectorAll('.flex.items-center.gap-2, .flex.w-full.items-center.gap-2').forEach(step => { const txt = step.textContent.trim(); if (txt && txt.length > 2) { const svg = step.querySelector('svg'); let icon = ''; if (svg) { const cls = svg.getAttribute('class') || ''; if (cls.includes('check')) icon = '✓'; else if (cls.includes('loader') || cls.includes('spin')) icon = '⟳'; else if (cls.includes('x-circle') || cls.includes('alert')) icon = '⚠'; } steps.push({ icon, text: txt.substring(0, 200) }); } }); const cardBtns = extractActions(card); messages.push({ type: 'task', title: titleEl ? titleEl.textContent.trim() : '', summary: summaryEl ? summaryEl.textContent.trim().substring(0, 500) : '', collapsed: expanded ? expanded.getAttribute('aria-expanded') === 'false' : true, steps: steps.slice(0, 20), actions: cardBtns.slice(0, 5), }); } // --- Thought Process --- const thoughtBtns = turn.querySelectorAll('button'); for (const btn of thoughtBtns) { if (btn.textContent.includes('Thought for')) { messages.push({ type: 'thought', label: btn.textContent.trim(), collapsed: true }); } } // --- isolate 바깥의 마크다운 콘텐츠 --- // (isolate 내부가 아닌 마크다운 블록) const allMkEls = turn.querySelectorAll('.leading-relaxed.select-text, .select-text .leading-relaxed'); for (const mkEl of allMkEls) { // isolate 내부면 건너뛰기 (이미 task로 처리) if (mkEl.closest('.isolate')) continue; const clone = mkEl.cloneNode(true); clone.querySelectorAll('style').forEach(s => s.remove()); const html = clone.innerHTML; const text = clone.textContent.trim(); if (!text || text.length < 2) continue; if (text.startsWith('/*') || text.includes('prefers-color-scheme')) continue; messages.push({ type: 'text', content: text.substring(0, 5000), html: html.substring(0, 10000), }); } // --- isolate 바깥 독립 코드/이미지/상태 --- const turnBlocks = turn.querySelectorAll(':scope > *'); for (const block of turnBlocks) { if (block.querySelector('.isolate') || block.classList.contains('isolate')) continue; // 상태 텍스트 (Running, Generating 등) if (block.classList.contains('whitespace-nowrap')) { const st = block.textContent.trim(); if (st) messages.push({ type: 'status', content: st }); continue; } } } return JSON.stringify(messages); })() `, returnByValue: true, }); try { return JSON.parse(result.value) || []; } catch { return []; } } catch (err) { console.error('[CDP] 채팅 스크래핑 오류:', err.message); return []; } } /** * Antigravity 채팅 입력창에 메시지를 전송 * * 단일 Runtime.evaluate로 요소 찾기 + 텍스트 입력 수행 * (2단계 분리 시 셀렉터 이스케이프 손실 버그 방지) */ async sendMessage(text) { if (!this.connected || !this.client) { return { success: false, error: 'CDP 연결 없음' }; } try { const safeText = JSON.stringify(text); const { result } = await this.client.Runtime.evaluate({ expression: ` (function() { const selectors = [ '#antigravity\\\\.agentSidePanelInputBox [contenteditable="true"][role="textbox"]', '.antigravity-agent-side-panel [contenteditable="true"][role="textbox"]', '#conversation [contenteditable="true"]', '[contenteditable="true"][role="textbox"]', ]; let el = null; for (const sel of selectors) { el = document.querySelector(sel); if (el) break; } if (!el) return { success: false, error: 'input not found' }; el.focus(); const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); selection.removeAllRanges(); selection.addRange(range); document.execCommand('insertText', false, ${safeText}); return { success: true, text: el.textContent.substring(0, 50) }; })() `, returnByValue: true, }); if (!result.value?.success) { return { success: false, error: result.value?.error || '입력 실패' }; } // 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(); // 초기 전체 스캔 (한 번만) if (!this.initialScrapeComplete) { this._initialFullScrape().then(() => { this.initialScrapeComplete = true; console.log(`[CDP] 초기 전체 스캔 완료: ${this.messageHistory.length}개 메시지`); }); } this.pollInterval = setInterval(async () => { if (!this.connected) { this.stopPolling(); return; } const messages = await this.scrapeChatDOM(); if (this._accumulateMessages(messages)) { if (this.onChatUpdate) { this.onChatUpdate([...this.messageHistory]); } } }, intervalMs); console.log(`[CDP] 폴링 시작 (${intervalMs}ms 간격)`); } /** * 초기 전체 스크롤 스캔 — Antigravity 대화를 top→bottom으로 순회하며 전체 수집 */ async _initialFullScrape() { if (!this.connected || !this.client) return; try { // 스크롤 정보 가져오기 const { result: scrollInfo } = await this.client.Runtime.evaluate({ expression: ` (function() { const conv = document.querySelector('#conversation'); if (!conv) return JSON.stringify(null); const sc = conv.querySelector('.overflow-y-auto'); if (!sc) return JSON.stringify(null); return JSON.stringify({ scrollTop: sc.scrollTop, scrollHeight: sc.scrollHeight, clientHeight: sc.clientHeight, }); })() `, returnByValue: true, }); const info = JSON.parse(scrollInfo.value); if (!info) return; const savedScrollTop = info.scrollTop; const totalHeight = info.scrollHeight; const viewHeight = info.clientHeight; const steps = Math.ceil(totalHeight / (viewHeight * 0.8)); // 80% 겹침두며 스크롤 console.log(`[CDP] 전체 스캔 시작: ${totalHeight}px, ${steps}단계`); // top으로 이동 await this.client.Runtime.evaluate({ expression: `document.querySelector('#conversation .overflow-y-auto').scrollTop = 0;`, }); await new Promise(r => setTimeout(r, 300)); // 상단부터 하단까지 스크래핑 for (let step = 0; step < steps; step++) { const messages = await this.scrapeChatDOM(); this._accumulateMessages(messages); // 80% 단위로 스크롤 await this.client.Runtime.evaluate({ expression: ` const sc = document.querySelector('#conversation .overflow-y-auto'); sc.scrollTop += Math.round(sc.clientHeight * 0.8); `, }); await new Promise(r => setTimeout(r, 200)); } // 원래 스크롤 위치 복원 await this.client.Runtime.evaluate({ expression: `document.querySelector('#conversation .overflow-y-auto').scrollTop = ${savedScrollTop};`, }); // 초기 수집 결과 전송 if (this.messageHistory.length > 0 && this.onChatUpdate) { this.onChatUpdate([...this.messageHistory]); } console.log(`[CDP] 전체 스캔 결과: ${this.messageHistory.length}개 메시지 수집`); } catch (err) { console.error('[CDP] 전체 스캔 오류:', err.message); } } /** * 채팅 폴링 중지 */ 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;