feat(chat): redesign chat UI with structured rendering and interactive action buttons
This commit is contained in:
@@ -95,9 +95,17 @@ class CDPClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 채팅 영역의 DOM을 스크래핑
|
||||
* 채팅 영역의 DOM을 구조화된 JSON으로 추출
|
||||
*
|
||||
* Antigravity의 에이전트 사이드 패널에서 대화 내용을 추출
|
||||
* Antigravity DOM을 순회하여 메시지 블록을 타입별로 분류:
|
||||
* - task: 작업 카드 (접기/펼치기 가능한 그룹)
|
||||
* - text: 마크다운 텍스트
|
||||
* - thought: Thought Process 블록
|
||||
* - tool: 도구 호출/결과
|
||||
* - code: 코드 블록
|
||||
* - image: 이미지
|
||||
* - user: 사용자 메시지
|
||||
* - status: 상태 표시 (진행 중 등)
|
||||
*/
|
||||
async scrapeChatDOM() {
|
||||
if (!this.connected || !this.client) return null;
|
||||
@@ -106,29 +114,168 @@ class CDPClient {
|
||||
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;
|
||||
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 '<!-- agent chat panel not found -->';
|
||||
return JSON.stringify(messages);
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
});
|
||||
|
||||
return result.value || null;
|
||||
try {
|
||||
return JSON.parse(result.value) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CDP] 채팅 스크래핑 오류:', err.message);
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user