feat: Gravity Web Phase 1 - CDP remote control dashboard
This commit is contained in:
277
server/cdp-client.js
Normal file
277
server/cdp-client.js
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* CDP Client — Antigravity IDE와 Chrome DevTools Protocol로 통신
|
||||
*
|
||||
* 채팅 DOM 스크래핑, 메시지 전송, 상태 모니터링
|
||||
*/
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* CDP 연결 수립
|
||||
*/
|
||||
async connect() {
|
||||
try {
|
||||
// CDP 타겟 목록에서 올바른 페이지 찾기
|
||||
const targets = await CDP.List({ host: this.host, port: this.port });
|
||||
|
||||
// Antigravity 메인 윈도우 타겟 찾기 (type: 'page')
|
||||
const pageTarget = 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 } = 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의 채팅 컨테이너를 JS로 탐색하여 HTML 반환
|
||||
* DOM 셀렉터는 실제 Antigravity 버전에 따라 조정 필요
|
||||
*/
|
||||
async scrapeChatDOM() {
|
||||
if (!this.connected || !this.client) return null;
|
||||
|
||||
try {
|
||||
const { result } = await this.client.Runtime.evaluate({
|
||||
expression: `
|
||||
(function() {
|
||||
// Antigravity 채팅 컨테이너 셀렉터들 (우선순위 순)
|
||||
const selectors = [
|
||||
'.chat-messages-container',
|
||||
'[class*="chat"][class*="container"]',
|
||||
'[class*="conversation"]',
|
||||
'[class*="messages"]',
|
||||
'.monaco-workbench .part.panel .content',
|
||||
];
|
||||
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
return el.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// 폴백: body 전체(디버깅용)
|
||||
return '<!-- chat container not found -->';
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
});
|
||||
|
||||
return result.value || null;
|
||||
} catch (err) {
|
||||
console.error('[CDP] 채팅 스크래핑 오류:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Antigravity 채팅 입력창에 메시지를 전송
|
||||
*/
|
||||
async sendMessage(text) {
|
||||
if (!this.connected || !this.client) {
|
||||
return { success: false, error: 'CDP 연결 없음' };
|
||||
}
|
||||
|
||||
try {
|
||||
// 1) 입력창 찾기 및 포커스
|
||||
const { result: focusResult } = await this.client.Runtime.evaluate({
|
||||
expression: `
|
||||
(function() {
|
||||
const selectors = [
|
||||
'textarea[class*="chat"]',
|
||||
'[class*="chat"] textarea',
|
||||
'[class*="input"] textarea',
|
||||
'textarea[placeholder]',
|
||||
'.chat-input textarea',
|
||||
'[contenteditable="true"]',
|
||||
];
|
||||
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
el.focus();
|
||||
return { found: true, tag: el.tagName, sel: sel };
|
||||
}
|
||||
}
|
||||
return { found: false };
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
});
|
||||
|
||||
if (!focusResult.value?.found) {
|
||||
return { success: false, error: '채팅 입력창을 찾을 수 없습니다' };
|
||||
}
|
||||
|
||||
// 2) 텍스트 입력 (clipboardData 방식 - 가장 범용적)
|
||||
await this.client.Runtime.evaluate({
|
||||
expression: `
|
||||
(function() {
|
||||
const el = document.querySelector('${focusResult.value.sel}');
|
||||
if (!el) return;
|
||||
|
||||
// contenteditable인 경우
|
||||
if (el.contentEditable === 'true') {
|
||||
el.textContent = ${JSON.stringify(text)};
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
} else {
|
||||
// textarea인 경우 — React setState 호환
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype, 'value'
|
||||
).set;
|
||||
nativeInputValueSetter.call(el, ${JSON.stringify(text)});
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
})()
|
||||
`,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스크린샷 (Phase 2 미리보기용)
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CDPClient;
|
||||
Reference in New Issue
Block a user