Files
gravity_web/public/js/app.js

689 lines
28 KiB
JavaScript

/**
* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="md-code"><code>$2</code></pre>')
.replace(/\n/g, '<br>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/^## (.*)/gm, '<h3>$1</h3>')
.replace(/^### (.*)/gm, '<h4>$1</h4>')
.replace(/^\- (.*)/gm, '<li>$1</li>')
.replace(/^\d+\. (.*)/gm, '<li>$1</li>');
}
// ─── 서버 메시지 핸들러 ───────────────────────────────
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':
// Bridge WS 이벤트 처리
if (msg.step_changed || msg.type === 'step_changed') {
scheduleRefresh();
loadBridgeSessions(); // 세션 목록도 갱신 (stepCount 등)
} else if (msg.type === 'session_changed' || msg.type === 'new_conversation') {
loadBridgeSessions();
} else if (msg.type === 'state_changed') {
scheduleRefresh();
}
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();
})();