/** * Gravity Web — App 초기화 및 WebSocket 관리 */ (function () { // ─── 컴포넌트 초기화 ────────────────────────────────── const sessionPanel = new SessionPanel(); const chatPanel = new ChatPanel(); const mirrorPanel = new MirrorPanel(); let ws = null; let reconnectTimer = null; let currentView = 'chat'; // 'chat' | 'mirror' const WS_URL = `ws://${location.host}/ws`; // ─── DOM 레퍼런스 ───────────────────────────────────── const connectionStatus = document.getElementById('connectionStatus'); const statusDot = connectionStatus.querySelector('.status-dot'); const statusText = connectionStatus.querySelector('.status-text'); const addSessionBtn = document.getElementById('addSessionBtn'); const addSessionModal = document.getElementById('addSessionModal'); const closeModal = document.getElementById('closeModal'); const cancelModal = document.getElementById('cancelModal'); const confirmAddSession = document.getElementById('confirmAddSession'); const sessionNameInput = document.getElementById('sessionName'); const sessionHostInput = document.getElementById('sessionHost'); const sessionPortInput = document.getElementById('sessionPort'); const screenshotBtn = document.getElementById('screenshotBtn'); const reconnectBtn = document.getElementById('reconnectBtn'); const screenshotOverlay = document.getElementById('screenshotOverlay'); const screenshotImage = document.getElementById('screenshotImage'); const closeScreenshot = document.getElementById('closeScreenshot'); const tabChat = document.getElementById('tabChat'); const tabMirror = document.getElementById('tabMirror'); const chatMessagesWrap = document.getElementById('chatMessages'); const chatInputArea = document.querySelector('.chat-input-area'); const mirrorHint = document.getElementById('mirrorHint'); // ─── WebSocket 연결 ─────────────────────────────────── function connectWebSocket() { ws = new WebSocket(WS_URL); ws.onopen = () => { setConnectionStatus('connected', '서버 연결됨'); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } // Bridge 세션 자동 로드 loadBridgeSessions(); }; ws.onclose = () => { setConnectionStatus('error', '연결 끊김'); scheduleReconnect(); }; ws.onerror = () => { setConnectionStatus('error', '연결 오류'); }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); handleMessage(msg); } catch (e) { console.error('WS 메시지 파싱 오류:', e); } }; } function scheduleReconnect() { if (reconnectTimer) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; connectWebSocket(); }, 3000); } function sendWs(msg) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(msg)); } } // ─── Bridge 세션 관리 ────────────────────────────────── let bridgeSessions = []; // Bridge에서 가져온 대화 목록 let activeBridgeSession = null; async function loadBridgeSessions() { try { // 세션 목록 + cascades를 병렬로 가져오기 const [sessRes, cascRes] = await Promise.all([ fetch('/api/bridge/sessions'), fetch('/api/bridge/cascades'), ]); if (!sessRes.ok) return; const sessData = await sessRes.json(); let cascadeMap = {}; if (cascRes.ok) { const cascData = await cascRes.json(); cascadeMap = cascData.cascades || cascData; } bridgeSessions = (sessData.sessions || []).map(s => { const cascade = cascadeMap[s.id] || {}; // 워크스페이스에서 프로젝트명 추출 const wsUri = cascade.workspaces?.[0]?.workspaceFolderAbsoluteUri || ''; const project = wsUri ? decodeURIComponent(wsUri.split('/').pop()) : ''; // 상태 const runStatus = cascade.status || ''; const isRunning = runStatus.includes('RUNNING'); return { id: s.id, name: s.title || s.id.substring(0, 8), host: 'bridge', cdpPort: 0, status: isRunning ? 'running' : 'connected', title: s.title, stepCount: s.stepCount, lastModified: s.lastModifiedTime, project: project, isRunning: isRunning, isBridge: true, }; }); sessionPanel.update(bridgeSessions); // 활성 세션 없으면 가장 최근 대화 자동 선택 if (!sessionPanel.activeSessionId && bridgeSessions.length > 0) { selectBridgeSession(bridgeSessions[0].id); } } catch (e) { console.warn('[Bridge] 세션 로드 실패:', e); } } async function selectBridgeSession(sessionId) { activeBridgeSession = sessionId; sessionPanel.setActive(sessionId); const session = bridgeSessions.find(s => s.id === sessionId); if (session) { chatPanel.showSession({ name: session.name, status: session.isRunning ? 'running' : 'connected' }); } // trajectory에서 전체 대화 내용 가져오기 try { const res = await fetch(`/api/bridge/trajectory/${sessionId}`); if (!res.ok) return; const data = await res.json(); if (data.trajectory?.steps) { const messages = parseTrajectoryToMessages(data.trajectory.steps); chatPanel.updateChat(messages); } } catch (e) { console.warn('[Bridge] trajectory 로드 실패:', e); } } /** * Trajectory 스텝을 Antigravity 스타일 대화 흐름으로 변환 * * 규칙: * - USER_INPUT → 사용자 턴 구분자 (새 대화 블록 시작) * - NOTIFY_USER → AI의 최종 응답 (마크다운) * - TASK_BOUNDARY → 작업 카드 (이후 도구 스텝들을 내부 요약으로 포함) * - RUN_COMMAND, CODE_ACTION → task 카드 내부 도구 호출 요약 * - 기타 (PLANNER_RESPONSE, EPHEMERAL, VIEW_FILE 등) → 건너뛰기 */ function parseTrajectoryToMessages(steps) { const msgs = []; let currentTask = null; // 현재 활성 task 카드 let toolActions = []; // 현재 task 내 도구 호출들 function flushTask() { if (currentTask) { currentTask.tools = [...toolActions]; msgs.push(currentTask); currentTask = null; toolActions = []; } } for (const step of steps) { switch (step.type) { case 'CORTEX_STEP_TYPE_USER_INPUT': { // 이전 task 플러시 flushTask(); // 사용자 턴 구분 const text = step.userInput?.items?.[0]?.chunk?.value || ''; msgs.push({ type: 'user', text: text || '(사용자 입력)', time: step.metadata?.createdAt || '', }); break; } case 'CORTEX_STEP_TYPE_NOTIFY_USER': { // 이전 task 플러시 flushTask(); const content = step.notifyUser?.notificationContent || ''; if (content) { msgs.push({ type: 'text', html: simpleMarkdown(content), }); } break; } case 'CORTEX_STEP_TYPE_TASK_BOUNDARY': { const tb = step.taskBoundary || {}; if (!tb.taskName) break; // 같은 이름의 task는 업데이트 (중복 카드 방지) if (currentTask && currentTask.title === tb.taskName) { currentTask.summary = tb.taskSummary || currentTask.summary; currentTask.status = tb.taskStatus || currentTask.status; currentTask.mode = tb.mode || currentTask.mode; } else { flushTask(); currentTask = { type: 'task', title: tb.taskName, summary: tb.taskSummary || '', status: tb.taskStatus || '', mode: tb.mode || '', tools: [], }; } break; } case 'CORTEX_STEP_TYPE_RUN_COMMAND': { const cmd = step.runCommand?.commandLine || step.runCommand?.command || ''; if (cmd) { toolActions.push({ icon: '⚡', label: cmd.length > 60 ? cmd.substring(0, 57) + '...' : cmd, done: step.status === 'CORTEX_STEP_STATUS_DONE', }); } break; } case 'CORTEX_STEP_TYPE_CODE_ACTION': { const ca = step.codeAction || {}; const file = ca.filePath || ca.targetFile || ''; const desc = ca.description || '코드 수정'; toolActions.push({ icon: '📝', label: file ? file.split(/[\\/]/).pop() + ': ' + desc : desc, done: step.status === 'CORTEX_STEP_STATUS_DONE', }); break; } case 'CORTEX_STEP_TYPE_VIEW_FILE': case 'CORTEX_STEP_TYPE_LIST_DIRECTORY': { // 카운트만 — 개별 표시 안 함 break; } // PLANNER_RESPONSE, EPHEMERAL, CHECKPOINT, CONVERSATION_HISTORY, KNOWLEDGE_ARTIFACTS → 건너뛰기 } } flushTask(); return msgs; } function simpleMarkdown(text) { return text .replace(/&/g, '&').replace(//g, '>') .replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
') .replace(/\n/g, '
') .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/^## (.*)/gm, '

$1

') .replace(/^### (.*)/gm, '

$1

') .replace(/^\- (.*)/gm, '
  • $1
  • ') .replace(/^\d+\. (.*)/gm, '
  • $1
  • '); } // ─── 서버 메시지 핸들러 ─────────────────────────────── function handleMessage(msg) { switch (msg.type) { case 'sessions_list': // CDP 세션 (레거시) — Bridge 세션과 병합하지 않음 break; case 'session_switched': sessionPanel.setActive(msg.sessionId); chatPanel.showSession(msg.session); break; case 'chat_update': if (msg.sessionId === sessionPanel.activeSessionId) { chatPanel.updateChat(msg.messages); } break; case 'message_sent': if (!msg.success) { showToast(`전송 실패: ${msg.error}`, 'error'); } break; case 'bridge_event': handleBridgeEvent(msg); break; case 'screenshot': screenshotImage.src = `data:image/jpeg;base64,${msg.data}`; screenshotOverlay.style.display = 'flex'; break; case 'screencast_frame': if (msg.sessionId === sessionPanel.activeSessionId) { mirrorPanel.updateFrame(msg.data, msg.metadata); if (mirrorHint) mirrorHint.style.display = 'none'; } break; case 'error': showToast(msg.message, 'error'); break; } } function handleBridgeEvent(msg) { switch (msg.type) { case 'bridge_event': // step_changed → 활성 대화 갱신 if (msg.step_changed || msg.sessions) { loadBridgeSessions(); } if (msg.new_conversation) { loadBridgeSessions(); } break; } } // ─── 세션 패널 이벤트 ───────────────────────────────── sessionPanel.onSessionSelect = (sessionId) => { // Bridge 세션인지 확인 const isBridge = bridgeSessions.some(s => s.id === sessionId); if (isBridge) { selectBridgeSession(sessionId); } else { sendWs({ type: 'switch_session', sessionId }); } }; sessionPanel.onSessionRemove = async (sessionId) => { try { const res = await fetch(`/api/sessions/${sessionId}`, { method: 'DELETE' }); if (res.ok) { if (sessionPanel.activeSessionId === sessionId) { chatPanel.showEmpty(); sessionPanel.setActive(null); } showToast('세션이 제거되었습니다', 'success'); } } catch (e) { showToast('세션 제거 실패', 'error'); } }; // ─── 채팅 패널 이벤트 ───────────────────────────────── chatPanel.onSendMessage = async (text) => { if (!sessionPanel.activeSessionId) return; // Bridge 세션이면 Bridge API로 전송 const isBridge = bridgeSessions.some(s => s.id === sessionPanel.activeSessionId); if (isBridge) { try { const res = await fetch('/api/bridge/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, sessionId: sessionPanel.activeSessionId }), }); if (!res.ok) showToast('전송 실패', 'error'); } catch { showToast('전송 실패', 'error'); } return; } sendWs({ type: 'send_message', sessionId: sessionPanel.activeSessionId, text, }); }; 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 }); }; mirrorPanel.onStopScreencast = (sessionId) => { sendWs({ type: 'stop_screencast', sessionId }); }; mirrorPanel.onInputEvent = (sessionId, event) => { sendWs({ type: 'input_event', sessionId, event }); }; // ─── 탭 전환 (Chat ↔ Mirror) ──────────────────────── function switchView(view) { currentView = view; // 탭 UI tabChat.classList.toggle('active', view === 'chat'); tabMirror.classList.toggle('active', view === 'mirror'); // 채팅 영역 표시/숨기기 chatMessagesWrap.style.display = view === 'chat' ? '' : 'none'; if (chatInputArea) chatInputArea.style.display = view === 'chat' ? '' : 'none'; if (view === 'mirror') { if (mirrorHint) mirrorHint.style.display = ''; mirrorPanel.start(sessionPanel.activeSessionId); } else { mirrorPanel.stop(); } } tabChat?.addEventListener('click', () => switchView('chat')); tabMirror?.addEventListener('click', () => switchView('mirror')); // ─── 모달 ───────────────────────────────────────────── function showModal() { addSessionModal.style.display = 'flex'; sessionNameInput.value = ''; sessionHostInput.value = 'localhost'; sessionPortInput.value = '9000'; setTimeout(() => sessionNameInput.focus(), 100); } function hideModal() { addSessionModal.style.display = 'none'; } async function addSession() { const name = sessionNameInput.value.trim(); const host = sessionHostInput.value.trim(); const port = parseInt(sessionPortInput.value, 10); if (!name) { sessionNameInput.focus(); return; } hideModal(); try { const res = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, host, cdpPort: port }), }); const session = await res.json(); if (session.status === 'connected') { showToast(`${name} 연결 성공`, 'success'); } else { showToast(`${name} 연결 실패: ${session.error || '알 수 없는 오류'}`, 'error'); } } catch (e) { showToast('세션 추가 실패', 'error'); } } addSessionBtn.addEventListener('click', showModal); closeModal.addEventListener('click', hideModal); cancelModal.addEventListener('click', hideModal); confirmAddSession.addEventListener('click', addSession); // Enter로 모달 확인 addSessionModal.addEventListener('keydown', (e) => { if (e.key === 'Enter') addSession(); if (e.key === 'Escape') hideModal(); }); // 배경 클릭으로 모달 닫기 addSessionModal.addEventListener('click', (e) => { if (e.target === addSessionModal) hideModal(); }); // ─── 스크린샷 ───────────────────────────────────────── screenshotBtn.addEventListener('click', () => { sendWs({ type: 'get_screenshot', sessionId: sessionPanel.activeSessionId }); }); closeScreenshot.addEventListener('click', () => { screenshotOverlay.style.display = 'none'; }); screenshotOverlay.addEventListener('click', (e) => { if (e.target === screenshotOverlay) { screenshotOverlay.style.display = 'none'; } }); // ─── 재연결 ─────────────────────────────────────────── reconnectBtn.addEventListener('click', async () => { const id = sessionPanel.activeSessionId; if (!id) return; try { const res = await fetch(`/api/sessions/${id}/reconnect`, { method: 'POST' }); const result = await res.json(); if (result.success) { showToast('재연결 성공', 'success'); } else { showToast(`재연결 실패: ${result.error}`, 'error'); } } catch { showToast('재연결 실패', 'error'); } }); // ─── 유틸리티 ───────────────────────────────────────── function setConnectionStatus(status, text) { statusDot.className = `status-dot ${status}`; statusText.textContent = text; } function showToast(message, type = '') { const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => toast.remove(), 3000); } // ─── 시작 ───────────────────────────────────────────── connectWebSocket(); })();