From 10604761134a54ddde419bb78ebc7c518607b961 Mon Sep 17 00:00:00 2001 From: Variet Date: Sun, 8 Mar 2026 14:05:59 +0900 Subject: [PATCH] feat(server,frontend): real-time sync architecture with message accumulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .agents/AGENT.md | 4 + .agents/references/known-issues.md | 44 ++- .agents/workflows/check-gitea.md | 6 +- .agents/workflows/check-vikunja.md | 10 +- .agents/workflows/end.md | 8 +- .agents/workflows/services.md | 35 ++- .agents/workflows/start.md | 2 +- docs/devlog/2026-03-08.md | 5 + docs/devlog/entries/20260308-001.md | 30 ++ public/css/style.css | 81 ++++++ public/index.html | 40 ++- public/js/app.js | 437 ++++++++++++++++++---------- public/js/chat-panel.js | 79 ++++- public/js/session-panel.js | 55 +++- server/index.js | 129 +++++++- server/message-accumulator.js | 184 ++++++++++++ 16 files changed, 940 insertions(+), 209 deletions(-) create mode 100644 docs/devlog/2026-03-08.md create mode 100644 docs/devlog/entries/20260308-001.md create mode 100644 server/message-accumulator.js diff --git a/.agents/AGENT.md b/.agents/AGENT.md index b36d0c9..d49ce4e 100644 --- a/.agents/AGENT.md +++ b/.agents/AGENT.md @@ -19,6 +19,10 @@ description: 모든 작업에 자동 적용되는 에이전트 행동 규칙. 7. NEVER truncate error messages — always show the full error output 8. NEVER use `curl` in PowerShell — always use `curl.exe` 9. NEVER run `npm` directly — use `cmd /c npm` to avoid execution policy issues +10. NEVER run `python` directly — use `C:\ProgramData\miniforge3\envs\gravity_web\python.exe` +11. NEVER use `python -c "..."` inline one-liners in PowerShell — write a `.py` file instead +12. NEVER start server without killing existing port process first (EADDRINUSE) +13. NEVER use `send_command_input Terminate` to stop servers — use `Stop-Process` or `taskkill /F` via `run_command` instead (Terminate hangs the agent) ## ALWAYS (필수) diff --git a/.agents/references/known-issues.md b/.agents/references/known-issues.md index 4ffa546..71db3fe 100644 --- a/.agents/references/known-issues.md +++ b/.agents/references/known-issues.md @@ -46,4 +46,46 @@ ## 프로젝트별 이슈 -(세션 진행 중 발견되는 이슈를 여기에 추가) +### [2026-03-08] Python 실행 — Windows Store 리다이렉트 +- **증상**: `python` 명령이 Microsoft Store 페이지를 열거나 빈 출력 반환 +- **원인**: Windows 기본 `python`이 App Execution Alias(Microsoft Store 리다이렉트)로 설정됨 +- **해결**: **`C:\ProgramData\miniforge3\envs\gravity_web\python.exe`** 전체 경로 사용 +- **주의**: 워크플로우 내 모든 `python` 호출을 전체 경로로 교체할 것 + +### [2026-03-08] PowerShell inline Python — 따옴표 깨짐 +- **증상**: `python -c "import json; ..."` 또는 파이프 `| python -c "..."` 에서 SyntaxError +- **원인**: PowerShell이 쌍따옴표/홑따옴표/중괄호를 자체 해석하여 Python에 전달 시 깨짐 +- **해결**: `.py` 스크립트 파일을 `/tmp/`에 생성 후 전체 경로 Python으로 실행 +- **주의**: **절대** `python -c` 인라인 실행 금지. `curl.exe ... | python -c` 조합은 100% 실패 + +### [2026-03-08] 서버 포트 — EADDRINUSE +- **증상**: `node index.js` 시작 시 `Error: listen EADDRINUSE: address already in use :::3300` +- **원인**: 이전 서버 프로세스가 종료되지 않은 상태에서 재시작 +- **해결**: 서버 시작 전 기존 프로세스를 포트 기반으로 종료 + ```powershell + $p = Get-NetTCPConnection -LocalPort 3300 -ErrorAction SilentlyContinue + if ($p) { Stop-Process -Id $p.OwningProcess -Force; Start-Sleep 1 } + ``` +- **주의**: `Stop-Process -Name node` 는 다른 Node 프로세스까지 죽일 수 있으므로 포트 기반 종료 권장 + +--- + +## Antigravity SDK/API 제한사항 + +### [2026-03-08] Trajectory API — 341개 제한, 페이지네이션 불가 +- **증상**: `GetCascadeTrajectory` RPC가 항상 처음 341개 스텝만 반환 +- **원인**: API 서버 측 하드 제한. `startStepIndex`, `endStepIndex`, `stepIndexRange` 파라미터 모두 무시됨 +- **해결**: 서버 사이드 `message-accumulator.js`로 cascades diff를 누적하여 step 341+ 이후 메시지 보존 +- **주의**: trajectory만으로는 전체 대화 히스토리를 표시할 수 없음 + +### [2026-03-08] Cascade API — 명령 승인 대기 상태 미제공 +- **증상**: `run_command(SafeToAutoRun=false)` 대기 중에도 cascade status는 `CASCADE_RUN_STATUS_RUNNING` 유지 +- **원인**: Cascade API에 command approval 관련 별도 필드 없음 +- **해결**: stepCount 변화 정체(6초)로 추정 + 헤더에 영구적 승인/거절 버튼 추가 +- **주의**: 자동 감지는 정확하지 않음. 사용자가 직접 버튼 클릭이 더 신뢰성 있음 + +### [2026-03-08] broadcastToAll 미정의 버그 +- **증상**: bridge WS 이벤트가 브라우저에 전혀 전달되지 않음 (실시간 업데이트 불가) +- **원인**: `index.js`에서 `broadcastToAll()` 함수를 호출하지만 정의가 누락되어 있었음 +- **해결**: `broadcastToAll` 함수 정의 추가 (wsClients 순회하여 JSON 전송) +- **주의**: JS는 미정의 함수 호출 시 런타임 에러만 발생하고 서버는 죽지 않아 발견이 어려움 diff --git a/.agents/workflows/check-gitea.md b/.agents/workflows/check-gitea.md index 2d16c5e..367dd8b 100644 --- a/.agents/workflows/check-gitea.md +++ b/.agents/workflows/check-gitea.md @@ -26,15 +26,15 @@ $issues | ForEach-Object { Write-Host "#$($_.number) $($_.title)" } 3. Wiki 페이지 목록: ```powershell -python .agents\workflows\helpers\wiki_helper.py list +C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\wiki_helper.py list ``` 4. Wiki 페이지 읽기: ```powershell -python .agents\workflows\helpers\wiki_helper.py read "Architecture" +C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\wiki_helper.py read "Architecture" ``` 5. Wiki 페이지 업데이트: ```powershell -python .agents\workflows\helpers\wiki_helper.py update "페이지-제목" /tmp/wiki_content.md +C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\wiki_helper.py update "페이지-제목" /tmp/wiki_content.md ``` diff --git a/.agents/workflows/check-vikunja.md b/.agents/workflows/check-vikunja.md index 244d3a8..61fcb0c 100644 --- a/.agents/workflows/check-vikunja.md +++ b/.agents/workflows/check-vikunja.md @@ -12,27 +12,27 @@ description: Vikunja API로 gravity_web 프로젝트 태스크 현황을 조회 1. 전체 목록: ```powershell -python .agents\workflows\helpers\vikunja_helper.py list +C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\vikunja_helper.py list ``` 2. TODO만: ```powershell -python .agents\workflows\helpers\vikunja_helper.py list todo +C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\vikunja_helper.py list todo ``` 3. DONE만: ```powershell -python .agents\workflows\helpers\vikunja_helper.py list done +C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\vikunja_helper.py list done ``` 4. 태스크 완료 처리 (**⚠️ 반드시 이 방법 사용 — 직접 API 호출 금지**): ```powershell -python .agents\workflows\helpers\vikunja_helper.py done {TASK_ID} +C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\vikunja_helper.py done {TASK_ID} ``` 5. 새 태스크 생성: ```powershell -python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:High +C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:High ``` > [!CAUTION] diff --git a/.agents/workflows/end.md b/.agents/workflows/end.md index 5c47945..d9241e9 100644 --- a/.agents/workflows/end.md +++ b/.agents/workflows/end.md @@ -83,9 +83,9 @@ git log --oneline -20 | 커밋 유형 | Vikunja 액션 | |-----------|-------------| -| 기존 태스크 해당 작업 **완료** | `python .agents\workflows\helpers\vikunja_helper.py done {ID}` | -| 신규 작업 완료 (기존 태스크 없음) | `python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --done --labels Backend,Priority:High` | -| 작업 중 발견된 **미완료 TODO** | `python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:Mid` | +| 기존 태스크 해당 작업 **완료** | `C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\vikunja_helper.py done {ID}` | +| 신규 작업 완료 (기존 태스크 없음) | `C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --done --labels Backend,Priority:High` | +| 작업 중 발견된 **미완료 TODO** | `C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:Mid` | > [!IMPORTANT] > 모든 커밋이 기존 또는 신규 태스크에 매핑되었는지 확인. @@ -99,7 +99,7 @@ git log --oneline -20 | 프론트엔드 변경 | Architecture | ```powershell -python .agents\workflows\helpers\wiki_helper.py update "Architecture" /tmp/wiki_content.md +C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\wiki_helper.py update "Architecture" /tmp/wiki_content.md ``` --- diff --git a/.agents/workflows/services.md b/.agents/workflows/services.md index 7b458f7..df34b27 100644 --- a/.agents/workflows/services.md +++ b/.agents/workflows/services.md @@ -13,10 +13,41 @@ description: Gravity Web 프로젝트 연동 서비스 URL, API 키, 프로젝 |------|-----| | **Node.js** | 시스템 설치 (`node`, `npm`) | | **Python (helper)** | `C:\ProgramData\miniforge3\envs\gravity_web\python.exe` | -| **프로젝트 루트** | `c:\Users\Certes\Desktop\gravity_web` | +| **프로젝트 루트** | `c:\Users\Cafe-Variet-E495\Desktop\gravity_web\gravity_web` | | **Shell** | PowerShell (`curl` = `Invoke-WebRequest` 별칭이므로 반드시 **`curl.exe`** 사용) | | **서버 실행** | `cd server && cmd /c node index.js` (port 3300) | +### Python 실행 규칙 + +```powershell +# ✅ 항상 전체 경로 사용 (python은 Windows Store 리다이렉트됨) +$PYTHON = "C:\ProgramData\miniforge3\envs\gravity_web\python.exe" +& $PYTHON .agents\workflows\helpers\vikunja_helper.py list todo +``` + +```powershell +# ❌ 금지: bare python / inline one-liner +python script.py # PATH 문제 +python -c "import json; ..." # PowerShell 이스케이핑 깨짐 +curl.exe ... | python -c "..." # 파이프 + 인라인 = 100% 실패 +``` + +> [!WARNING] +> PowerShell에서 **inline Python one-liner는 절대 사용 금지**. 따옴표/특수문자가 깨집니다. +> 반드시 `.py` 파일을 `/tmp/` 에 만들어서 실행하세요. + +### 서버 시작/종료 패턴 + +```powershell +# 서버 시작 전 기존 프로세스 정리 (EADDRINUSE 방지) +$existing = Get-NetTCPConnection -LocalPort 3300 -ErrorAction SilentlyContinue +if ($existing) { + Stop-Process -Id (Get-Process -Id $existing.OwningProcess).Id -Force + Start-Sleep -Seconds 1 +} +cmd /c "cd server && node index.js" +``` + ## Gitea (Git Repository) | 항목 | 값 | @@ -43,7 +74,7 @@ description: Gravity Web 프로젝트 연동 서비스 URL, API 키, 프로젝 > 태스크 목록은 항상 라이브 조회를 사용합니다. 하드코딩된 매핑은 유지하지 않습니다. ```powershell -python .agents\workflows\helpers\vikunja_helper.py list todo +C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\vikunja_helper.py list todo ``` ## 기타 서비스 diff --git a/.agents/workflows/start.md b/.agents/workflows/start.md index 7e9eebb..e52f911 100644 --- a/.agents/workflows/start.md +++ b/.agents/workflows/start.md @@ -48,7 +48,7 @@ git log --oneline -5 ### 3. Vikunja TODO 태스크 ```powershell -python .agents\workflows\helpers\vikunja_helper.py list todo +C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\vikunja_helper.py list todo ``` ### 4. 종합 보고 diff --git a/docs/devlog/2026-03-08.md b/docs/devlog/2026-03-08.md new file mode 100644 index 0000000..28fd62e --- /dev/null +++ b/docs/devlog/2026-03-08.md @@ -0,0 +1,5 @@ +# 2026-03-08 Devlog + +| # | 시간 | 작업 설명 | 커밋 | 상태 | +|---|------|----------|------|------| +| 1 | 08:42~13:58 | 실시간 동기화 아키텍처 구현 (message-accumulator, cascade polling, 승인 버튼) | `pending` | 🔧 | diff --git a/docs/devlog/entries/20260308-001.md b/docs/devlog/entries/20260308-001.md new file mode 100644 index 0000000..5cdc087 --- /dev/null +++ b/docs/devlog/entries/20260308-001.md @@ -0,0 +1,30 @@ +# 실시간 동기화 아키텍처 구현 + +- **시간**: 2026-03-08 08:42~13:58 +- **Commit**: `pending` +- **Vikunja**: #251 → done + +## 결정 사항 + +### Trajectory API 한계 → Message Accumulator 패턴 +- Trajectory API가 341개로 하드캡, 페이지네이션 파라미터 무시됨 +- Cascades API가 최신 task/notify만 제공 (1개씩) +- **해결**: 서버사이드 `message-accumulator.js`로 cascade 스냅샷을 3초마다 diff하여 새 메시지 누적 저장 +- 초기 로드: trajectory(341) + accumulated messages 합산 + +### 실시간 Push vs Polling +- Bridge WS 이벤트(step_changed)에 의존만으로는 누락 발생 가능 +- **3초 interval polling**을 백업으로 추가하여 안정성 보장 +- WS 이벤트 + polling 이중 구조 + +### 승인 버튼: 자동 감지 vs 수동 +- Cascade API에 command approval 대기 필드 없음 (status는 항상 RUNNING) +- stepCount 정체 감지(6초)로 추정 시도 → 불안정 +- **최종 결정**: 헤더에 영구적 ✅ 승인 / ❌ 거절 버튼 추가 (always accessible) + +## 미완료 + +- [ ] 승인 버튼 자동 감지 안정화 (isWaiting 정확도 개선) +- [ ] inline 승인 버튼(chat 내부)과 헤더 버튼 간 상태 동기화 +- [ ] 대화 중간 메시지 누락 문제 (서버 재시작 시 accumulator 초기화) +- [ ] isWaiting→isRunning 전환 시 DOM 리렌더링으로 인한 스크롤 점프 미세 조정 diff --git a/public/css/style.css b/public/css/style.css index 01fa664..c7a13b9 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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 */ diff --git a/public/index.html b/public/index.html index cc85e11..5d8a135 100644 --- a/public/index.html +++ b/public/index.html @@ -1,5 +1,6 @@ + @@ -7,9 +8,12 @@ - + +
@@ -17,12 +21,12 @@
@@ -59,8 +64,9 @@
- - + +

세션을 선택하세요

@@ -79,6 +85,10 @@
+
+ + +
@@ -89,14 +99,11 @@
- +
@@ -115,7 +122,7 @@ 스크린샷 미리보기 - Antigravity Screenshot + Antigravity Screenshot @@ -158,4 +165,5 @@ - + + \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js index 0243203..2f19f25 100644 --- a/public/js/app.js +++ b/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(/```(\w*)\n([\s\S]*?)```/g, '
$2
') + .replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { + const idx = codeBlocks.length; + codeBlocks.push(`
${code}
`); + return `___CODE_BLOCK_${idx}___`; + }); + // 코드 블록 외부만 줄바꿈 변환 + processed = processed .replace(/\n/g, '
') .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/`([^`]+)`/g, '$1') @@ -410,6 +494,11 @@ .replace(/^### (.*)/gm, '

$1

') .replace(/^\- (.*)/gm, '
  • $1
  • ') .replace(/^\d+\. (.*)/gm, '
  • $1
  • '); + // 코드 블록 복원 + 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 }); }); diff --git a/public/js/chat-panel.js b/public/js/chat-panel.js index b527652..284962d 100644 --- a/public/js/chat-panel.js +++ b/public/js/chat-panel.js @@ -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 = `
    @@ -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; } } diff --git a/public/js/session-panel.js b/public/js/session-panel.js index 53424f2..96f04e2 100644 --- a/public/js/session-panel.js +++ b/public/js/session-panel.js @@ -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 - ? `${this._escapeHtml(s.project)}` - : ''; - const runningDot = s.isRunning ? '' : ''; - 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 += `
    + + ${this._escapeHtml(proj)} (${sessions.length}) +
    `; + + for (const s of sessions) { + const timeStr = s.lastModified ? this._relativeTime(s.lastModified) : ''; + const runningDot = s.isRunning ? '' : ''; + const detail = s.isBridge + ? `${s.stepCount || 0} steps${timeStr ? ' · ' + timeStr : ''}` + : `${s.host}:${s.cdpPort} · ${this._statusText(s.status)}`; + + html += `
    ${runningDot}${this._escapeHtml(s.name)}
    -
    ${projectBadge}${detail}
    +
    ${detail}
    `; - }).join(''); + } + } + + this.listEl.innerHTML = html; // 이벤트 바인딩 this.listEl.querySelectorAll('.session-card').forEach(card => { diff --git a/server/index.js b/server/index.js index def29f2..f585afa 100644 --- a/server/index.js +++ b/server/index.js @@ -12,12 +12,20 @@ const { WebSocketServer, WebSocket } = require('ws'); const SessionManager = require('./session-manager'); const { AutoDiscovery } = require('./auto-discover'); const BridgeClient = require('./bridge-client'); +const MessageAccumulator = require('./message-accumulator'); const PORT = process.env.PORT || 3300; const app = express(); const server = http.createServer(app); app.use(express.json()); +// 개발 모드: 캐시 비활성화 +app.use((req, res, next) => { + res.set('Cache-Control', 'no-store, no-cache, must-revalidate'); + res.set('Pragma', 'no-cache'); + res.set('Expires', '0'); + next(); +}); app.use(express.static(path.join(__dirname, '..', 'public'))); const sessionManager = new SessionManager(); @@ -253,6 +261,18 @@ function broadcastSessions() { } } +/** + * 모든 WS 클라이언트에 메시지 브로드캐스트 (Bridge 이벤트 전달용) + */ +function broadcastToAll(msg) { + const payload = JSON.stringify(msg); + for (const [ws] of wsClients) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(payload); + } + } +} + // ─── Bridge API 프록시 ────────────────────────────────── const bridge = new BridgeClient(); @@ -284,6 +304,22 @@ app.get('/api/bridge/cascades', async (req, res) => { } }); +// 단일 세션 cascades (실시간 갱신용 — 응답 크기 최소화) +app.get('/api/bridge/cascades/:sessionId', async (req, res) => { + try { + const data = await bridge.getCascades(); + const cascades = data.cascades || data; + const session = cascades[req.params.sessionId]; + if (!session) { + res.json({ status: 'not_found' }); + } else { + res.json(session); + } + } catch (e) { + res.status(502).json({ error: 'Bridge 연결 실패: ' + e.message }); + } +}); + app.post('/api/bridge/send', async (req, res) => { try { const { message, sessionId } = req.body; @@ -296,7 +332,7 @@ app.post('/api/bridge/send', async (req, res) => { app.post('/api/bridge/accept', async (req, res) => { try { - const result = await bridge.acceptStep(); + const result = await bridge.sendAction('acceptStep'); res.json(result); } catch (e) { res.status(502).json({ error: e.message }); @@ -305,7 +341,7 @@ app.post('/api/bridge/accept', async (req, res) => { app.post('/api/bridge/reject', async (req, res) => { try { - const result = await bridge.rejectStep(); + const result = await bridge.sendAction('rejectStep'); res.json(result); } catch (e) { res.status(502).json({ error: e.message }); @@ -314,7 +350,7 @@ app.post('/api/bridge/reject', async (req, res) => { app.post('/api/bridge/accept-terminal', async (req, res) => { try { - const result = await bridge.acceptTerminal(); + const result = await bridge.sendAction('acceptTerminal'); res.json(result); } catch (e) { res.status(502).json({ error: e.message }); @@ -339,9 +375,92 @@ app.post('/api/bridge/action', async (req, res) => { res.status(502).json({ error: e.message }); } }); -bridge.connectWs((msg) => { - broadcastToAll({ type: 'bridge_event', ...msg }); + +// 승인/거절 통합 엔드포인트: 모든 유형을 순차 시도 +app.post('/api/bridge/approve', async (req, res) => { + const { type } = req.body; // 'accept' or 'reject' + const actions = type === 'accept' + ? ['acceptStep', 'acceptCommand', 'acceptTerminal'] + : ['rejectStep', 'rejectCommand', 'rejectTerminal']; + + for (const action of actions) { + try { + const result = await bridge.sendAction(action); + console.log(`[Approve] ${action} 성공`); + return res.json({ success: true, action, result }); + } catch (_) { + // 이 타입은 안 맞음 → 다음 시도 + } + } + res.status(502).json({ error: '승인/거절 실패: 대기 중인 요청 없음' }); }); +const accumulator = new MessageAccumulator(); + +// 메시지 누적 API +app.get('/api/bridge/messages/:sessionId', async (req, res) => { + // 요청 시점에 즉시 최신 cascade 반영 + try { + const cascData = await bridge.getCascades(); + const cascades = cascData.cascades || cascData; + const sid = req.params.sessionId; + if (cascades[sid]) { + accumulator.processCascade(sid, cascades[sid]); + } + } catch (_) { } + const messages = accumulator.getMessages(req.params.sessionId); + const status = accumulator.getStatus(req.params.sessionId); + res.json({ messages, ...status }); +}); + +bridge.connectWs(async (msg) => { + console.log(`[Bridge Event] ${msg.type} session=${msg.sessionId || 'N/A'}`); + broadcastToAll({ type: 'bridge_event', ...msg }); + + // step_changed/state_changed → cascade 스냅샷 diff → 새 메시지 push + if (msg.type === 'step_changed' || msg.type === 'state_changed') { + await pollAndPushCascades(msg.sessionId); + } +}); + +// 주기적 cascade polling (WS 이벤트 누락 보완, 3초 간격) +setInterval(async () => { + if (!bridge.connected) return; + await pollAndPushCascades(); +}, 3000); + +async function pollAndPushCascades(specificSessionId) { + try { + const cascData = await bridge.getCascades(); + const cascades = cascData.cascades || cascData; + + for (const [sessionId, cascade] of Object.entries(cascades)) { + if (specificSessionId && sessionId !== specificSessionId) continue; + + const result = accumulator.processCascade(sessionId, cascade); + if (result && result.newMessages) { + const status = accumulator._computeStatus(cascade); + console.log(`[Accumulator] ${sessionId.substring(0, 8)}: +${result.newMessages.length} messages (${status.isRunning ? 'running' : status.isBlocking ? 'blocking' : 'idle'})`); + broadcastToAll({ + type: 'new_messages', + sessionId, + messages: result.newMessages, + ...status, + }); + } else if (result) { + // 메시지 변경 없어도 상태 변경은 push + const status = accumulator._computeStatus(cascade); + broadcastToAll({ + type: 'new_messages', + sessionId, + messages: null, + ...status, + }); + } + } + } catch (e) { + // 연결 안 됨 — 무시 + } +} // ─── CDP REST API (레거시) ────────────────────────────── diff --git a/server/message-accumulator.js b/server/message-accumulator.js new file mode 100644 index 0000000..05bd081 --- /dev/null +++ b/server/message-accumulator.js @@ -0,0 +1,184 @@ +/** + * MessageAccumulator — 세션별 메시지 누적 관리 + * + * Antigravity API 한계(trajectory 최대 341 스텝)를 우회하기 위해, + * bridge WS 이벤트 발생 시 cascades 스냅샷을 diff하여 변경된 + * task/notify/blocking 메시지를 서버 측에서 누적 저장. + */ +class MessageAccumulator { + constructor() { + /** @type {Map} */ + this.sessions = new Map(); + } + + /** + * cascade 스냅샷을 받아 diff → 새 메시지 추출 및 저장 + * @returns {object[]|null} 새로 추가된 메시지 배열 (변경 없으면 null) + */ + processCascade(sessionId, cascade) { + if (!cascade || !sessionId) return null; + + let session = this.sessions.get(sessionId); + if (!session) { + session = { messages: [], lastSnapshot: {} }; + this.sessions.set(sessionId, session); + } + + const prev = session.lastSnapshot; + const newMessages = []; + + // 1. Task boundary 변경 감지 + const tbStep = cascade.latestTaskBoundaryStep?.step; + const tbIndex = cascade.latestTaskBoundaryStep?.stepIndex ?? -1; + const prevTbIndex = prev.taskStepIndex ?? -1; + + if (tbStep?.taskBoundary && tbIndex > prevTbIndex) { + const tb = tbStep.taskBoundary; + newMessages.push({ + type: 'task', + title: tb.taskName || '', + summary: tb.taskSummary || '', + status: tb.taskStatus || '', + mode: tb.mode || '', + tools: [], + time: tbStep.metadata?.createdAt || '', + stepIndex: tbIndex, + }); + } else if (tbStep?.taskBoundary && tbIndex === prevTbIndex) { + // 같은 task인데 summary/status 업데이트 → 기존 메시지 수정 + const existing = session.messages.filter(m => m.type === 'task' && m.stepIndex === tbIndex).pop(); + if (existing) { + const tb = tbStep.taskBoundary; + existing.summary = tb.taskSummary || existing.summary; + existing.status = tb.taskStatus || existing.status; + } + } + + // 2. Notify user 변경 감지 + const nuStep = cascade.latestNotifyUserStep?.step; + const nuIndex = cascade.latestNotifyUserStep?.stepIndex ?? -1; + const prevNuIndex = prev.notifyStepIndex ?? -1; + + if (nuStep?.notifyUser?.notificationContent && nuIndex > prevNuIndex) { + newMessages.push({ + type: 'text', + content: nuStep.notifyUser.notificationContent, + isBlocking: !!nuStep.notifyUser.isBlocking, + time: nuStep.metadata?.createdAt || '', + stepIndex: nuIndex, + }); + } + + // 3. 사용자 입력 감지 (텍스트는 없지만 시간으로 추정) + const lastUserInput = cascade.lastUserInputTime || ''; + const prevUserInput = prev.lastUserInputTime || ''; + const lastUserInputIdx = cascade.lastUserInputStepIndex ?? -1; + const prevUserInputIdx = prev.lastUserInputStepIndex ?? -1; + + if (lastUserInput && lastUserInput !== prevUserInput && lastUserInputIdx > prevUserInputIdx) { + newMessages.push({ + type: 'user_input', + time: lastUserInput, + stepIndex: lastUserInputIdx, + }); + } + + // 4. 현재 상태 계산 + const currentStatus = this._computeStatus(cascade); + + // 스냅샷 저장 + session.lastSnapshot = { + taskStepIndex: tbIndex, + notifyStepIndex: nuIndex, + lastUserInputTime: lastUserInput, + lastUserInputStepIndex: lastUserInputIdx, + status: cascade.status, + stepCount: cascade.stepCount, + }; + + // 새 메시지 정렬 후 추가 + if (newMessages.length > 0) { + newMessages.sort((a, b) => (a.stepIndex || 0) - (b.stepIndex || 0)); + session.messages.push(...newMessages); + } + + return newMessages.length > 0 ? { newMessages, currentStatus } : { newMessages: null, currentStatus }; + } + + /** + * 현재 상태 계산 (blocking, running, waiting, idle) + */ + _computeStatus(cascade) { + const nuStep = cascade.latestNotifyUserStep?.step; + const lastUserInput = cascade.lastUserInputTime || ''; + const nuTime = nuStep?.metadata?.createdAt || ''; + const isRunning = cascade.status === 'CASCADE_RUN_STATUS_RUNNING'; + const stepCount = cascade.stepCount || 0; + + // notify_user blocking: notify가 blocking이고, AI가 idle이고, 사용자 미응답 + let isBlocking = false; + if (nuStep?.notifyUser?.isBlocking && !isRunning) { + if (!lastUserInput || lastUserInput < nuTime) { + isBlocking = true; + } + } + + // stepCount 변화 추적 (승인 대기 감지용) + const sessionId = cascade.trajectoryId || ''; + if (!this._stepHistory) this._stepHistory = new Map(); + const now = Date.now(); + const hist = this._stepHistory.get(sessionId) || { count: 0, lastChangeTime: now }; + + let isWaiting = false; + if (stepCount !== hist.count) { + hist.count = stepCount; + hist.lastChangeTime = now; + } else if (isRunning && (now - hist.lastChangeTime) > 6000) { + // RUNNING인데 6초간 stepCount 안 변함 → 사용자 응답 대기 + isWaiting = true; + } + this._stepHistory.set(sessionId, hist); + + return { + isRunning, + isBlocking, + isWaiting, + stepCount, + }; + } + + /** + * 세션의 전체 누적 메시지 반환 + */ + getMessages(sessionId) { + const session = this.sessions.get(sessionId); + return session ? session.messages : []; + } + + /** + * 세션의 현재 상태 반환 + */ + getStatus(sessionId) { + const session = this.sessions.get(sessionId); + if (!session?.lastSnapshot) return { isRunning: false, isBlocking: false, stepCount: 0 }; + const cascade = session.lastSnapshot; + return { + isRunning: cascade.status === 'CASCADE_RUN_STATUS_RUNNING', + isBlocking: false, // 정확한 판단은 processCascade에서만 + stepCount: cascade.stepCount || 0, + }; + } + + /** + * 초기 trajectory 메시지와 병합하여 전체 메시지 반환 + * trajectory의 마지막 stepIndex 이후의 누적 메시지만 추가 + */ + getMergedMessages(sessionId, trajectoryMessages, trajLastStepIndex) { + const accumulated = this.getMessages(sessionId); + // trajectory 이후의 누적 메시지만 필터 + const afterTrajectory = accumulated.filter(m => (m.stepIndex || 0) > trajLastStepIndex); + return [...trajectoryMessages, ...afterTrajectory]; + } +} + +module.exports = MessageAccumulator;