Files
gravity_web/public/js/chat-panel.js

423 lines
14 KiB
JavaScript

/**
* 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 = '<div class="chat-welcome"><p>채팅 데이터를 불러오는 중...</p></div>';
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 = `
<div class="chat-welcome">
<p>⚠️ 채팅 데이터를 가져올 수 없습니다.<br>
Antigravity에서 채팅을 시작해주세요.</p>
</div>`;
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 = `<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';
// 라벨(알림 문구) 표시
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\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;
}
_isScrolledToBottom() {
const el = this.messagesEl;
return el.scrollTop + el.clientHeight >= 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;
}
}