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:
@@ -182,6 +182,37 @@ body {
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
/* 프로젝트 그룹 헤더 */
|
||||
.project-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.project-group-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 이전 메시지 로더 */
|
||||
.load-more-indicator {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
padding: 12px 0;
|
||||
opacity: 0.6;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Session Card */
|
||||
.session-card {
|
||||
display: flex;
|
||||
@@ -392,6 +423,56 @@ body {
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.approve-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-approve,
|
||||
.btn-reject {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.btn-approve:hover {
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.btn-approve:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-reject:hover {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-reject:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Chat Messages */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -7,9 +8,12 @@
|
||||
<meta name="description" content="Antigravity IDE 원격 제어 대시보드">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Header -->
|
||||
@@ -17,12 +21,12 @@
|
||||
<div class="header-left">
|
||||
<div class="logo">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="url(#grad)" stroke-width="2"/>
|
||||
<circle cx="12" cy="12" r="4" fill="url(#grad)"/>
|
||||
<circle cx="12" cy="12" r="10" stroke="url(#grad)" stroke-width="2" />
|
||||
<circle cx="12" cy="12" r="4" fill="url(#grad)" />
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0" y1="0" x2="24" y2="24">
|
||||
<stop offset="0%" stop-color="#818cf8"/>
|
||||
<stop offset="100%" stop-color="#6366f1"/>
|
||||
<stop offset="0%" stop-color="#818cf8" />
|
||||
<stop offset="100%" stop-color="#6366f1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
@@ -45,7 +49,8 @@
|
||||
<h2>세션</h2>
|
||||
<button class="btn-icon" id="addSessionBtn" title="세션 추가">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -59,8 +64,9 @@
|
||||
<!-- No session selected state -->
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="empty-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.3">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"
|
||||
opacity="0.3">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>세션을 선택하세요</h3>
|
||||
@@ -79,6 +85,10 @@
|
||||
<button class="view-tab active" data-view="chat" id="tabChat">💬 채팅</button>
|
||||
<button class="view-tab" data-view="mirror" id="tabMirror">🖥️ 미러</button>
|
||||
</div>
|
||||
<div class="approve-controls" id="approveControls">
|
||||
<button class="btn-approve" id="btnApprove" title="현재 요청 승인">✅ 승인</button>
|
||||
<button class="btn-reject" id="btnReject" title="현재 요청 거절">❌ 거절</button>
|
||||
</div>
|
||||
<button class="btn-sm" id="screenshotBtn" title="스크린샷">📷</button>
|
||||
<button class="btn-sm" id="reconnectBtn" title="재연결">🔄</button>
|
||||
</div>
|
||||
@@ -89,14 +99,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-area">
|
||||
<textarea
|
||||
id="chatInput"
|
||||
placeholder="Antigravity에 보낼 메시지를 입력하세요..."
|
||||
rows="1"
|
||||
></textarea>
|
||||
<textarea id="chatInput" placeholder="Antigravity에 보낼 메시지를 입력하세요..." rows="1"></textarea>
|
||||
<button class="btn-send" id="sendBtn" title="전송">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
<line x1="22" y1="2" x2="11" y2="13" />
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -115,7 +122,7 @@
|
||||
<span>스크린샷 미리보기</span>
|
||||
<button class="btn-icon" id="closeScreenshot">✕</button>
|
||||
</div>
|
||||
<img id="screenshotImage" alt="Antigravity Screenshot"/>
|
||||
<img id="screenshotImage" alt="Antigravity Screenshot" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -158,4 +165,5 @@
|
||||
<script src="js/mirror-panel.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -60,10 +60,7 @@ class ChatPanel {
|
||||
this.containerEl.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조화된 메시지 배열로 채팅 렌더링
|
||||
*/
|
||||
updateChat(messages) {
|
||||
updateChat(messages, hasMore = false, scrollToBottom = true) {
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
this.messagesEl.innerHTML = `
|
||||
<div class="chat-welcome">
|
||||
@@ -73,14 +70,27 @@ class ChatPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
// 변경 감지 — 같은 내용이면 리렌더 안 함
|
||||
const hash = JSON.stringify(messages).length + ':' + messages.length;
|
||||
// 변경 감지 — 실제 내용 기반 hash
|
||||
const contentKey = messages.map(m => (m.type || '') + (m.text || '') + (m.title || '') + (m.html || '').substring(0, 30)).join('|');
|
||||
const hash = contentKey.length + ':' + messages.length + ':' + contentKey.substring(0, 200);
|
||||
if (hash === this._lastHash) return;
|
||||
this._lastHash = hash;
|
||||
this._shownCount = messages.length;
|
||||
|
||||
const prevScrollTop = this.messagesEl.scrollTop;
|
||||
const prevScrollHeight = this.messagesEl.scrollHeight;
|
||||
const wasAtBottom = this._isScrolledToBottom();
|
||||
const frag = document.createDocumentFragment();
|
||||
|
||||
// 상단 "더 보기" 영역
|
||||
if (hasMore) {
|
||||
const loader = document.createElement('div');
|
||||
loader.className = 'load-more-indicator';
|
||||
loader.id = 'loadMoreIndicator';
|
||||
loader.textContent = '⬆ 스크롤하여 이전 메시지 로드';
|
||||
frag.appendChild(loader);
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const el = this._renderMessage(msg);
|
||||
if (el) frag.appendChild(el);
|
||||
@@ -89,7 +99,51 @@ class ChatPanel {
|
||||
this.messagesEl.innerHTML = '';
|
||||
this.messagesEl.appendChild(frag);
|
||||
|
||||
if (wasAtBottom) this._scrollToBottom();
|
||||
// 스크롤 이벤트: 최상단 → 이전 메시지 로드
|
||||
this.messagesEl.onscroll = () => {
|
||||
if (this.messagesEl.scrollTop < 5 && this.onLoadMore) {
|
||||
this.onLoadMore();
|
||||
}
|
||||
};
|
||||
|
||||
// 스크롤 위치 결정
|
||||
if (scrollToBottom || wasAtBottom) {
|
||||
this._scrollToBottom();
|
||||
} else {
|
||||
// 기존 스크롤 위치 유지
|
||||
this.messagesEl.scrollTop = prevScrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 메시지를 상단에 추가 (스크롤 위치 보존)
|
||||
*/
|
||||
prependMessages(msgs) {
|
||||
if (!msgs || msgs.length === 0) return;
|
||||
const prevHeight = this.messagesEl.scrollHeight;
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const msg of msgs) {
|
||||
const el = this._renderMessage(msg);
|
||||
if (el) frag.appendChild(el);
|
||||
}
|
||||
// 로딩 인디케이터 다음에 삽입
|
||||
const indicator = document.getElementById('loadMoreIndicator');
|
||||
if (indicator) {
|
||||
indicator.after(frag);
|
||||
} else {
|
||||
this.messagesEl.prepend(frag);
|
||||
}
|
||||
this._shownCount = (this._shownCount || 0) + msgs.length;
|
||||
// 스크롤 위치 보존
|
||||
this.messagesEl.scrollTop = this.messagesEl.scrollHeight - prevHeight;
|
||||
// 더 이상 로드할 게 없으면 인디케이터 숨기기
|
||||
if (indicator && this.messagesEl.children.length - 1 >= this._shownCount) {
|
||||
indicator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
getShownCount() {
|
||||
return this._shownCount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,9 +374,12 @@ class ChatPanel {
|
||||
|
||||
// api_call: 직접 API 호출 (승인/거절 등)
|
||||
if (btn.action === 'api_call' && btn.endpoint) {
|
||||
el.addEventListener('click', async () => {
|
||||
el.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
el.disabled = true;
|
||||
el.textContent = '처리 중...';
|
||||
this.actionInProgress = true; // polling에 의한 DOM 교체 방지
|
||||
try {
|
||||
const res = await fetch(btn.endpoint, {
|
||||
method: 'POST',
|
||||
@@ -339,6 +396,8 @@ class ChatPanel {
|
||||
}
|
||||
} catch (e) {
|
||||
el.textContent = `오류: ${e.message}`;
|
||||
} finally {
|
||||
this.actionInProgress = false;
|
||||
}
|
||||
});
|
||||
} else if (btn.action === 'switch_mirror') {
|
||||
@@ -406,7 +465,7 @@ class ChatPanel {
|
||||
_renderUser(msg) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'msg-user';
|
||||
wrapper.textContent = msg.content;
|
||||
wrapper.textContent = msg.text || msg.content || '';
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -416,7 +475,7 @@ class ChatPanel {
|
||||
_renderStatus(msg) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'msg-status';
|
||||
wrapper.textContent = msg.content;
|
||||
wrapper.textContent = msg.text || msg.content || '';
|
||||
return wrapper;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class SessionPanel {
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 목록 렌더링
|
||||
* 세션 목록 렌더링 (프로젝트별 그룹핑)
|
||||
*/
|
||||
render() {
|
||||
if (!this.listEl) return;
|
||||
@@ -43,28 +43,57 @@ class SessionPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
this.listEl.innerHTML = this.sessions.map(s => {
|
||||
const timeStr = s.lastModified ? this._relativeTime(s.lastModified) : '';
|
||||
const projectBadge = s.project
|
||||
? `<span class="session-project" style="background:${this._projectColor(s.project)}">${this._escapeHtml(s.project)}</span>`
|
||||
: '';
|
||||
const runningDot = s.isRunning ? '<span class="running-dot">●</span>' : '';
|
||||
const detail = s.isBridge
|
||||
? `${s.stepCount || 0} steps${timeStr ? ' · ' + timeStr : ''}`
|
||||
: `${s.host}:${s.cdpPort} · ${this._statusText(s.status)}`;
|
||||
// 프로젝트별 그룹핑
|
||||
const groups = {};
|
||||
for (const s of this.sessions) {
|
||||
const proj = s.project || '기타';
|
||||
if (!groups[proj]) groups[proj] = [];
|
||||
groups[proj].push(s);
|
||||
}
|
||||
|
||||
return `
|
||||
// 활성(running) 있는 프로젝트 → 상단, 나머지 → 시간순
|
||||
const sortedProjects = Object.keys(groups).sort((a, b) => {
|
||||
const aRunning = groups[a].some(s => s.isRunning);
|
||||
const bRunning = groups[b].some(s => s.isRunning);
|
||||
if (aRunning && !bRunning) return -1;
|
||||
if (!aRunning && bRunning) return 1;
|
||||
// 최근 수정 순
|
||||
const aTime = Math.max(...groups[a].map(s => s.lastModified ? new Date(s.lastModified).getTime() : 0));
|
||||
const bTime = Math.max(...groups[b].map(s => s.lastModified ? new Date(s.lastModified).getTime() : 0));
|
||||
return bTime - aTime;
|
||||
});
|
||||
|
||||
let html = '';
|
||||
for (const proj of sortedProjects) {
|
||||
const sessions = groups[proj];
|
||||
const color = this._projectColor(proj);
|
||||
html += `<div class="project-group-header">
|
||||
<span class="project-group-dot" style="background:${color}"></span>
|
||||
${this._escapeHtml(proj)} <span style="opacity:0.5;font-weight:400">(${sessions.length})</span>
|
||||
</div>`;
|
||||
|
||||
for (const s of sessions) {
|
||||
const timeStr = s.lastModified ? this._relativeTime(s.lastModified) : '';
|
||||
const runningDot = s.isRunning ? '<span class="running-dot">●</span>' : '';
|
||||
const detail = s.isBridge
|
||||
? `${s.stepCount || 0} steps${timeStr ? ' · ' + timeStr : ''}`
|
||||
: `${s.host}:${s.cdpPort} · ${this._statusText(s.status)}`;
|
||||
|
||||
html += `
|
||||
<div class="session-card ${s.id === this.activeSessionId ? 'active' : ''} ${s.isRunning ? 'is-running' : ''}"
|
||||
data-session-id="${s.id}">
|
||||
<div class="session-indicator ${s.status}"></div>
|
||||
<div class="session-info">
|
||||
<div class="session-name">${runningDot}${this._escapeHtml(s.name)}</div>
|
||||
<div class="session-detail">${projectBadge}${detail}</div>
|
||||
<div class="session-detail">${detail}</div>
|
||||
</div>
|
||||
<button class="session-remove" data-remove-id="${s.id}" title="세션 제거">✕</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
this.listEl.innerHTML = html;
|
||||
|
||||
// 이벤트 바인딩
|
||||
this.listEl.querySelectorAll('.session-card').forEach(card => {
|
||||
|
||||
Reference in New Issue
Block a user