feat(server,frontend): real-time sync architecture with message accumulator
- 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
This commit is contained in:
437
public/js/app.js
437
public/js/app.js
@@ -97,47 +97,73 @@
|
||||
fetch('/api/bridge/sessions'),
|
||||
fetch('/api/bridge/cascades'),
|
||||
]);
|
||||
if (!sessRes.ok) return;
|
||||
const sessData = await sessRes.json();
|
||||
|
||||
const sessData = sessRes.ok ? await sessRes.json() : { sessions: [] };
|
||||
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 || '';
|
||||
// 세션 → 맵으로 변환 (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() // "Variet/gravity_web" → "gravity_web"
|
||||
? repoName.split('/').pop()
|
||||
: (wsUri ? decodeURIComponent(wsUri.split('/').pop()) : '');
|
||||
|
||||
// 대화 이름: cascade summary > task name > title (Conversation N은 무시)
|
||||
const rawTitle = s.title || '';
|
||||
// 대화 이름: cascade summary > task name > title
|
||||
const rawTitle = s.title || c.summary || '';
|
||||
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 summary = c.summary || '';
|
||||
const taskName = c.latestTaskBoundaryStep?.step?.taskBoundary?.taskName || '';
|
||||
const displayName = summary || taskName || (isGeneric ? '' : rawTitle) || id.substring(0, 8);
|
||||
|
||||
// 상태
|
||||
const runStatus = cascade.status || '';
|
||||
const runStatus = c.status || '';
|
||||
const isRunning = runStatus.includes('RUNNING');
|
||||
return {
|
||||
id: s.id,
|
||||
id: id,
|
||||
name: displayName,
|
||||
host: 'bridge',
|
||||
cdpPort: 0,
|
||||
status: isRunning ? 'running' : 'connected',
|
||||
title: displayName,
|
||||
stepCount: cascade.stepCount || s.stepCount || 0,
|
||||
lastModified: cascade.lastModifiedTime || s.lastModifiedTime,
|
||||
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) {
|
||||
@@ -148,6 +174,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 채팅 메시지 저장소 (lazy load용)
|
||||
let allMessages = [];
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
async function selectBridgeSession(sessionId) {
|
||||
activeBridgeSession = sessionId;
|
||||
sessionPanel.setActive(sessionId);
|
||||
@@ -155,121 +185,96 @@
|
||||
if (session) {
|
||||
chatPanel.showSession({ name: session.name, status: session.isRunning ? 'running' : 'connected' });
|
||||
}
|
||||
await refreshTrajectory(sessionId);
|
||||
// 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 refreshTrajectory(sessionId, scrollToBottom = true) {
|
||||
async function loadSessionMessages(sessionId, scrollToBottom = true) {
|
||||
try {
|
||||
const [trajRes, cascRes] = await Promise.all([
|
||||
// 1. trajectory (히스토리, 최대 ~341 스텝)
|
||||
// 2. 서버 누적 메시지 (trajectory 이후 cascade diff로 캡쳐된 것들)
|
||||
const [trajRes, accRes] = await Promise.all([
|
||||
fetch(`/api/bridge/trajectory/${sessionId}`),
|
||||
fetch('/api/bridge/cascades'),
|
||||
fetch(`/api/bridge/messages/${sessionId}`),
|
||||
]);
|
||||
|
||||
let messages = [];
|
||||
let isComplete = true; // trajectory가 전체를 포함하는지
|
||||
allMessages = [];
|
||||
let trajLastStepIndex = -1;
|
||||
|
||||
// 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가 작업 중...',
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chatPanel.updateChat(messages);
|
||||
if (scrollToBottom) {
|
||||
const el = document.getElementById('chatMessages');
|
||||
if (el) setTimeout(() => el.scrollTop = el.scrollHeight, 100);
|
||||
// 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] trajectory 로드 실패:', e);
|
||||
console.warn('[Bridge] 메시지 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,14 +289,74 @@
|
||||
return `${Math.floor(hrs / 24)}일 전`;
|
||||
}
|
||||
|
||||
// 실시간 갱신 디바운스
|
||||
let refreshTimer = null;
|
||||
function scheduleRefresh() {
|
||||
if (refreshTimer) return;
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshTimer = null;
|
||||
if (activeBridgeSession) refreshTrajectory(activeBridgeSession, false);
|
||||
}, 2000); // 2초 디바운스
|
||||
/**
|
||||
* 서버 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,11 +388,22 @@
|
||||
case 'CORTEX_STEP_TYPE_USER_INPUT': {
|
||||
// 이전 task 플러시
|
||||
flushTask();
|
||||
// 사용자 턴 구분
|
||||
const text = step.userInput?.items?.[0]?.chunk?.value || '';
|
||||
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 || '(사용자 입력)',
|
||||
text: text,
|
||||
time: step.metadata?.createdAt || '',
|
||||
});
|
||||
break;
|
||||
@@ -400,9 +476,17 @@
|
||||
}
|
||||
|
||||
function simpleMarkdown(text) {
|
||||
return text
|
||||
// 코드 블록을 임시 플레이스홀더로 보존
|
||||
const codeBlocks = [];
|
||||
let processed = text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="md-code"><code>$2</code></pre>')
|
||||
.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>')
|
||||
@@ -410,6 +494,11 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
// ─── 서버 메시지 핸들러 ───────────────────────────────
|
||||
@@ -437,14 +526,21 @@
|
||||
break;
|
||||
|
||||
case 'bridge_event':
|
||||
// Bridge WS 이벤트 처리
|
||||
if (msg.step_changed || msg.type === 'step_changed') {
|
||||
scheduleRefresh();
|
||||
loadBridgeSessions(); // 세션 목록도 갱신 (stepCount 등)
|
||||
// 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();
|
||||
} else if (msg.type === 'state_changed') {
|
||||
scheduleRefresh();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'new_messages':
|
||||
// 서버가 push한 누적 메시지 → 즉시 화면 반영
|
||||
if (msg.sessionId === activeBridgeSession) {
|
||||
applyNewMessages(msg);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -466,19 +562,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -529,7 +613,27 @@
|
||||
});
|
||||
};
|
||||
|
||||
chatPanel.onActionClick = (button) => {
|
||||
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,
|
||||
@@ -637,6 +741,41 @@
|
||||
});
|
||||
|
||||
// ─── 스크린샷 ─────────────────────────────────────────
|
||||
// ─── 헤더 승인/거절 버튼 ──────────────────────────────────
|
||||
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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user