Files
gravity_web/server/cdp-client.js

400 lines
11 KiB
JavaScript

/**
* 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 '<!-- agent chat panel not found -->';
})()
`,
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;