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:
@@ -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 (필수)
|
||||||
|
|
||||||
|
|||||||
@@ -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는 미정의 함수 호출 시 런타임 에러만 발생하고 서버는 죽지 않아 발견이 어려움
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
## 기타 서비스
|
## 기타 서비스
|
||||||
|
|||||||
@@ -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. 종합 보고
|
||||||
|
|||||||
5
docs/devlog/2026-03-08.md
Normal file
5
docs/devlog/2026-03-08.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-03-08 Devlog
|
||||||
|
|
||||||
|
| # | 시간 | 작업 설명 | 커밋 | 상태 |
|
||||||
|
|---|------|----------|------|------|
|
||||||
|
| 1 | 08:42~13:58 | 실시간 동기화 아키텍처 구현 (message-accumulator, cascade polling, 승인 버튼) | `pending` | 🔧 |
|
||||||
30
docs/devlog/entries/20260308-001.md
Normal file
30
docs/devlog/entries/20260308-001.md
Normal 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 리렌더링으로 인한 스크롤 점프 미세 조정
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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>
|
||||||
415
public/js/app.js
415
public/js/app.js
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="md-code"><code>$2</code></pre>')
|
.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
||||||
|
const idx = codeBlocks.length;
|
||||||
|
codeBlocks.push(`<pre class="md-code"><code>${code}</code></pre>`);
|
||||||
|
return `___CODE_BLOCK_${idx}___`;
|
||||||
|
});
|
||||||
|
// 코드 블록 외부만 줄바꿈 변환
|
||||||
|
processed = processed
|
||||||
.replace(/\n/g, '<br>')
|
.replace(/\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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
129
server/index.js
129
server/index.js
@@ -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 (레거시) ──────────────────────────────
|
||||||
|
|
||||||
|
|||||||
184
server/message-accumulator.js
Normal file
184
server/message-accumulator.js
Normal 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;
|
||||||
Reference in New Issue
Block a user