/** * 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] || {}; // 워크스페이스에서 프로젝트명 추출 (computedName 우선) const repoName = cascade.workspaces?.[0]?.repository?.computedName || ''; const wsUri = cascade.workspaces?.[0]?.workspaceFolderAbsoluteUri || ''; const project = repoName ? repoName.split('/').pop() // "Variet/gravity_web" → "gravity_web" : (wsUri ? decodeURIComponent(wsUri.split('/').pop()) : ''); // 대화 이름: cascade summary > task name > title (Conversation N은 무시) const rawTitle = s.title || ''; const isGeneric = /^Conversation \d+$/.test(rawTitle); const summary = cascade.summary || ''; const taskName = cascade.latestTaskBoundaryStep?.step?.taskBoundary?.taskName || ''; const displayName = summary || taskName || (isGeneric ? '' : rawTitle) || s.id.substring(0, 8); // 상태 const runStatus = cascade.status || ''; const isRunning = runStatus.includes('RUNNING'); return { id: s.id, name: displayName, host: 'bridge', cdpPort: 0, status: isRunning ? 'running' : 'connected', title: displayName, stepCount: cascade.stepCount || s.stepCount || 0, lastModified: cascade.lastModifiedTime || 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' }); } await refreshTrajectory(sessionId); } async function refreshTrajectory(sessionId, scrollToBottom = true) { try { const [trajRes, cascRes] = await Promise.all([ fetch(`/api/bridge/trajectory/${sessionId}`), fetch('/api/bridge/cascades'), ]); let messages = []; let isComplete = true; // trajectory가 전체를 포함하는지 // trajectory 파싱 if (trajRes.ok) { const trajData = await trajRes.json(); if (trajData.trajectory?.steps) { const total = trajData.numTotalSteps || 0; const got = trajData.trajectory.steps.length; isComplete = (total <= got); if (isComplete) { // ≤336 스텝: 전체 표시 가능 messages = parseTrajectoryToMessages(trajData.trajectory.steps); } // > 336 스텝: trajectory 건너뛰고 cascades 중심으로 표시 } } // cascades에서 최신 상태 if (cascRes.ok) { const cascData = await cascRes.json(); const cascade = (cascData.cascades || cascData)[sessionId]; if (cascade) { // 긴 대화일 때: cascades 기반 표시 if (!isComplete) { // 대화 요약 if (cascade.summary) { messages.push({ type: 'status', text: `💬 대화 요약: ${cascade.summary}`, }); } messages.push({ type: 'status', text: `📊 총 ${cascade.stepCount || '?'}개 스텝 · 최종 입력: ${formatRelativeTime(cascade.lastModifiedTime)}`, }); } // 현재 Task 상태 (항상 최신) if (cascade.latestTaskBoundaryStep?.step?.taskBoundary) { const tb = cascade.latestTaskBoundaryStep.step.taskBoundary; // 이미 trajectory에서 같은 task가 없는 경우만 추가 const lastTask = messages.filter(m => m.type === 'task').pop(); if (!lastTask || lastTask.title !== tb.taskName) { messages.push({ type: 'task', title: tb.taskName || '', summary: tb.taskSummary || '', status: tb.taskStatus || '', mode: tb.mode || '', tools: [], }); } } // 최신 AI 응답 (항상 표시) if (cascade.latestNotifyUserStep?.step?.notifyUser) { const nu = cascade.latestNotifyUserStep.step.notifyUser; const content = nu.notificationContent || ''; if (content) { // 이미 trajectory에서 같은 내용이 없는 경우만 추가 const lastText = messages.filter(m => m.type === 'text').pop(); const contentSnip = content.substring(0, 50); if (!lastText || !lastText.html?.includes(contentSnip.replace(/[<>&]/g, ''))) { messages.push({ type: 'text', html: simpleMarkdown(content), }); } } // 🔴 사용자 행동 필요 (isBlocking) if (nu.isBlocking) { messages.push({ type: 'actions', label: '⚠️ 사용자 승인 대기 중', buttons: [ { label: '✅ 진행', action: 'api_call', endpoint: '/api/bridge/action', body: { action: 'acceptStep' } }, { label: '❌ 거절', action: 'api_call', endpoint: '/api/bridge/action', body: { action: 'rejectStep' }, variant: 'danger' }, ], }); } } // 실행 중 상태 표시 if (cascade.status === 'CASCADE_RUN_STATUS_RUNNING') { const lastMsg = messages[messages.length - 1]; if (!lastMsg || lastMsg.type !== 'actions') { messages.push({ type: 'status', text: '🔄 AI가 작업 중...', }); } } } } chatPanel.updateChat(messages); if (scrollToBottom) { const el = document.getElementById('chatMessages'); if (el) setTimeout(() => el.scrollTop = el.scrollHeight, 100); } } catch (e) { console.warn('[Bridge] trajectory 로드 실패:', e); } } function formatRelativeTime(isoStr) { if (!isoStr) return ''; const diff = Date.now() - new Date(isoStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return '방금 전'; if (mins < 60) return `${mins}분 전`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}시간 전`; return `${Math.floor(hrs / 24)}일 전`; } // 실시간 갱신 디바운스 let refreshTimer = null; function scheduleRefresh() { if (refreshTimer) return; refreshTimer = setTimeout(() => { refreshTimer = null; if (activeBridgeSession) refreshTrajectory(activeBridgeSession, false); }, 2000); // 2초 디바운스 } /** * 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, '$1')
.replace(/^## (.*)/gm, '