/** * 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'), ]); const sessData = sessRes.ok ? await sessRes.json() : { sessions: [] }; let cascadeMap = {}; if (cascRes.ok) { const cascData = await cascRes.json(); cascadeMap = cascData.cascades || cascData; } // 세션 → 맵으로 변환 (id 기준) const sessionIds = new Set(); const sessionList = (sessData.sessions || []); function buildSession(id, sessionData, cascade) { const s = sessionData || {}; const c = cascade || {}; // 워크스페이스에서 프로젝트명 추출 const repoName = c.workspaces?.[0]?.repository?.computedName || ''; const wsUri = c.workspaces?.[0]?.workspaceFolderAbsoluteUri || ''; const project = repoName ? repoName.split('/').pop() : (wsUri ? decodeURIComponent(wsUri.split('/').pop()) : ''); // 대화 이름: cascade summary > task name > title const rawTitle = s.title || c.summary || ''; const isGeneric = /^Conversation \d+$/.test(rawTitle); const summary = c.summary || ''; const taskName = c.latestTaskBoundaryStep?.step?.taskBoundary?.taskName || ''; const displayName = summary || taskName || (isGeneric ? '' : rawTitle) || id.substring(0, 8); const runStatus = c.status || ''; const isRunning = runStatus.includes('RUNNING'); return { id: id, name: displayName, host: 'bridge', cdpPort: 0, status: isRunning ? 'running' : 'connected', title: displayName, stepCount: c.stepCount || s.stepCount || 0, lastModified: c.lastModifiedTime || s.lastModifiedTime, project: project, isRunning: isRunning, isBridge: true, }; } // 1) Sessions API 기반 목록 bridgeSessions = sessionList.map(s => { sessionIds.add(s.id); return buildSession(s.id, s, cascadeMap[s.id]); }); // 2) Cascades에만 있는 대화 추가 (Sessions API 누락분) for (const [cascadeId, cascade] of Object.entries(cascadeMap)) { if (!sessionIds.has(cascadeId)) { bridgeSessions.push(buildSession(cascadeId, null, cascade)); } } // 최근 수정 순 정렬 bridgeSessions.sort((a, b) => { const ta = a.lastModified ? new Date(a.lastModified).getTime() : 0; const tb = b.lastModified ? new Date(b.lastModified).getTime() : 0; return tb - ta; }); sessionPanel.update(bridgeSessions); // 활성 세션 없으면 가장 최근 대화 자동 선택 if (!sessionPanel.activeSessionId && bridgeSessions.length > 0) { selectBridgeSession(bridgeSessions[0].id); } } catch (e) { console.warn('[Bridge] 세션 로드 실패:', e); } } // 채팅 메시지 저장소 (lazy load용) let allMessages = []; const PAGE_SIZE = 30; 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' }); } // lazy load 콜백 등록 chatPanel.onLoadMore = () => { const shown = chatPanel.getShownCount(); const remaining = allMessages.length - shown; if (remaining <= 0) return; const batch = allMessages.slice(Math.max(0, remaining - PAGE_SIZE), remaining); chatPanel.prependMessages(batch); }; try { await loadSessionMessages(sessionId); } catch (e) { console.warn('[Bridge] 초기 로드 실패:', e); chatPanel.updateChat([{ type: 'status', text: '⚠️ 데이터 로드 실패. 잠시 후 자동 재시도...' }], false, true); } } async function loadSessionMessages(sessionId, scrollToBottom = true) { try { // 1. trajectory (히스토리, 최대 ~341 스텝) // 2. 서버 누적 메시지 (trajectory 이후 cascade diff로 캡쳐된 것들) const [trajRes, accRes] = await Promise.all([ fetch(`/api/bridge/trajectory/${sessionId}`), fetch(`/api/bridge/messages/${sessionId}`), ]); allMessages = []; let trajLastStepIndex = -1; if (trajRes.ok) { const trajData = await trajRes.json(); if (trajData.trajectory?.steps) { const steps = trajData.trajectory.steps; allMessages = parseTrajectoryToMessages(steps); // 마지막 stepIndex 기억 for (let i = steps.length - 1; i >= 0; i--) { if (steps[i].metadata?.createdAt) { trajLastStepIndex = i; break; } } } } // 3. 서버 누적 메시지 병합 (trajectory 이후 것만) let currentStatus = { isRunning: false, isBlocking: false }; if (accRes.ok) { const accData = await accRes.json(); currentStatus = { isRunning: accData.isRunning, isBlocking: accData.isBlocking }; if (accData.messages?.length > 0) { for (const msg of accData.messages) { // trajectory에 이미 있는 메시지는 스킵 if (msg.stepIndex !== undefined && msg.stepIndex <= trajLastStepIndex) continue; if (msg.type === 'task') { allMessages.push({ type: 'task', title: msg.title || '', summary: msg.summary || '', status: msg.status || '', mode: msg.mode || '', tools: [], }); } else if (msg.type === 'text') { allMessages.push({ type: 'text', html: simpleMarkdown(msg.content || ''), }); } else if (msg.type === 'user_input') { // 사용자 입력 (텍스트 없음, 시간만) } } } } // 4. 상태 표시 (blocking / waiting / running) if (currentStatus.isBlocking || currentStatus.isWaiting) { allMessages.push({ type: 'actions', label: '⚠️ 사용자 승인 대기 중', buttons: [ { label: '✅ 진행', action: 'api_call', endpoint: '/api/bridge/approve', body: { type: 'accept' } }, { label: '❌ 거절', action: 'api_call', endpoint: '/api/bridge/approve', body: { type: 'reject' }, variant: 'danger' }, ], }); } else if (currentStatus.isRunning) { allMessages.push({ type: 'status', text: '🔄 AI가 작업 중...' }); } // 5. 화면 표시 const initial = allMessages.slice(Math.max(0, allMessages.length - PAGE_SIZE)); chatPanel.updateChat(initial, allMessages.length > PAGE_SIZE, scrollToBottom); } catch (e) { console.warn('[Bridge] 메시지 로드 실패:', 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)}일 전`; } /** * 서버 WS push된 새 메시지를 화면에 즉시 반영 let _prevStatusKey = ''; // 이전 상태 추적 (상태 변경 시만 DOM 갱신) function applyNewMessages(data) { if (!activeBridgeSession) return; // 버튼 클릭 처리 중이면 DOM 교체 방지 if (chatPanel.actionInProgress) return; const hasNewMessages = data.messages && data.messages.length > 0; // 현재 상태 키 계산 const statusKey = data.isBlocking ? 'blocking' : data.isWaiting ? 'waiting' : data.isRunning ? 'running' : 'idle'; const statusChanged = statusKey !== _prevStatusKey; _prevStatusKey = statusKey; // 기존 상태 메시지(status/actions) 제거 while (allMessages.length > 0) { const last = allMessages[allMessages.length - 1]; if (last.type === 'status' || last.type === 'actions') { allMessages.pop(); } else { break; } } // 새 메시지 추가 if (hasNewMessages) { for (const msg of data.messages) { if (msg.type === 'task') { const lastTask = allMessages.filter(m => m.type === 'task').pop(); if (lastTask && lastTask.title === msg.title) { lastTask.summary = msg.summary || lastTask.summary; lastTask.status = msg.status || lastTask.status; } else { allMessages.push({ type: 'task', title: msg.title || '', summary: msg.summary || '', status: msg.status || '', mode: msg.mode || '', tools: [], }); } } else if (msg.type === 'text') { allMessages.push({ type: 'text', html: simpleMarkdown(msg.content || ''), }); } } } // 상태 표시 if (data.isBlocking || data.isWaiting) { allMessages.push({ type: 'actions', label: '⚠️ 사용자 승인 대기 중', buttons: [ { label: '✅ 진행', action: 'api_call', endpoint: '/api/bridge/approve', body: { type: 'accept' } }, { label: '❌ 거절', action: 'api_call', endpoint: '/api/bridge/approve', body: { type: 'reject' }, variant: 'danger' }, ], }); } else if (data.isRunning) { allMessages.push({ type: 'status', text: '🔄 AI가 작업 중...' }); } // 화면 갱신: 새 메시지 또는 상태 변경 시만 (동일 상태 반복은 건너뛰어 스크롤 점프 방지) if (hasNewMessages || statusChanged) { const initial = allMessages.slice(Math.max(0, allMessages.length - PAGE_SIZE)); chatPanel.updateChat(initial, allMessages.length > PAGE_SIZE, false); } } /** * 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(); let text = ''; const ur = step.userInput?.userResponse || ''; const itemText = step.userInput?.items?.[0]?.item?.text || step.userInput?.items?.[0]?.item?.recipe?.title || ''; // 시스템 자동승인 메시지 필터 if (ur && !ur.includes('system-generated message')) { text = ur; } else if (itemText) { text = itemText; } // 시스템 자동승인만 있는 경우 표시 스킵 if (!text || text.includes('system-generated message')) break; 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) { // 코드 블록을 임시 플레이스홀더로 보존 const codeBlocks = []; let processed = text .replace(/&/g, '&').replace(//g, '>') .replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { const idx = codeBlocks.length; codeBlocks.push(`
${code}`);
return `___CODE_BLOCK_${idx}___`;
});
// 코드 블록 외부만 줄바꿈 변환
processed = processed
.replace(/\n/g, '$1')
.replace(/^## (.*)/gm, '