- Add message-accumulator.js: cascades diff-based message accumulation - Add 3-second cascade polling with broadcastToAll (was undefined!) - Add /api/bridge/approve endpoint: tries accept/reject Step→Command→Terminal - Add persistent approve/reject buttons in chat header toolbar - Frontend: loadSessionMessages (trajectory + accumulated), applyNewMessages (WS push) - Status change detection: _prevStatusKey tracking prevents unnecessary re-renders - actionInProgress flag prevents DOM replacement during button fetch - Known issues: Trajectory 341 hard limit, Cascade no command-approval state
828 lines
34 KiB
JavaScript
828 lines
34 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'),
|
|
]);
|
|
|
|
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(/>/g, '>')
|
|
.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
|
const idx = codeBlocks.length;
|
|
codeBlocks.push(`<pre class="md-code"><code>${code}</code></pre>`);
|
|
return `___CODE_BLOCK_${idx}___`;
|
|
});
|
|
// 코드 블록 외부만 줄바꿈 변환
|
|
processed = processed
|
|
.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>');
|
|
// 코드 블록 복원
|
|
for (let i = 0; i < codeBlocks.length; i++) {
|
|
processed = processed.replace(`___CODE_BLOCK_${i}___`, codeBlocks[i]);
|
|
}
|
|
return processed;
|
|
}
|
|
|
|
// ─── 서버 메시지 핸들러 ───────────────────────────────
|
|
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 이벤트 → 세션 목록만 갱신 (메시지는 new_messages로 처리)
|
|
if (msg.type === 'step_changed') {
|
|
if (!this._lastSessionLoad || Date.now() - this._lastSessionLoad > 10000) {
|
|
this._lastSessionLoad = Date.now();
|
|
loadBridgeSessions();
|
|
}
|
|
} else if (msg.type === 'session_changed' || msg.type === 'new_conversation') {
|
|
loadBridgeSessions();
|
|
}
|
|
break;
|
|
|
|
case 'new_messages':
|
|
// 서버가 push한 누적 메시지 → 즉시 화면 반영
|
|
if (msg.sessionId === activeBridgeSession) {
|
|
applyNewMessages(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;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ─── 세션 패널 이벤트 ─────────────────────────────────
|
|
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 = async (button) => {
|
|
// Bridge 세션이면 REST API로 호출
|
|
const isBridge = bridgeSessions.some(s => s.id === sessionPanel.activeSessionId);
|
|
if (isBridge && button.action === 'api_call' && button.endpoint) {
|
|
try {
|
|
const res = await fetch(button.endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(button.body || {}),
|
|
});
|
|
if (res.ok) {
|
|
showToast(`"${button.label}" 완료`, 'success');
|
|
} else {
|
|
showToast(`"${button.label}" 실패`, 'error');
|
|
}
|
|
} catch {
|
|
showToast(`"${button.label}" 오류`, 'error');
|
|
}
|
|
return;
|
|
}
|
|
// CDP 세션: WS로 좌표 클릭
|
|
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();
|
|
});
|
|
|
|
// ─── 스크린샷 ─────────────────────────────────────────
|
|
// ─── 헤더 승인/거절 버튼 ──────────────────────────────────
|
|
const btnApprove = document.getElementById('btnApprove');
|
|
const btnReject = document.getElementById('btnReject');
|
|
|
|
async function handleApproval(type, btn) {
|
|
btn.disabled = true;
|
|
const origText = btn.textContent;
|
|
btn.textContent = '처리 중...';
|
|
try {
|
|
const res = await fetch('/api/bridge/approve', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ type }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
btn.textContent = '✓ 완료';
|
|
showToast(`${type === 'accept' ? '승인' : '거절'} 성공`, 'success');
|
|
} else {
|
|
btn.textContent = `실패`;
|
|
showToast(data.error || '요청 실패', 'error');
|
|
}
|
|
} catch (e) {
|
|
btn.textContent = '오류';
|
|
showToast(e.message, 'error');
|
|
}
|
|
setTimeout(() => {
|
|
btn.textContent = origText;
|
|
btn.disabled = false;
|
|
}, 2000);
|
|
}
|
|
|
|
btnApprove.addEventListener('click', () => handleApproval('accept', btnApprove));
|
|
btnReject.addEventListener('click', () => handleApproval('reject', btnReject));
|
|
|
|
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();
|
|
})();
|