/** * Chat Panel — 구조화된 메시지 렌더링 (Antigravity 스타일) */ class ChatPanel { constructor() { this.containerEl = document.getElementById('chatContainer'); this.emptyEl = document.getElementById('emptyState'); this.messagesEl = document.getElementById('chatMessages'); this.inputEl = document.getElementById('chatInput'); this.sendBtn = document.getElementById('sendBtn'); this.sessionNameEl = document.getElementById('chatSessionName'); 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'; }); this.inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this._sendMessage(); } }); this.sendBtn.addEventListener('click', () => this._sendMessage()); } _sendMessage() { const text = this.inputEl.value.trim(); if (!text) return; if (this.onSendMessage) this.onSendMessage(text); this.inputEl.value = ''; this.inputEl.style.height = 'auto'; } 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.inputEl.focus(); } showEmpty() { this.activeSession = null; this.emptyEl.style.display = 'flex'; this.containerEl.style.display = 'none'; } /** * 구조화된 메시지 배열로 채팅 렌더링 */ updateChat(messages) { if (!messages || !Array.isArray(messages) || messages.length === 0) { this.messagesEl.innerHTML = `

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

`; return; } // 변경 감지 — 같은 내용이면 리렌더 안 함 const hash = JSON.stringify(messages).length + ':' + messages.length; if (hash === this._lastHash) return; this._lastHash = hash; const wasAtBottom = this._isScrolledToBottom(); const frag = document.createDocumentFragment(); for (const msg of messages) { const el = this._renderMessage(msg); if (el) frag.appendChild(el); } 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); case 'user': return this._renderUser(msg); case 'status': return this._renderStatus(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 = '▾'; const title = document.createElement('span'); title.className = 'msg-card-title'; title.textContent = msg.title || 'Task'; header.appendChild(toggle); header.appendChild(title); // 모드 뱃지 if (msg.mode) { const badge = document.createElement('span'); badge.className = 'msg-mode-badge'; const modeMap = { PLANNING: '📋', EXECUTION: '⚙️', VERIFICATION: '✅' }; badge.textContent = modeMap[msg.mode] || msg.mode; badge.title = msg.mode; header.appendChild(badge); } // 요약 if (msg.summary) { const summary = document.createElement('div'); summary.className = 'msg-card-summary'; summary.textContent = msg.summary; header.appendChild(summary); } card.appendChild(header); // 도구 호출 목록 (tools) const toolList = msg.tools || msg.steps || []; if (toolList.length > 0) { const body = document.createElement('div'); body.className = 'msg-card-body'; for (const tool of toolList) { const row = document.createElement('div'); row.className = 'msg-step'; const icon = document.createElement('span'); icon.className = 'msg-step-icon'; icon.textContent = tool.icon || '•'; const text = document.createElement('span'); text.className = 'msg-step-text'; text.textContent = tool.label || tool.text || ''; const check = document.createElement('span'); check.className = 'msg-step-check'; check.textContent = tool.done ? '✓' : '…'; check.style.color = tool.done ? '#22c55e' : '#94a3b8'; row.appendChild(icon); row.appendChild(text); row.appendChild(check); 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 ? '▾' : '▸'; }); } // 카드 내부 액션 버튼 if (msg.actions && msg.actions.length > 0) { const actionsDiv = document.createElement('div'); actionsDiv.className = 'msg-card-actions'; for (const btn of msg.actions) { const el = document.createElement('button'); el.className = 'msg-action-btn'; el.textContent = btn.label; if (['Proceed', 'Approve', 'Accept', 'Yes', 'Allow'].some(k => btn.label.includes(k))) { el.classList.add('msg-action-primary'); } if (btn.x && btn.y) { el.style.cursor = 'pointer'; el.addEventListener('click', (e) => { e.stopPropagation(); if (this.onActionClick) { this.onActionClick({ label: btn.label, x: btn.x, y: btn.y }); } }); } actionsDiv.appendChild(el); } card.appendChild(actionsDiv); } 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'; // 라벨(알림 문구) 표시 if (msg.label) { const labelEl = document.createElement('div'); labelEl.className = 'msg-actions-label'; labelEl.textContent = msg.label; div.appendChild(labelEl); } for (const btn of (msg.buttons || [])) { const el = document.createElement('button'); el.className = btn.variant === 'danger' ? 'msg-action-btn msg-action-danger' : 'msg-action-btn msg-action-primary'; el.textContent = btn.label || btn; el.style.cursor = 'pointer'; // api_call: 직접 API 호출 (승인/거절 등) if (btn.action === 'api_call' && btn.endpoint) { el.addEventListener('click', async () => { el.disabled = true; el.textContent = '처리 중...'; try { const res = await fetch(btn.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(btn.body || {}), }); if (res.ok) { el.textContent = '✓ 완료'; // 모든 형제 버튼 비활성화 div.querySelectorAll('button').forEach(b => { b.disabled = true; }); } else { const err = await res.json().catch(() => ({})); el.textContent = `실패: ${err.error || res.status}`; } } catch (e) { el.textContent = `오류: ${e.message}`; } }); } else if (btn.action === 'switch_mirror') { el.addEventListener('click', () => { document.getElementById('tabMirror')?.click(); }); } else if (btn.x && btn.y) { 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(/= el.scrollHeight - 50; } _scrollToBottom() { this.messagesEl.scrollTop = this.messagesEl.scrollHeight; } /** * 사용자 메시지 렌더링 */ _renderUser(msg) { const wrapper = document.createElement('div'); wrapper.className = 'msg-user'; wrapper.textContent = msg.content; return wrapper; } /** * 상태 표시 (Running, Generating 등) */ _renderStatus(msg) { const wrapper = document.createElement('div'); wrapper.className = 'msg-status'; wrapper.textContent = msg.content; return wrapper; } }