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:
2026-03-08 14:05:59 +09:00
parent e7521433cb
commit 1060476113
16 changed files with 940 additions and 209 deletions

View File

@@ -19,6 +19,10 @@ description: 모든 작업에 자동 적용되는 에이전트 행동 규칙.
7. NEVER truncate error messages — always show the full error output 7. NEVER truncate error messages — always show the full error output
8. NEVER use `curl` in PowerShell — always use `curl.exe` 8. NEVER use `curl` in PowerShell — always use `curl.exe`
9. NEVER run `npm` directly — use `cmd /c npm` to avoid execution policy issues 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 (필수) ## ALWAYS (필수)

View File

@@ -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는 미정의 함수 호출 시 런타임 에러만 발생하고 서버는 죽지 않아 발견이 어려움

View File

@@ -26,15 +26,15 @@ $issues | ForEach-Object { Write-Host "#$($_.number) $($_.title)" }
3. Wiki 페이지 목록: 3. Wiki 페이지 목록:
```powershell ```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 페이지 읽기: 4. Wiki 페이지 읽기:
```powershell ```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 페이지 업데이트: 5. Wiki 페이지 업데이트:
```powershell ```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
``` ```

View File

@@ -12,27 +12,27 @@ description: Vikunja API로 gravity_web 프로젝트 태스크 현황을 조회
1. 전체 목록: 1. 전체 목록:
```powershell ```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만: 2. TODO만:
```powershell ```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만: 3. DONE만:
```powershell ```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 호출 금지**): 4. 태스크 완료 처리 (**⚠️ 반드시 이 방법 사용 — 직접 API 호출 금지**):
```powershell ```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. 새 태스크 생성: 5. 새 태스크 생성:
```powershell ```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] > [!CAUTION]

View File

@@ -83,9 +83,9 @@ git log --oneline -20
| 커밋 유형 | Vikunja 액션 | | 커밋 유형 | Vikunja 액션 |
|-----------|-------------| |-----------|-------------|
| 기존 태스크 해당 작업 **완료** | `python .agents\workflows\helpers\vikunja_helper.py done {ID}` | | 기존 태스크 해당 작업 **완료** | `C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\vikunja_helper.py done {ID}` |
| 신규 작업 완료 (기존 태스크 없음) | `python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --done --labels Backend,Priority:High` | | 신규 작업 완료 (기존 태스크 없음) | `C:\ProgramData\miniforge3\envs\gravity_web\python.exe .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` | | 작업 중 발견된 **미완료 TODO** | `C:\ProgramData\miniforge3\envs\gravity_web\python.exe .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:Mid` |
> [!IMPORTANT] > [!IMPORTANT]
> 모든 커밋이 기존 또는 신규 태스크에 매핑되었는지 확인. > 모든 커밋이 기존 또는 신규 태스크에 매핑되었는지 확인.
@@ -99,7 +99,7 @@ git log --oneline -20
| 프론트엔드 변경 | Architecture | | 프론트엔드 변경 | Architecture |
```powershell ```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
``` ```
--- ---

View File

@@ -13,10 +13,41 @@ description: Gravity Web 프로젝트 연동 서비스 URL, API 키, 프로젝
|------|-----| |------|-----|
| **Node.js** | 시스템 설치 (`node`, `npm`) | | **Node.js** | 시스템 설치 (`node`, `npm`) |
| **Python (helper)** | `C:\ProgramData\miniforge3\envs\gravity_web\python.exe` | | **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`** 사용) | | **Shell** | PowerShell (`curl` = `Invoke-WebRequest` 별칭이므로 반드시 **`curl.exe`** 사용) |
| **서버 실행** | `cd server && cmd /c node index.js` (port 3300) | | **서버 실행** | `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) ## Gitea (Git Repository)
| 항목 | 값 | | 항목 | 값 |
@@ -43,7 +74,7 @@ description: Gravity Web 프로젝트 연동 서비스 URL, API 키, 프로젝
> 태스크 목록은 항상 라이브 조회를 사용합니다. 하드코딩된 매핑은 유지하지 않습니다. > 태스크 목록은 항상 라이브 조회를 사용합니다. 하드코딩된 매핑은 유지하지 않습니다.
```powershell ```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
``` ```
## 기타 서비스 ## 기타 서비스

View File

@@ -48,7 +48,7 @@ git log --oneline -5
### 3. Vikunja TODO 태스크 ### 3. Vikunja TODO 태스크
```powershell ```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. 종합 보고 ### 4. 종합 보고

View File

@@ -0,0 +1,5 @@
# 2026-03-08 Devlog
| # | 시간 | 작업 설명 | 커밋 | 상태 |
|---|------|----------|------|------|
| 1 | 08:42~13:58 | 실시간 동기화 아키텍처 구현 (message-accumulator, cascade polling, 승인 버튼) | `pending` | 🔧 |

View File

@@ -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 리렌더링으로 인한 스크롤 점프 미세 조정

View File

@@ -182,6 +182,37 @@ body {
padding: 0 8px 8px; 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 */
.session-card { .session-card {
display: flex; display: flex;
@@ -392,6 +423,56 @@ body {
.chat-actions { .chat-actions {
display: flex; display: flex;
gap: 6px; 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 */ /* Chat Messages */

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,9 +8,12 @@
<meta name="description" content="Antigravity IDE 원격 제어 대시보드"> <meta name="description" content="Antigravity IDE 원격 제어 대시보드">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link rel="stylesheet" href="css/style.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<!-- Header --> <!-- Header -->
@@ -45,7 +49,8 @@
<h2>세션</h2> <h2>세션</h2>
<button class="btn-icon" id="addSessionBtn" title="세션 추가"> <button class="btn-icon" id="addSessionBtn" title="세션 추가">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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> </svg>
</button> </button>
</div> </div>
@@ -59,7 +64,8 @@
<!-- No session selected state --> <!-- No session selected state -->
<div class="empty-state" id="emptyState"> <div class="empty-state" id="emptyState">
<div class="empty-icon"> <div class="empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.3"> <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" /> <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> </svg>
</div> </div>
@@ -79,6 +85,10 @@
<button class="view-tab active" data-view="chat" id="tabChat">💬 채팅</button> <button class="view-tab active" data-view="chat" id="tabChat">💬 채팅</button>
<button class="view-tab" data-view="mirror" id="tabMirror">🖥️ 미러</button> <button class="view-tab" data-view="mirror" id="tabMirror">🖥️ 미러</button>
</div> </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="screenshotBtn" title="스크린샷">📷</button>
<button class="btn-sm" id="reconnectBtn" title="재연결">🔄</button> <button class="btn-sm" id="reconnectBtn" title="재연결">🔄</button>
</div> </div>
@@ -89,14 +99,11 @@
</div> </div>
</div> </div>
<div class="chat-input-area"> <div class="chat-input-area">
<textarea <textarea id="chatInput" placeholder="Antigravity에 보낼 메시지를 입력하세요..." rows="1"></textarea>
id="chatInput"
placeholder="Antigravity에 보낼 메시지를 입력하세요..."
rows="1"
></textarea>
<button class="btn-send" id="sendBtn" title="전송"> <button class="btn-send" id="sendBtn" title="전송">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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> </svg>
</button> </button>
</div> </div>
@@ -158,4 +165,5 @@
<script src="js/mirror-panel.js"></script> <script src="js/mirror-panel.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -97,47 +97,73 @@
fetch('/api/bridge/sessions'), fetch('/api/bridge/sessions'),
fetch('/api/bridge/cascades'), fetch('/api/bridge/cascades'),
]); ]);
if (!sessRes.ok) return;
const sessData = await sessRes.json(); const sessData = sessRes.ok ? await sessRes.json() : { sessions: [] };
let cascadeMap = {}; let cascadeMap = {};
if (cascRes.ok) { if (cascRes.ok) {
const cascData = await cascRes.json(); const cascData = await cascRes.json();
cascadeMap = cascData.cascades || cascData; cascadeMap = cascData.cascades || cascData;
} }
bridgeSessions = (sessData.sessions || []).map(s => { // 세션 → 맵으로 변환 (id 기준)
const cascade = cascadeMap[s.id] || {}; const sessionIds = new Set();
// 워크스페이스에서 프로젝트명 추출 (computedName 우선) const sessionList = (sessData.sessions || []);
const repoName = cascade.workspaces?.[0]?.repository?.computedName || '';
const wsUri = cascade.workspaces?.[0]?.workspaceFolderAbsoluteUri || ''; 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 const project = repoName
? repoName.split('/').pop() // "Variet/gravity_web" → "gravity_web" ? repoName.split('/').pop()
: (wsUri ? decodeURIComponent(wsUri.split('/').pop()) : ''); : (wsUri ? decodeURIComponent(wsUri.split('/').pop()) : '');
// 대화 이름: cascade summary > task name > title (Conversation N은 무시) // 대화 이름: cascade summary > task name > title
const rawTitle = s.title || ''; const rawTitle = s.title || c.summary || '';
const isGeneric = /^Conversation \d+$/.test(rawTitle); const isGeneric = /^Conversation \d+$/.test(rawTitle);
const summary = cascade.summary || ''; const summary = c.summary || '';
const taskName = cascade.latestTaskBoundaryStep?.step?.taskBoundary?.taskName || ''; const taskName = c.latestTaskBoundaryStep?.step?.taskBoundary?.taskName || '';
const displayName = summary || taskName || (isGeneric ? '' : rawTitle) || s.id.substring(0, 8); const displayName = summary || taskName || (isGeneric ? '' : rawTitle) || id.substring(0, 8);
// 상태 const runStatus = c.status || '';
const runStatus = cascade.status || '';
const isRunning = runStatus.includes('RUNNING'); const isRunning = runStatus.includes('RUNNING');
return { return {
id: s.id, id: id,
name: displayName, name: displayName,
host: 'bridge', host: 'bridge',
cdpPort: 0, cdpPort: 0,
status: isRunning ? 'running' : 'connected', status: isRunning ? 'running' : 'connected',
title: displayName, title: displayName,
stepCount: cascade.stepCount || s.stepCount || 0, stepCount: c.stepCount || s.stepCount || 0,
lastModified: cascade.lastModifiedTime || s.lastModifiedTime, lastModified: c.lastModifiedTime || s.lastModifiedTime,
project: project, project: project,
isRunning: isRunning, isRunning: isRunning,
isBridge: true, 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); sessionPanel.update(bridgeSessions);
// 활성 세션 없으면 가장 최근 대화 자동 선택 // 활성 세션 없으면 가장 최근 대화 자동 선택
if (!sessionPanel.activeSessionId && bridgeSessions.length > 0) { if (!sessionPanel.activeSessionId && bridgeSessions.length > 0) {
@@ -148,6 +174,10 @@
} }
} }
// 채팅 메시지 저장소 (lazy load용)
let allMessages = [];
const PAGE_SIZE = 30;
async function selectBridgeSession(sessionId) { async function selectBridgeSession(sessionId) {
activeBridgeSession = sessionId; activeBridgeSession = sessionId;
sessionPanel.setActive(sessionId); sessionPanel.setActive(sessionId);
@@ -155,121 +185,96 @@
if (session) { if (session) {
chatPanel.showSession({ name: session.name, status: session.isRunning ? 'running' : 'connected' }); 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 { 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/trajectory/${sessionId}`),
fetch('/api/bridge/cascades'), fetch(`/api/bridge/messages/${sessionId}`),
]); ]);
let messages = []; allMessages = [];
let isComplete = true; // trajectory가 전체를 포함하는지 let trajLastStepIndex = -1;
// trajectory 파싱
if (trajRes.ok) { if (trajRes.ok) {
const trajData = await trajRes.json(); const trajData = await trajRes.json();
if (trajData.trajectory?.steps) { if (trajData.trajectory?.steps) {
const total = trajData.numTotalSteps || 0; const steps = trajData.trajectory.steps;
const got = trajData.trajectory.steps.length; allMessages = parseTrajectoryToMessages(steps);
isComplete = (total <= got); // 마지막 stepIndex 기억
for (let i = steps.length - 1; i >= 0; i--) {
if (isComplete) { if (steps[i].metadata?.createdAt) {
// ≤336 스텝: 전체 표시 가능 trajLastStepIndex = i;
messages = parseTrajectoryToMessages(trajData.trajectory.steps); break;
}
} }
// > 336 스텝: trajectory 건너뛰고 cascades 중심으로 표시
} }
} }
// cascades에서 최신 상태 // 3. 서버 누적 메시지 병합 (trajectory 이후 것만)
if (cascRes.ok) { let currentStatus = { isRunning: false, isBlocking: false };
const cascData = await cascRes.json(); if (accRes.ok) {
const cascade = (cascData.cascades || cascData)[sessionId]; const accData = await accRes.json();
if (cascade) { currentStatus = { isRunning: accData.isRunning, isBlocking: accData.isBlocking };
// 긴 대화일 때: cascades 기반 표시
if (!isComplete) { if (accData.messages?.length > 0) {
// 대화 요약 for (const msg of accData.messages) {
if (cascade.summary) { // trajectory에 이미 있는 메시지는 스킵
messages.push({ if (msg.stepIndex !== undefined && msg.stepIndex <= trajLastStepIndex) continue;
type: 'status',
text: `💬 대화 요약: ${cascade.summary}`, 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') {
messages.push({ allMessages.push({
type: 'status', type: 'text', html: simpleMarkdown(msg.content || ''),
text: `📊 총 ${cascade.stepCount || '?'}개 스텝 · 최종 입력: ${formatRelativeTime(cascade.lastModifiedTime)}`,
}); });
} else if (msg.type === 'user_input') {
// 사용자 입력 (텍스트 없음, 시간만)
}
} }
// 현재 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 응답 (항상 표시) // 4. 상태 표시 (blocking / waiting / running)
if (cascade.latestNotifyUserStep?.step?.notifyUser) { if (currentStatus.isBlocking || currentStatus.isWaiting) {
const nu = cascade.latestNotifyUserStep.step.notifyUser; allMessages.push({
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', type: 'actions',
label: '⚠️ 사용자 승인 대기 중', label: '⚠️ 사용자 승인 대기 중',
buttons: [ buttons: [
{ label: '✅ 진행', action: 'api_call', endpoint: '/api/bridge/action', body: { action: 'acceptStep' } }, { label: '✅ 진행', action: 'api_call', endpoint: '/api/bridge/approve', body: { type: 'accept' } },
{ label: '❌ 거절', action: 'api_call', endpoint: '/api/bridge/action', body: { action: 'rejectStep' }, variant: 'danger' }, { label: '❌ 거절', action: 'api_call', endpoint: '/api/bridge/approve', body: { type: 'reject' }, variant: 'danger' },
], ],
}); });
} } else if (currentStatus.isRunning) {
allMessages.push({ type: 'status', text: '🔄 AI가 작업 중...' });
} }
// 실행 중 상태 표시 // 5. 화면 표시
if (cascade.status === 'CASCADE_RUN_STATUS_RUNNING') { const initial = allMessages.slice(Math.max(0, allMessages.length - PAGE_SIZE));
const lastMsg = messages[messages.length - 1]; chatPanel.updateChat(initial, allMessages.length > PAGE_SIZE, scrollToBottom);
if (!lastMsg || lastMsg.type !== 'actions') {
messages.push({
type: 'status',
text: '🔄 AI가 작업 중...',
});
}
}
}
}
chatPanel.updateChat(messages);
if (scrollToBottom) {
const el = document.getElementById('chatMessages');
if (el) setTimeout(() => el.scrollTop = el.scrollHeight, 100);
}
} catch (e) { } catch (e) {
console.warn('[Bridge] trajectory 로드 실패:', e); console.warn('[Bridge] 메시지 로드 실패:', e);
} }
} }
@@ -284,14 +289,74 @@
return `${Math.floor(hrs / 24)}일 전`; return `${Math.floor(hrs / 24)}일 전`;
} }
// 실시간 갱신 디바운스 /**
let refreshTimer = null; * 서버 WS push된 새 메시지를 화면에 즉시 반영
function scheduleRefresh() { let _prevStatusKey = ''; // 이전 상태 추적 (상태 변경 시만 DOM 갱신)
if (refreshTimer) return;
refreshTimer = setTimeout(() => { function applyNewMessages(data) {
refreshTimer = null; if (!activeBridgeSession) return;
if (activeBridgeSession) refreshTrajectory(activeBridgeSession, false); // 버튼 클릭 처리 중이면 DOM 교체 방지
}, 2000); // 2초 디바운스 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': { case 'CORTEX_STEP_TYPE_USER_INPUT': {
// 이전 task 플러시 // 이전 task 플러시
flushTask(); flushTask();
// 사용자 턴 구분 let text = '';
const text = step.userInput?.items?.[0]?.chunk?.value || ''; 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({ msgs.push({
type: 'user', type: 'user',
text: text || '(사용자 입력)', text: text,
time: step.metadata?.createdAt || '', time: step.metadata?.createdAt || '',
}); });
break; break;
@@ -400,9 +476,17 @@
} }
function simpleMarkdown(text) { function simpleMarkdown(text) {
return text // 코드 블록을 임시 플레이스홀더로 보존
const codeBlocks = [];
let processed = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.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(/\n/g, '<br>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>') .replace(/`([^`]+)`/g, '<code>$1</code>')
@@ -410,6 +494,11 @@
.replace(/^### (.*)/gm, '<h4>$1</h4>') .replace(/^### (.*)/gm, '<h4>$1</h4>')
.replace(/^\- (.*)/gm, '<li>$1</li>') .replace(/^\- (.*)/gm, '<li>$1</li>')
.replace(/^\d+\. (.*)/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; break;
case 'bridge_event': case 'bridge_event':
// Bridge WS 이벤트 처리 // Bridge WS 이벤트 → 세션 목록만 갱신 (메시지는 new_messages로 처리)
if (msg.step_changed || msg.type === 'step_changed') { if (msg.type === 'step_changed') {
scheduleRefresh(); if (!this._lastSessionLoad || Date.now() - this._lastSessionLoad > 10000) {
loadBridgeSessions(); // 세션 목록도 갱신 (stepCount 등) this._lastSessionLoad = Date.now();
loadBridgeSessions();
}
} else if (msg.type === 'session_changed' || msg.type === 'new_conversation') { } else if (msg.type === 'session_changed' || msg.type === 'new_conversation') {
loadBridgeSessions(); loadBridgeSessions();
} else if (msg.type === 'state_changed') { }
scheduleRefresh(); break;
case 'new_messages':
// 서버가 push한 누적 메시지 → 즉시 화면 반영
if (msg.sessionId === activeBridgeSession) {
applyNewMessages(msg);
} }
break; 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) => { 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({ sendWs({
type: 'click_action', type: 'click_action',
sessionId: sessionPanel.activeSessionId, 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', () => { screenshotBtn.addEventListener('click', () => {
sendWs({ type: 'get_screenshot', sessionId: sessionPanel.activeSessionId }); sendWs({ type: 'get_screenshot', sessionId: sessionPanel.activeSessionId });
}); });

View File

@@ -60,10 +60,7 @@ class ChatPanel {
this.containerEl.style.display = 'none'; this.containerEl.style.display = 'none';
} }
/** updateChat(messages, hasMore = false, scrollToBottom = true) {
* 구조화된 메시지 배열로 채팅 렌더링
*/
updateChat(messages) {
if (!messages || !Array.isArray(messages) || messages.length === 0) { if (!messages || !Array.isArray(messages) || messages.length === 0) {
this.messagesEl.innerHTML = ` this.messagesEl.innerHTML = `
<div class="chat-welcome"> <div class="chat-welcome">
@@ -73,14 +70,27 @@ class ChatPanel {
return; return;
} }
// 변경 감지 — 같은 내용이면 리렌더 안 함 // 변경 감지 — 실제 내용 기반 hash
const hash = JSON.stringify(messages).length + ':' + messages.length; 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; if (hash === this._lastHash) return;
this._lastHash = hash; this._lastHash = hash;
this._shownCount = messages.length;
const prevScrollTop = this.messagesEl.scrollTop;
const prevScrollHeight = this.messagesEl.scrollHeight;
const wasAtBottom = this._isScrolledToBottom(); const wasAtBottom = this._isScrolledToBottom();
const frag = document.createDocumentFragment(); 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) { for (const msg of messages) {
const el = this._renderMessage(msg); const el = this._renderMessage(msg);
if (el) frag.appendChild(el); if (el) frag.appendChild(el);
@@ -89,7 +99,51 @@ class ChatPanel {
this.messagesEl.innerHTML = ''; this.messagesEl.innerHTML = '';
this.messagesEl.appendChild(frag); 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 호출 (승인/거절 등) // api_call: 직접 API 호출 (승인/거절 등)
if (btn.action === 'api_call' && btn.endpoint) { if (btn.action === 'api_call' && btn.endpoint) {
el.addEventListener('click', async () => { el.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
el.disabled = true; el.disabled = true;
el.textContent = '처리 중...'; el.textContent = '처리 중...';
this.actionInProgress = true; // polling에 의한 DOM 교체 방지
try { try {
const res = await fetch(btn.endpoint, { const res = await fetch(btn.endpoint, {
method: 'POST', method: 'POST',
@@ -339,6 +396,8 @@ class ChatPanel {
} }
} catch (e) { } catch (e) {
el.textContent = `오류: ${e.message}`; el.textContent = `오류: ${e.message}`;
} finally {
this.actionInProgress = false;
} }
}); });
} else if (btn.action === 'switch_mirror') { } else if (btn.action === 'switch_mirror') {
@@ -406,7 +465,7 @@ class ChatPanel {
_renderUser(msg) { _renderUser(msg) {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'msg-user'; wrapper.className = 'msg-user';
wrapper.textContent = msg.content; wrapper.textContent = msg.text || msg.content || '';
return wrapper; return wrapper;
} }
@@ -416,7 +475,7 @@ class ChatPanel {
_renderStatus(msg) { _renderStatus(msg) {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'msg-status'; wrapper.className = 'msg-status';
wrapper.textContent = msg.content; wrapper.textContent = msg.text || msg.content || '';
return wrapper; return wrapper;
} }
} }

View File

@@ -29,7 +29,7 @@ class SessionPanel {
} }
/** /**
* 세션 목록 렌더링 * 세션 목록 렌더링 (프로젝트별 그룹핑)
*/ */
render() { render() {
if (!this.listEl) return; if (!this.listEl) return;
@@ -43,28 +43,57 @@ class SessionPanel {
return; return;
} }
this.listEl.innerHTML = this.sessions.map(s => { // 프로젝트별 그룹핑
const groups = {};
for (const s of this.sessions) {
const proj = s.project || '기타';
if (!groups[proj]) groups[proj] = [];
groups[proj].push(s);
}
// 활성(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 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 runningDot = s.isRunning ? '<span class="running-dot">●</span>' : '';
const detail = s.isBridge const detail = s.isBridge
? `${s.stepCount || 0} steps${timeStr ? ' · ' + timeStr : ''}` ? `${s.stepCount || 0} steps${timeStr ? ' · ' + timeStr : ''}`
: `${s.host}:${s.cdpPort} · ${this._statusText(s.status)}`; : `${s.host}:${s.cdpPort} · ${this._statusText(s.status)}`;
return ` html += `
<div class="session-card ${s.id === this.activeSessionId ? 'active' : ''} ${s.isRunning ? 'is-running' : ''}" <div class="session-card ${s.id === this.activeSessionId ? 'active' : ''} ${s.isRunning ? 'is-running' : ''}"
data-session-id="${s.id}"> data-session-id="${s.id}">
<div class="session-indicator ${s.status}"></div> <div class="session-indicator ${s.status}"></div>
<div class="session-info"> <div class="session-info">
<div class="session-name">${runningDot}${this._escapeHtml(s.name)}</div> <div class="session-name">${runningDot}${this._escapeHtml(s.name)}</div>
<div class="session-detail">${projectBadge}${detail}</div> <div class="session-detail">${detail}</div>
</div> </div>
<button class="session-remove" data-remove-id="${s.id}" title="세션 제거">✕</button> <button class="session-remove" data-remove-id="${s.id}" title="세션 제거">✕</button>
</div> </div>
`; `;
}).join(''); }
}
this.listEl.innerHTML = html;
// 이벤트 바인딩 // 이벤트 바인딩
this.listEl.querySelectorAll('.session-card').forEach(card => { this.listEl.querySelectorAll('.session-card').forEach(card => {

View File

@@ -12,12 +12,20 @@ const { WebSocketServer, WebSocket } = require('ws');
const SessionManager = require('./session-manager'); const SessionManager = require('./session-manager');
const { AutoDiscovery } = require('./auto-discover'); const { AutoDiscovery } = require('./auto-discover');
const BridgeClient = require('./bridge-client'); const BridgeClient = require('./bridge-client');
const MessageAccumulator = require('./message-accumulator');
const PORT = process.env.PORT || 3300; const PORT = process.env.PORT || 3300;
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
app.use(express.json()); 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'))); app.use(express.static(path.join(__dirname, '..', 'public')));
const sessionManager = new SessionManager(); 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 프록시 ────────────────────────────────── // ─── Bridge API 프록시 ──────────────────────────────────
const bridge = new BridgeClient(); 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) => { app.post('/api/bridge/send', async (req, res) => {
try { try {
const { message, sessionId } = req.body; 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) => { app.post('/api/bridge/accept', async (req, res) => {
try { try {
const result = await bridge.acceptStep(); const result = await bridge.sendAction('acceptStep');
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(502).json({ error: e.message }); 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) => { app.post('/api/bridge/reject', async (req, res) => {
try { try {
const result = await bridge.rejectStep(); const result = await bridge.sendAction('rejectStep');
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(502).json({ error: e.message }); 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) => { app.post('/api/bridge/accept-terminal', async (req, res) => {
try { try {
const result = await bridge.acceptTerminal(); const result = await bridge.sendAction('acceptTerminal');
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(502).json({ error: e.message }); 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 }); 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 (레거시) ────────────────────────────── // ─── CDP REST API (레거시) ──────────────────────────────

View File

@@ -0,0 +1,184 @@
/**
* MessageAccumulator — 세션별 메시지 누적 관리
*
* Antigravity API 한계(trajectory 최대 341 스텝)를 우회하기 위해,
* bridge WS 이벤트 발생 시 cascades 스냅샷을 diff하여 변경된
* task/notify/blocking 메시지를 서버 측에서 누적 저장.
*/
class MessageAccumulator {
constructor() {
/** @type {Map<string, {messages: object[], lastSnapshot: object}>} */
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;