/**
* 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 스텝 배열을 ChatPanel message[] 형식으로 변환
*/
function parseTrajectoryToMessages(steps) {
const msgs = [];
for (const step of steps) {
switch (step.type) {
case 'CORTEX_STEP_TYPE_USER_INPUT': {
const text = step.userInput?.items?.[0]?.chunk?.value || '';
if (text) {
msgs.push({ type: 'user', text });
}
break;
}
case 'CORTEX_STEP_TYPE_NOTIFY_USER': {
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) {
msgs.push({
type: 'task',
title: tb.taskName || '',
summary: tb.taskSummary || '',
status: tb.taskStatus || '',
mode: tb.mode || '',
});
}
break;
}
case 'CORTEX_STEP_TYPE_CODE_ACTION': {
const ca = step.codeAction || {};
const file = ca.filePath || ca.targetFile || '';
const desc = ca.description || '';
if (file || desc) {
msgs.push({
type: 'code',
language: '',
code: '',
description: `📝 ${file ? file.split(/[\\/]/).pop() : '파일'}: ${desc || '코드 수정'}`,
});
}
break;
}
case 'CORTEX_STEP_TYPE_RUN_COMMAND': {
const cmd = step.runCommand?.commandLine || step.runCommand?.command || '';
if (cmd) {
msgs.push({
type: 'code',
language: 'bash',
code: cmd,
description: `⚡ 명령 실행 (${step.status === 'CORTEX_STEP_STATUS_DONE' ? '완료' : '실행 중'})`,
});
}
break;
}
case 'CORTEX_STEP_TYPE_PLANNER_RESPONSE': {
// 에이전트 사고 과정 — 접기 가능
const text = step.plannerResponse?.text || step.plannerResponse?.rawText || '';
if (text && text.length > 10) {
msgs.push({
type: 'thought',
text: text.substring(0, 300) + (text.length > 300 ? '...' : ''),
});
}
break;
}
// 덜 중요한 스텝은 생략 (VIEW_FILE, LIST_DIRECTORY, EPHEMERAL 등)
}
}
return msgs;
}
function simpleMarkdown(text) {
return text
.replace(/&/g, '&').replace(//g, '>')
.replace(/\n/g, '
')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/^## (.*)/gm, '