feat(chat): redesign chat UI with structured rendering and interactive action buttons

This commit is contained in:
2026-03-07 21:08:08 +09:00
parent 4b855c9e57
commit 507324f78e
5 changed files with 826 additions and 107 deletions

View File

@@ -44,9 +44,16 @@
}
/* ─── Reset ──────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
html,
body {
height: 100%;
font-family: var(--font-sans);
font-size: 14px;
@@ -57,13 +64,22 @@ html, body {
}
/* ─── Scrollbar ──────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--bg-active);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* ─── Header ─────────────────────────────────────────── */
.header {
@@ -77,7 +93,11 @@ html, body {
z-index: 100;
}
.header-left { display: flex; align-items: center; gap: 12px; }
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.logo {
display: flex;
@@ -94,7 +114,11 @@ html, body {
-webkit-text-fill-color: transparent;
}
.header-right { display: flex; align-items: center; gap: 16px; }
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.connection-status {
display: flex;
@@ -112,8 +136,14 @@ html, body {
transition: background var(--transition-normal);
}
.status-dot.connected { background: var(--success); box-shadow: 0 0 6px var(--success); }
.status-dot.error { background: var(--error); }
.status-dot.connected {
background: var(--success);
box-shadow: 0 0 6px var(--success);
}
.status-dot.error {
background: var(--error);
}
/* ─── Main Layout ────────────────────────────────────── */
.main-layout {
@@ -181,14 +211,34 @@ html, body {
flex-shrink: 0;
}
.session-indicator.connected { background: var(--success); box-shadow: 0 0 4px rgba(52, 211, 153, 0.4); }
.session-indicator.connecting { background: var(--warning); animation: pulse 1.5s infinite; }
.session-indicator.disconnected { background: var(--text-muted); }
.session-indicator.error { background: var(--error); }
.session-indicator.connected {
background: var(--success);
box-shadow: 0 0 4px rgba(52, 211, 153, 0.4);
}
.session-indicator.connecting {
background: var(--warning);
animation: pulse 1.5s infinite;
}
.session-indicator.disconnected {
background: var(--text-muted);
}
.session-indicator.error {
background: var(--error);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.session-info {
@@ -223,8 +273,14 @@ html, body {
font-size: 14px;
}
.session-card:hover .session-remove { opacity: 1; }
.session-remove:hover { color: var(--error); background: rgba(248, 113, 113, 0.1); }
.session-card:hover .session-remove {
opacity: 1;
}
.session-remove:hover {
color: var(--error);
background: rgba(248, 113, 113, 0.1);
}
/* ─── Chat Area ──────────────────────────────────────── */
.chat-area {
@@ -317,32 +373,265 @@ html, body {
font-size: 13px;
}
/* Antigravity 에서 가져온 HTML을 표시하는 컨테이너 */
.chat-messages .ag-content {
font-family: var(--font-sans);
line-height: 1.6;
/* Antigravity 에서 가져온 구조화 메시지를 렌더링하는 컴포넌트 */
/* --- 메시지 공통 --- */
.chat-messages>* {
margin-bottom: 8px;
}
/* --- 작업 카드 (Task boundary) --- */
.msg-card {
border: 1px solid var(--border-subtle);
border-radius: 10px;
overflow: hidden;
background: var(--bg-secondary);
transition: border-color var(--transition-fast);
}
.msg-card:hover {
border-color: var(--border-medium);
}
.msg-card-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 10px 14px;
}
.msg-toggle {
font-size: 11px;
color: var(--text-muted);
width: 14px;
text-align: center;
flex-shrink: 0;
transition: color var(--transition-fast);
}
.msg-card-header:hover .msg-toggle {
color: var(--text-primary);
}
.chat-messages .ag-content pre,
.chat-messages .ag-content code {
font-family: var(--font-mono);
.msg-card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.chat-messages .ag-content pre {
.msg-card-summary {
width: 100%;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
padding-left: 22px;
}
.msg-card-body {
border-top: 1px solid var(--border-subtle);
padding: 8px 14px 10px;
}
.msg-step {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 3px 0;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.msg-step-icon {
width: 16px;
text-align: center;
flex-shrink: 0;
font-size: 11px;
}
.msg-step-text {
flex: 1;
min-width: 0;
word-break: break-word;
}
/* --- 텍스트 메시지 --- */
.msg-text {
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
padding: 4px 0;
word-break: break-word;
}
.msg-text p {
margin: 4px 0;
}
.msg-text ul,
.msg-text ol {
padding-left: 18px;
margin: 4px 0;
}
.msg-text li {
margin: 2px 0;
}
.msg-text strong {
font-weight: 600;
}
.msg-text a {
color: var(--text-accent);
text-decoration: none;
}
.msg-text a:hover {
text-decoration: underline;
}
.msg-text code:not(pre code) {
font-family: var(--font-mono);
font-size: 12px;
background: var(--bg-tertiary);
padding: 1px 5px;
border-radius: 4px;
color: var(--text-accent);
}
.msg-text pre {
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: 8px;
padding: 14px;
padding: 10px 12px;
overflow-x: auto;
margin: 10px 0;
margin: 6px 0;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
}
.chat-messages .ag-content code {
.msg-text img {
max-width: 320px;
max-height: 200px;
border-radius: 8px;
margin: 4px 0;
}
/* --- Thought Process --- */
.msg-thought {
margin: 4px 0;
}
.msg-thought-btn {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 10px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 12px;
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
}
.msg-thought-btn:hover {
background: var(--bg-hover);
color: var(--text-secondary);
}
.msg-thought-icon {
font-size: 14px;
}
/* --- 코드 블록 --- */
.msg-code-block {
border: 1px solid var(--border-subtle);
border-radius: 8px;
overflow: hidden;
margin: 4px 0;
}
.msg-code-lang {
padding: 4px 12px;
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
text-transform: lowercase;
}
.msg-code-block pre {
margin: 0;
padding: 10px 12px;
background: var(--bg-primary);
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
}
.msg-code-block code {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
}
/* --- 이미지 --- */
.msg-image {
margin: 4px 0;
}
.msg-image img {
max-width: 320px;
max-height: 200px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
object-fit: contain;
}
/* --- 액션 버튼 --- */
.msg-actions {
display: flex;
gap: 6px;
padding: 4px 0;
flex-wrap: wrap;
}
.msg-action-btn {
padding: 4px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: 6px;
color: var(--text-secondary);
font-family: var(--font-sans);
font-size: 12px;
cursor: default;
transition: all var(--transition-fast);
}
.msg-action-btn[style*="cursor: pointer"]:hover {
background: var(--bg-hover);
border-color: var(--border-medium);
color: var(--text-primary);
}
.msg-action-btn.msg-action-primary {
background: var(--accent-primary);
color: white;
border-color: var(--accent-primary);
}
.msg-action-btn.msg-action-primary:hover {
background: var(--accent-secondary);
transform: scale(1.02);
}
/* Chat Input */
@@ -371,8 +660,13 @@ html, body {
transition: border-color var(--transition-fast);
}
.chat-input-area textarea::placeholder { color: var(--text-muted); }
.chat-input-area textarea:focus { border-color: var(--accent-primary); }
.chat-input-area textarea::placeholder {
color: var(--text-muted);
}
.chat-input-area textarea:focus {
border-color: var(--accent-primary);
}
/* ─── Buttons ────────────────────────────────────────── */
.btn-icon {
@@ -430,7 +724,9 @@ html, body {
transform: scale(1.05);
}
.btn-send:active { transform: scale(0.95); }
.btn-send:active {
transform: scale(0.95);
}
.btn {
padding: 8px 16px;
@@ -448,7 +744,9 @@ html, body {
color: white;
}
.btn-primary:hover { background: var(--accent-secondary); }
.btn-primary:hover {
background: var(--accent-secondary);
}
.btn-secondary {
background: var(--bg-tertiary);
@@ -456,7 +754,9 @@ html, body {
border: 1px solid var(--border-subtle);
}
.btn-secondary:hover { background: var(--bg-hover); }
.btn-secondary:hover {
background: var(--bg-hover);
}
/* ─── Modal ──────────────────────────────────────────── */
.modal-backdrop {
@@ -471,7 +771,15 @@ html, body {
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: var(--bg-elevated);
@@ -483,7 +791,17 @@ html, body {
animation: slideUp 0.2s ease;
}
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: none; opacity: 1; } }
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: none;
opacity: 1;
}
}
.modal-header {
display: flex;
@@ -533,7 +851,9 @@ html, body {
transition: border-color var(--transition-fast);
}
.form-group input:focus { border-color: var(--accent-primary); }
.form-group input:focus {
border-color: var(--accent-primary);
}
.form-hint {
font-size: 11px;
@@ -605,11 +925,32 @@ html, body {
animation-fill-mode: forwards;
}
@keyframes slideInRight { from { transform: translateX(100px); opacity: 0; } to { transform: none; opacity: 1; } }
@keyframes fadeOut { to { opacity: 0; transform: translateY(10px); } }
@keyframes slideInRight {
from {
transform: translateX(100px);
opacity: 0;
}
.toast.error { border-color: var(--error); }
.toast.success { border-color: var(--success); }
to {
transform: none;
opacity: 1;
}
}
@keyframes fadeOut {
to {
opacity: 0;
transform: translateY(10px);
}
}
.toast.error {
border-color: var(--error);
}
.toast.success {
border-color: var(--success);
}
/* ─── View Tabs (Chat ↔ Mirror) ──────────────────────── */
.view-tabs {
@@ -678,4 +1019,3 @@ html, body {
pointer-events: none;
animation: pulse 1.5s infinite;
}

View File

@@ -98,7 +98,7 @@
case 'chat_update':
if (msg.sessionId === sessionPanel.activeSessionId) {
chatPanel.updateChat(msg.html);
chatPanel.updateChat(msg.messages);
}
break;
@@ -129,6 +129,10 @@
case 'screencast_stopped':
break;
case 'action_clicked':
// 서버에서의 확인 응답 (토스트는 이미 프론트엔드에서 표시)
break;
case 'error':
showToast(msg.message, 'error');
break;
@@ -164,6 +168,17 @@
});
};
chatPanel.onActionClick = (button) => {
sendWs({
type: 'click_action',
sessionId: sessionPanel.activeSessionId,
label: button.label,
x: button.x,
y: button.y,
});
showToast(`"${button.label}" 클릭`, 'success');
};
// ─── 미러 패널 이벤트 ─────────────────────────────
mirrorPanel.onStartScreencast = (sessionId) => {
sendWs({ type: 'start_screencast', sessionId });

View File

@@ -1,5 +1,5 @@
/**
* Chat Panel — 채팅 표시/입력 UI 관리
* Chat Panel — 구조화된 메시지 렌더링 (Antigravity 스타일)
*/
class ChatPanel {
@@ -13,22 +13,19 @@ class ChatPanel {
this.sessionStatusEl = document.getElementById('chatSessionStatus');
this.onSendMessage = null; // callback(text)
this.onActionClick = null; // callback(button) - {label, x, y}
this.activeSession = null;
this._lastHash = '';
this._setupInput();
}
/**
* 입력 이벤트 바인딩
*/
_setupInput() {
// 자동 높이 조절
this.inputEl.addEventListener('input', () => {
this.inputEl.style.height = 'auto';
this.inputEl.style.height = Math.min(this.inputEl.scrollHeight, 120) + 'px';
});
// Enter로 전송 (Shift+Enter는 줄바꿈)
this.inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -36,47 +33,27 @@ class ChatPanel {
}
});
this.sendBtn.addEventListener('click', () => {
this._sendMessage();
});
this.sendBtn.addEventListener('click', () => this._sendMessage());
}
_sendMessage() {
const text = this.inputEl.value.trim();
if (!text) return;
if (this.onSendMessage) {
this.onSendMessage(text);
}
// 입력창 초기화
if (this.onSendMessage) this.onSendMessage(text);
this.inputEl.value = '';
this.inputEl.style.height = 'auto';
}
/**
* 세션 선택 시 UI 표시
*/
showSession(session) {
this.activeSession = session;
this.emptyEl.style.display = 'none';
this.containerEl.style.display = 'flex';
this.sessionNameEl.textContent = session.name;
this.sessionStatusEl.textContent = session.status === 'connected' ? '● 연결됨' : session.status;
this.messagesEl.innerHTML = `
<div class="chat-welcome">
<p>채팅 데이터를 불러오는 중...</p>
</div>
`;
this.messagesEl.innerHTML = '<div class="chat-welcome"><p>채팅 데이터를 불러오는 중...</p></div>';
this.inputEl.focus();
}
/**
* 빈 상태 표시
*/
showEmpty() {
this.activeSession = null;
this.emptyEl.style.display = 'flex';
@@ -84,42 +61,258 @@ class ChatPanel {
}
/**
* 채팅 내용 업데이트 (Antigravity DOM HTML)
* 구조화된 메시지 배열로 채팅 렌더링
*/
updateChat(html) {
if (!html || html.includes('chat container not found')) {
updateChat(messages) {
if (!messages || !Array.isArray(messages) || messages.length === 0) {
this.messagesEl.innerHTML = `
<div class="chat-welcome">
<p>⚠️ 채팅 컨테이너를 찾을 수 없습니다.<br>
Antigravity에서 채팅을 시작해주세요.</p>
</div>
`;
<div class="chat-welcome">
<p>⚠️ 채팅 데이터를 가져올 수 없습니다.<br>
Antigravity에서 채팅을 시작해주세요.</p>
</div>`;
return;
}
// HTML 삽입 (Antigravity에서 가져온 DOM)
// 변경 감지 — 같은 내용이면 리렌더 안 함
const hash = JSON.stringify(messages).length + ':' + messages.length;
if (hash === this._lastHash) return;
this._lastHash = hash;
const wasAtBottom = this._isScrolledToBottom();
const frag = document.createDocumentFragment();
this.messagesEl.innerHTML = `<div class="ag-content">${html}</div>`;
for (const msg of messages) {
const el = this._renderMessage(msg);
if (el) frag.appendChild(el);
}
// 스크롤 유지
if (wasAtBottom) {
this._scrollToBottom();
this.messagesEl.innerHTML = '';
this.messagesEl.appendChild(frag);
if (wasAtBottom) this._scrollToBottom();
}
/**
* 메시지 타입별 렌더러
*/
_renderMessage(msg) {
switch (msg.type) {
case 'task': return this._renderTask(msg);
case 'text': return this._renderText(msg);
case 'thought': return this._renderThought(msg);
case 'code': return this._renderCode(msg);
case 'image': return this._renderImage(msg);
case 'actions': return this._renderActions(msg);
default: return null;
}
}
/**
* 세션 상태 업데이트
* 작업 카드 — Antigravity의 task boundary 카드
*/
_renderTask(msg) {
const card = document.createElement('div');
card.className = 'msg-card';
// 헤더
const header = document.createElement('div');
header.className = 'msg-card-header';
const toggle = document.createElement('span');
toggle.className = 'msg-toggle';
toggle.textContent = msg.collapsed ? '▸' : '▾';
const title = document.createElement('span');
title.className = 'msg-card-title';
title.textContent = msg.title || 'Task';
header.appendChild(toggle);
header.appendChild(title);
// 요약
if (msg.summary) {
const summary = document.createElement('div');
summary.className = 'msg-card-summary';
summary.textContent = msg.summary;
header.appendChild(summary);
}
card.appendChild(header);
// 하위 항목
if (msg.steps && msg.steps.length > 0) {
const body = document.createElement('div');
body.className = 'msg-card-body';
if (msg.collapsed) body.style.display = 'none';
for (const step of msg.steps) {
const row = document.createElement('div');
row.className = 'msg-step';
const icon = document.createElement('span');
icon.className = 'msg-step-icon';
icon.textContent = step.icon || '•';
const text = document.createElement('span');
text.className = 'msg-step-text';
text.textContent = step.text;
row.appendChild(icon);
row.appendChild(text);
body.appendChild(row);
}
card.appendChild(body);
// 접기/펴기 토글
header.style.cursor = 'pointer';
header.addEventListener('click', () => {
const isHidden = body.style.display === 'none';
body.style.display = isHidden ? '' : 'none';
toggle.textContent = isHidden ? '▾' : '▸';
});
}
return card;
}
/**
* 텍스트 메시지 — 마크다운 렌더링
*/
_renderText(msg) {
const div = document.createElement('div');
div.className = 'msg-text';
if (msg.html) {
// Antigravity에서 가져온 렌더링된 HTML (sanitized subset)
div.innerHTML = this._sanitizeHtml(msg.html);
} else {
div.textContent = msg.content || '';
}
return div;
}
/**
* Thought Process — 접을 수 있는 블록
*/
_renderThought(msg) {
const div = document.createElement('div');
div.className = 'msg-thought';
const btn = document.createElement('button');
btn.className = 'msg-thought-btn';
btn.innerHTML = `<span class="msg-thought-icon">💭</span> ${this._escapeHtml(msg.label || 'Thought')}`;
div.appendChild(btn);
return div;
}
/**
* 코드 블록
*/
_renderCode(msg) {
const wrapper = document.createElement('div');
wrapper.className = 'msg-code-block';
if (msg.language) {
const lang = document.createElement('div');
lang.className = 'msg-code-lang';
lang.textContent = msg.language;
wrapper.appendChild(lang);
}
const pre = document.createElement('pre');
const code = document.createElement('code');
code.textContent = msg.content || '';
pre.appendChild(code);
wrapper.appendChild(pre);
return wrapper;
}
/**
* 이미지 — 크기 제한 적용
*/
_renderImage(msg) {
const div = document.createElement('div');
div.className = 'msg-image';
const img = document.createElement('img');
img.src = msg.src;
img.alt = msg.alt || '';
img.loading = 'lazy';
// 최대 크기 제한
img.style.maxWidth = '320px';
img.style.maxHeight = '200px';
div.appendChild(img);
return div;
}
/**
* 액션 버튼 영역
*/
_renderActions(msg) {
const div = document.createElement('div');
div.className = 'msg-actions';
for (const btn of (msg.buttons || [])) {
const el = document.createElement('button');
el.className = 'msg-action-btn';
el.textContent = btn.label || btn;
// Proceed/Review 등 주요 액션은 강조
const label = btn.label || btn;
if (['Proceed', 'Approve', 'Accept', 'Yes', 'Allow'].some(k => label.includes(k))) {
el.classList.add('msg-action-primary');
}
// 좌표가 있으면 클릭 가능
if (btn.x && btn.y) {
el.style.cursor = 'pointer';
el.addEventListener('click', () => {
if (this.onActionClick) {
this.onActionClick({ label: btn.label, x: btn.x, y: btn.y });
}
});
}
div.appendChild(el);
}
return div;
}
/**
* HTML sanitize (간단히 위험 태그 제거)
*/
_sanitizeHtml(html) {
// script, iframe, object, embed, on* 속성 제거
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, '')
.replace(/<object\b[^>]*>[\s\S]*?<\/object>/gi, '')
.replace(/<embed\b[^>]*>/gi, '')
.replace(/\bon\w+\s*=\s*"[^"]*"/gi, '')
.replace(/\bon\w+\s*=\s*'[^']*'/gi, '')
// 이미지 크기 제한 인라인 추가
.replace(/<img\b/gi, '<img style="max-width:320px;max-height:200px;border-radius:8px;" ');
}
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
updateSessionStatus(status) {
if (!this.activeSession) return;
const statusText = {
connected: '● 연결됨',
disconnected: '○ 연결 끊김',
error: '⚠ 오류',
};
this.sessionStatusEl.textContent = statusText[status] || status;
}

View File

@@ -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',
];
const conv = document.querySelector('#conversation');
if (!conv) return JSON.stringify([]);
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) {
return el.innerHTML;
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 [];
}
}

View File

@@ -179,6 +179,30 @@ async function handleWsMessage(ws, msg) {
break;
}
case 'click_action': {
// 채팅 탭에서 Proceed/Cancel 등 버튼 클릭
const session = sessionManager.getSession(msg.sessionId || state.activeSessionId);
if (!session) {
ws.send(JSON.stringify({ type: 'error', message: '활성 세션 없음' }));
return;
}
const { x, y } = msg;
try {
// mousePressed + mouseReleased = 클릭
await session.client.dispatchInput({
type: 'mouse', action: 'mousePressed', x, y, button: 'left', clickCount: 1,
});
await session.client.dispatchInput({
type: 'mouse', action: 'mouseReleased', x, y, button: 'left', clickCount: 1,
});
ws.send(JSON.stringify({ type: 'action_clicked', success: true, label: msg.label }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: `버튼 클릭 실패: ${err.message}` }));
}
break;
}
default:
ws.send(JSON.stringify({ type: 'error', message: `알 수 없는 메시지 타입: ${msg.type}` }));
}
@@ -188,12 +212,12 @@ async function handleWsMessage(ws, msg) {
* 특정 세션의 CDP 채팅 폴링을 시작하고 결과를 ws 클라이언트에 전송
*/
function startSessionPolling(session, ws) {
session.client.onChatUpdate = (html) => {
session.client.onChatUpdate = (messages) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'chat_update',
sessionId: session.id,
html: html,
messages: messages,
timestamp: Date.now(),
}));
}