547 lines
18 KiB
JavaScript
547 lines
18 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을 구조화된 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([]);
|
|
|
|
// 각 turn(대화 턴)을 순회
|
|
const turns = topContainer.children;
|
|
for (let i = 0; i < turns.length; i++) {
|
|
const turn = turns[i];
|
|
|
|
// placeholder 블록 건너뛰기 (가상 스크롤)
|
|
const isPlaceholder = 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() === ''
|
|
);
|
|
if (isPlaceholder) continue;
|
|
|
|
// 턴 내부의 각 메시지 블록 순회
|
|
const blocks = turn.querySelectorAll(':scope > *');
|
|
for (const block of blocks) {
|
|
// placeholder 개별 블록도 건너뛰기
|
|
if (block.classList.contains('bg-gray-500/10') && block.textContent.trim() === '') continue;
|
|
|
|
// --- 작업 카드 (task boundary) ---
|
|
const taskCard = block.querySelector('.isolate');
|
|
if (taskCard || block.classList.contains('isolate')) {
|
|
const card = taskCard || block;
|
|
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) });
|
|
}
|
|
});
|
|
|
|
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),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// --- Thought Process ---
|
|
const thoughtBtn = block.querySelector('button');
|
|
if (thoughtBtn && thoughtBtn.textContent.includes('Thought for')) {
|
|
messages.push({
|
|
type: 'thought',
|
|
label: thoughtBtn.textContent.trim(),
|
|
collapsed: true,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// --- 코드 블록 ---
|
|
const pre = block.querySelector('pre');
|
|
if (pre && !block.querySelector('.isolate')) {
|
|
const codeEl = pre.querySelector('code');
|
|
const lang = codeEl ? (codeEl.className.match(/language-(\\w+)/) || [])[1] || '' : '';
|
|
messages.push({
|
|
type: 'code',
|
|
language: lang,
|
|
content: (codeEl || pre).textContent.substring(0, 2000),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// --- 이미지 ---
|
|
const img = block.querySelector('img');
|
|
if (img && img.src) {
|
|
messages.push({
|
|
type: 'image',
|
|
src: img.src,
|
|
alt: img.alt || '',
|
|
width: img.naturalWidth || img.width || 200,
|
|
height: img.naturalHeight || img.height || 150,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// --- 버튼 영역 (Proceed, Cancel 등) ---
|
|
const actionBtns = block.querySelectorAll('button');
|
|
if (actionBtns.length > 0) {
|
|
const actionKeywords = ['Proceed','Cancel','Open','View','Review','Approve','Reject','Yes','No','Accept','Deny','Allow','Skip'];
|
|
const buttons = Array.from(actionBtns).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);
|
|
|
|
if (buttons.length > 0 && buttons.some(b => actionKeywords.some(k => b.label.includes(k)))) {
|
|
messages.push({
|
|
type: 'actions',
|
|
buttons: buttons.slice(0, 8),
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// --- 일반 텍스트 ---
|
|
const text = block.textContent.trim();
|
|
if (text.length > 0) {
|
|
// CSS 코드나 내부 스타일은 건너뛰기
|
|
if (text.startsWith('/*') || text.startsWith('@media') || text.startsWith('.') && text.includes('{')) continue;
|
|
|
|
// leading-relaxed select-text → 마크다운 렌더링 텍스트
|
|
const mkEl = block.querySelector('.leading-relaxed.select-text');
|
|
const htmlContent = mkEl ? mkEl.innerHTML : block.innerHTML;
|
|
|
|
messages.push({
|
|
type: 'text',
|
|
content: text.substring(0, 3000),
|
|
html: htmlContent.substring(0, 5000),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return JSON.stringify(messages);
|
|
})()
|
|
`,
|
|
returnByValue: true,
|
|
});
|
|
|
|
try {
|
|
return JSON.parse(result.value) || [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
} catch (err) {
|
|
console.error('[CDP] 채팅 스크래핑 오류:', err.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|