diff --git a/public/css/style.css b/public/css/style.css index e8edf03..83f7f2a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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 { @@ -677,5 +1018,4 @@ html, body { font-size: 14px; pointer-events: none; animation: pulse 1.5s infinite; -} - +} \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js index 54044a8..4103c13 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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 }); diff --git a/public/js/chat-panel.js b/public/js/chat-panel.js index 2a8c6e6..fb70cfb 100644 --- a/public/js/chat-panel.js +++ b/public/js/chat-panel.js @@ -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 = ` -
-

채팅 데이터를 불러오는 중...

-
- `; - + this.messagesEl.innerHTML = '

채팅 데이터를 불러오는 중...

'; 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 = ` -
-

⚠️ 채팅 컨테이너를 찾을 수 없습니다.
- Antigravity에서 채팅을 시작해주세요.

-
- `; +
+

⚠️ 채팅 데이터를 가져올 수 없습니다.
+ Antigravity에서 채팅을 시작해주세요.

+
`; 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 = `
${html}
`; + 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 = `💭 ${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>/gi, '') + .replace(/]*>[\s\S]*?<\/iframe>/gi, '') + .replace(/]*>[\s\S]*?<\/object>/gi, '') + .replace(/]*>/gi, '') + .replace(/\bon\w+\s*=\s*"[^"]*"/gi, '') + .replace(/\bon\w+\s*=\s*'[^']*'/gi, '') + // 이미지 크기 제한 인라인 추가 + .replace(/ 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 ''; + 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 []; } } diff --git a/server/index.js b/server/index.js index 7083873..62e9f79 100644 --- a/server/index.js +++ b/server/index.js @@ -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(), })); }