diff --git a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json index e93e4e0..5d742f8 100644 --- a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json +++ b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1778170647495, + "createdAt": 1778249295071, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json index f300b94..4549a49 100644 --- a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json +++ b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1778170647483, + "createdAt": 1778249295065, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json index 70dc148..6766962 100644 --- a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json +++ b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json @@ -1,5 +1,5 @@ { "result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", - "createdAt": 1778170647479, + "createdAt": 1778249295060, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json index c9c2ead..c18db3c 100644 --- a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json +++ b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json @@ -1,5 +1,5 @@ { - "result": "---\nid: stress_conflict_1778170647465\ndate: 2026-05-07T16:17:27.498Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (9ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (5ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (12ms)\n", - "createdAt": 1778170647499, + "result": "---\nid: stress_conflict_1778249295044\ndate: 2026-05-08T14:08:15.076Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (10ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (6ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (9ms)\n", + "createdAt": 1778249295076, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1778170647465.json b/.astra/tests/stress/.astra/missions/stress_conflict_1778249295044.json similarity index 81% rename from .astra/tests/stress/.astra/missions/stress_conflict_1778170647465.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1778249295044.json index a5f11e4..9aa8516 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1778170647465.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1778249295044.json @@ -1,8 +1,8 @@ { - "missionId": "stress_conflict_1778170647465", + "missionId": "stress_conflict_1778249295044", "status": "completed", - "startTime": "2026-05-07T16:17:27.465Z", - "totalElapsedMs": 36, + "startTime": "2026-05-08T14:08:15.044Z", + "totalElapsedMs": 32, "results": { "planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", "researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", @@ -16,30 +16,30 @@ { "from": "idle", "to": "planner", - "durationMs": 9, + "durationMs": 10, "message": "전략 수립 중...", - "ts": "2026-05-07T16:17:27.474Z" + "ts": "2026-05-08T14:08:15.054Z" }, { "from": "planner", "to": "researcher", - "durationMs": 5, + "durationMs": 6, "message": "핵심 정보 수집 및 분석 중...", - "ts": "2026-05-07T16:17:27.479Z" + "ts": "2026-05-08T14:08:15.060Z" }, { "from": "researcher", "to": "writer", - "durationMs": 12, + "durationMs": 9, "message": "최종 리포트 작성 및 편집 중...", - "ts": "2026-05-07T16:17:27.491Z" + "ts": "2026-05-08T14:08:15.069Z" }, { "from": "writer", "to": "completed", - "durationMs": 10, + "durationMs": 7, "message": "미션 완료", - "ts": "2026-05-07T16:17:27.501Z" + "ts": "2026-05-08T14:08:15.076Z" } ], "resilienceMetrics": { diff --git a/.vscodeignore b/.vscodeignore index 76b3f70..4639bd4 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -2,14 +2,6 @@ .vscode/** src/** node_modules/** -!node_modules/axios/** -!node_modules/follow-redirects/** -!node_modules/form-data/** -!node_modules/proxy-from-env/** -!node_modules/mime-db/** -!node_modules/mime-types/** -!node_modules/combined-stream/** -!node_modules/delayed-stream/** *.ts *.map .gitignore diff --git a/ARCHITECTURE_ANALYSIS.md b/ARCHITECTURE_ANALYSIS.md new file mode 100644 index 0000000..714de4e --- /dev/null +++ b/ARCHITECTURE_ANALYSIS.md @@ -0,0 +1,274 @@ +# ConnectAI Architecture and Code Review + +## 1. 프로젝트 개요 + +`ConnectAI`는 VS Code 확장형 AI 어시스턴트인 `Astra`의 핵심 구현체입니다. 이 프로젝트는 로컬 AI 모델 서버(예: Ollama, LM Studio)를 사용하여: + +- 개발자와 프로젝트의 맥락을 유지 +- 로컬 지식 베이스(`Second Brain`)를 읽고 쓰기 +- 멀티 에이전트 워크플로우로 복잡한 요청을 처리 +- 확장형 브릿지 서버로 외부 도구와 통합 + +## 2. 주요 아키텍처 모듈 + +### 2.1 Extension Entry Point + +- 파일: [src/extension.ts](src/extension.ts) +- 역할: + - VS Code 확장 활성화/비활성화 + - 환경 검사 및 설정 검증 + - 브레인 디렉토리 초기화 + - LM Studio Lifecycle Subsystem 초기화 및 의존성 주입 + - `AgentExecutor`, `SidebarChatProvider`, `BridgeServer` 초기화 + - 핵심 명령(command) 등록 + +### 2.2 Agent Executor + +- 파일: [src/agent.ts](src/agent.ts) +- 역할: + - 채팅 세션 관리 + - 메시지 히스토리 유지 및 복원 + - 트랜잭션 기반 변경 승인/거부 + - 메시지 전송 처리 및 다중 에이전트 워크플로우 분기 + - 메모리/검색/상태 로깅 통합 + - `AgentExecutorOptions.onStreamLifecycle` 콜백으로 외부 시스템(LM Studio idle eject)에 stream start/end 알림 + +### 2.3 브릿지 서버 + +- 파일: [src/bridge.ts](src/bridge.ts) +- 역할: + - 외부 HTTP 클라이언트와 통신 + - `/ping`, `/api/exam`, `/api/evaluate`, `/api/evaluate-history`, `/api/brain-inject` 엔드포인트 제공 + - AI 호출과 Brain 주입 비즈니스 로직 분리 + +### 2.4 서비스 레이어 + +- 파일: [src/core/services.ts](src/core/services.ts) +- 역할: + - AI 모델 호출 및 브레인 파일 시스템 조작을 추상화 + - `IAIService`, `IBrainService` 인터페이스 정의 + - Ollama/LM Studio 폴백 로직 구현 + +### 2.5 구성 및 보안 + +- 파일: [src/config.ts](src/config.ts) +- 역할: + - VS Code 설정을 읽고 `IAgentConfig`로 변환 + - Brain 프로필, 활성 Brain, URL, 모델, 메모리 설정을 통합 + - 설정 검증 및 기본값 제공 + - 보안 정책 상수(`SECURITY_POLICY`, `EXCLUDED_DIRS`) 정의 + +### 2.6 에이전트 팩토리 + +- 파일: [src/agents/factory.ts](src/agents/factory.ts) +- 역할: + - `PlannerAgent`, `ResearcherAgent`, `WriterAgent` 정의 + - 각 에이전트가 담당하는 역할 및 프롬프트 템플릿 구현 + - LLM 호출을 담당하는 공통 `BaseAgent` 제공 + +### 2.7 워크플로우 매니저 + +- 파일: [src/agents/AgentWorkflowManager.ts](src/agents/AgentWorkflowManager.ts) +- 역할: + - Planner → Researcher → Writer로 이어지는 엄격한 워크플로우 실행 + - 상태 변환을 UI 이벤트로 매핑 + +### 2.8 에이전트 엔진 + +- 파일: [src/lib/engine.ts](src/lib/engine.ts) +- 역할: + - 전체 에이전트 파이프라인 실행 + - 상태/감사 추적(`MissionState`) + - 에러 분류 및 복구 로직 + - 캐시 및 중복 제거(`CacheManager`) + - 미션 재개(Resumption) 및 동시 실행 잠금(`lockManager`) + - 프롬프트/컨텍스트 증폭, proactive advice 생성 + +### 2.9 기억 솔루션 + +- 파일: [src/memory/index.ts](src/memory/index.ts) +- 역할: + - `MemoryManager`로 5계층 메모리 관리 + - Short-Term, Long-Term, Project, Procedural, Episodic 메모리 통합 + - 세션 종료 시 메모리 추출 및 저장 + +### 2.10 검색(Retrieval) + +- 파일: [src/retrieval/index.ts](src/retrieval/index.ts) +- 역할: + - RAG 기반 검색 오케스트레이션 + - Brain 파일, 메모리 레이어, 프로젝트/에피소드 검색 + - TF-IDF 유사도 기반 스코어링 + - 컨텍스트 예산 내에서 선택 + +### 2.11 LM Studio 라이프사이클 서브시스템 + +- 폴더: [src/lmstudio/](src/lmstudio/) +- 역할: + - LM Studio 모델의 명시적 load / idle 기반 auto-eject 관리 + - `@lmstudio/sdk`(WebSocket 기반) 위에 얇은 어댑터 + 상태 머신 구성 +- 구성: + - [src/lmstudio/client.ts](src/lmstudio/client.ts) — `LMStudioClient` + - SDK 인스턴스 lazy 생성, `http://...:1234`를 `ws://...:1234`로 자동 변환 + - `load(modelKey, signal?)` / `unload(modelKey)` / `listLoaded()` / `isReachable()` 노출 + - 모든 SDK 호출을 `LMStudioLifecycleError`로 정규화 + - [src/lmstudio/activityTracker.ts](src/lmstudio/activityTracker.ts) — `ActivityTracker` + - `vscode.EventEmitter` 기반 이벤트 버스. `bump()` / `onActivity` 인터페이스 + - [src/lmstudio/lifecycleManager.ts](src/lmstudio/lifecycleManager.ts) — `ModelLifecycleManager` + - 상태: `idle` → `loading` → `loaded` → `streaming` → `unloading` → `idle` + - 진입 신호: `onModelSelected`, `onStreamStart`, `onStreamEnd`, `onActivity`(bump 구독), `setEngine` + - idle 타이머는 `g1nation.lmStudio.idleTimeoutMs`(기본 300000ms, 0이면 비활성화) 기반으로 자동 unload + - `AbortController`로 빠른 모델 전환 시 진행 중 load 취소, 디바운스 300ms + - `disposeAndUnload(timeoutMs)`로 deactivate 시 best-effort eject + +### 2.12 사이드바 핸들러 모듈 + +- 폴더: [src/sidebar/](src/sidebar/) +- 역할: + - `SidebarChatProvider.onDidReceiveMessage`의 ~43개 case branches를 도메인별로 분리 + - 메인 provider는 라우터 6줄로 압축되어 가독성 / 점검 비용 대폭 절감 +- 구성: + - [src/sidebar/chatHandlers.ts](src/sidebar/chatHandlers.ts) — prompt, ready, model 선택, 세션 CRUD, exportResponse, approve/reject 등 chat-domain + - [src/sidebar/brainHandlers.ts](src/sidebar/brainHandlers.ts) — Brain 프로필 관리, syncBrain, saveWikiRaw + - [src/sidebar/chronicleHandlers.ts](src/sidebar/chronicleHandlers.ts) — Project Chronicle CRUD + 6개 record-write 엔트리포인트 + - [src/sidebar/agentHandlers.ts](src/sidebar/agentHandlers.ts) — Agent skill CRUD + 마지막 선택 저장 +- 컨벤션: 각 핸들러는 `(provider, data) => Promise` 시그니처. 처리한 메시지면 true 반환, 미처리는 false. 라우터가 chain으로 위임. 대응 case 없는 메시지는 `logInfo`로 기록. +- 핸들러가 `SidebarChatProvider` 인스턴스 멤버에 직접 접근하므로 instance-level visibility는 모두 풀려 있음. 단 `_`-prefix는 "internal use only" 컨벤션으로 유지되며 `readonly`는 보존됨. + +### 2.13 웹뷰 정적 자산 + +- 폴더: [media/](media/) +- 역할: 사이드바 웹뷰의 HTML/CSS/JS를 esbuild 번들에서 분리하여 IDE syntax highlighting / lint이 정상 동작하도록 외부화 +- 구성: + - [media/sidebar.html](media/sidebar.html) — 골격 + `__STYLES_URI__` / `__SCRIPT_URI__` placeholder + - [media/sidebar.css](media/sidebar.css) — 디자인 토큰, 컴포넌트 스타일 + - [media/sidebar.js](media/sidebar.js) — 웹뷰 측 이벤트 처리, postMessage 연동 +- 로딩 흐름: `SidebarChatProvider._getHtml()`이 템플릿 파일을 한 번 읽어 정적 캐시(`_htmlTemplateCache`)에 보관하고, `webview.asWebviewUri`로 placeholder를 치환 + +## 3. 프로젝트 설계도 요약 + +### 핵심 데이터 흐름 + +1. 사용자 요청 → `AgentExecutor.handlePrompt()` +2. 설정/메모리/검색 컨텍스트 수집 +3. `AgentWorkflowManager` → `AgentEngine.runMission()` 호출 +4. Planner, Researcher, Writer 순차 실행 +5. 결과를 캐시하고 `SidebarChatProvider`로 스트리밍 +6. 필요 시 브레인에 지식 주입 또는 외부 브릿지 응답 + +### 사이드바 메시지 흐름 + +1. 웹뷰 JS([media/sidebar.js](media/sidebar.js))가 `vscode.postMessage({ type, ... })` 발신 +2. `SidebarChatProvider.onDidReceiveMessage` 라우터가 도메인 핸들러 4개에 chain 위임 +3. 각 핸들러는 provider의 internal method를 호출, 결과를 `webview.postMessage`로 회신 +4. LM Studio 엔진 사용 중이면 `prompt`/`activity`/`model` 케이스가 `ModelLifecycleManager`에 신호 전달 + +### 핵심 설계 원칙 + +- **모듈 분리**: UI, AI 호출, 브레인 IO, 메모리, 검색, 에이전트 워크플로우, 모델 라이프사이클이 명확하게 분리됨 +- **상태 내구성**: `MissionState`를 `.astra/missions`에 저장하여 진행 상태 추적 +- **복원력**: 트랜잭션, 잠금, 재시도, 오류 분류, 캐시를 통한 내결함성 확보 +- **로컬 우선**: 로컬 브레인, 로컬 모델 서버, VS Code 환경과의 통합에 중심을 둠 +- **확장성**: 프로필 기반 Brain 선택, multi-agent toggle, memory layer 설정, 도메인별 핸들러 모듈 + +## 4. 강점 + +- 확장성과 유지보수성이 높은 분리된 아키텍처 +- 에이전트 워크플로우를 위한 명시적 `IAgent` 인터페이스 +- `MissionState` 기반 감사/복원 로깅 +- 로컬 AI 엔진(Ollama/LM Studio) 폴백 지원 +- LM Studio 모델 lifecycle 자동 관리 — 메모리 점유 최소화 +- `BridgeServer`로 외부 시스템 연결 가능 +- `MemoryManager`와 `RetrievalOrchestrator`를 통한 RAG+메모리 결합 +- 도메인별 사이드바 핸들러 모듈로 메시지 처리 흐름 명확화 + +## 5. 최근 적용된 최적화 + +| 항목 | 위치 | 효과 | +|---|---|---| +| 모델 목록 30초 TTL 캐시 | [src/sidebarProvider.ts](src/sidebarProvider.ts) `_sendModels()` | 사이드바 토글마다의 `/v1/models` HTTP 왕복 제거. 사용자 명시적 새로고침(`refreshModels`)은 `force=true`로 캐시 우회 | +| `findBrainFiles` 5초 TTL 메모이제이션 | [src/utils.ts](src/utils.ts) | 매 프롬프트마다 반복되던 동기 재귀 fs walk를 캐시. 명시적 무효화 hook(`invalidateBrainFilesCache`) 노출 | +| `secondBrainTrace` 슬롯별 재스캔 제거 | [src/features/secondBrainTrace.ts](src/features/secondBrainTrace.ts) | `scoreFile`을 terms-independent `scanFile`(파일 읽기·분류 1회)과 terms-dependent `scoreScan`으로 분리. N파일×(1+S슬롯) `readFileSync` → N회로 압축 | +| `marked` 미사용 의존성 제거 | [package.json](package.json), [.vscodeignore](.vscodeignore) | 웹뷰는 CDN 사용 중. node_modules 460KB 정리 + stale axios 화이트리스트 정리 | +| 웹뷰 자산 외부화 | [media/](media/) | esbuild 번들 –67KB. webview JS/CSS는 IDE에서 syntax highlighting/lint 정상 동작 | +| 사이드바 핸들러 도메인별 모듈 분리 | [src/sidebar/](src/sidebar/) | `sidebarProvider.ts` 3,535줄 → 1,920줄 (–46%). `onDidReceiveMessage` switch가 라우터 6줄로 | + +전체 회귀: 15 테스트 스위트 / 141 테스트 통과 (LM Studio lifecycle 15개 + brain files 캐시 6개 신규 포함). + +## 6. 추가 개선 제안 + +### 6.1 캐시 키 일관성 (미적용) + +- `CacheManager.get()`과 `set()`은 prompt+context 해시를 사용 +- `context`가 모델 또는 옵션을 포함하지 않으면 모델 변경 시 캐시 혼선 가능 +- 권장: 캐시 키에 모델명/temperature/엔진 종류를 명시적으로 포함 + +### 6.2 에러 로그 세분화 (미적용) + +- `AgentEngine.handleMissionFailure()`는 오류를 `error` 상태로 전환만 함 +- 사용자에게 직접 표시할 추가 요약 메시지를 생성하면 UI 피드백 향상 +- LM Studio lifecycle은 이미 `notifyError` → 웹뷰 `lmStudioError` 토스트 패턴을 사용 중. 동일 패턴을 mission failure에도 적용 가능 + +### 6.3 핫패스 캐시 무효화 와이어링 (선택) + +- `invalidateBrainFilesCache(dir)`는 export되어 있지만 mutation 지점(syncBrain 직후 / chronicle write 직후)에 wiring되어 있지 않음 +- 5초 TTL이 충분히 짧아 보류 상태. "방금 쓴 chronicle이 즉시 검색 안 됨" 같은 freshness 이슈가 보고되면 그때 추가 + +### 6.4 SidebarChatProvider 추가 분할 (선택, L effort) + +- 현재 1,920줄. 도메인 메시지 라우팅은 분리됐지만 chronicle / brain / session / agent 관련 internal method가 한 클래스에 공존 +- 실제 sub-provider로 분할(상태 분리 포함)하는 것은 큰 리팩터링이며 회귀 위험이 있음. 다음 큰 기능 추가 시 함께 진행하는 것이 ROI 측면에서 바람직 + +### 6.5 PDF 파이프라인 의존성 (미적용) + +- `pdf-parse`(+ transitive `pdfjs-dist` 13MB, `@napi-rs/canvas` native binary)가 .vsix에 동봉되지 않고 esbuild 번들에 700KB 포함되는 구조 +- external 처리 시 .vsix 다운로드가 17배 증가하여 사용자 측 손해 +- 더 가벼운 PDF 텍스트 추출 라이브러리로 교체하거나 PDF 기능 자체를 옵션화하는 별도 검토 필요 + +## 7. 주요 파일 및 담당 영역 + +### 진입점 / 코어 +- [package.json](package.json) — VS Code 확장 메타데이터, 명령, 확장 포인트, 신규 `g1nation.lmStudio.*` 설정 +- [src/extension.ts](src/extension.ts) — 확장 활성화, lifecycle 서브시스템 wiring, deactivate 시 best-effort eject +- [src/agent.ts](src/agent.ts) — 프롬프트/대화/세션/트랜잭션 핵심 + `onStreamLifecycle` 콜백 +- [src/bridge.ts](src/bridge.ts) — 외부 HTTP 브릿지 (port 4825) +- [src/config.ts](src/config.ts) — 설정, Brain 프로필, 보안 정책 +- [src/utils.ts](src/utils.ts) — 공용 헬퍼, brain 파일 캐시 포함 + +### Agent 파이프라인 +- [src/agents/factory.ts](src/agents/factory.ts) — Planner/Researcher/Writer 에이전트 +- [src/agents/AgentWorkflowManager.ts](src/agents/AgentWorkflowManager.ts) — 다중 에이전트 워크플로우 +- [src/lib/engine.ts](src/lib/engine.ts) — 에이전트 실행 파이프라인, 상태, 캐시, 오류 복구 + +### 메모리 / 검색 / 분류 +- [src/memory/index.ts](src/memory/index.ts) — 다중 메모리 계층 관리 +- [src/retrieval/index.ts](src/retrieval/index.ts) — 검색/컨텍스트 예산 관리 +- [src/features/secondBrainTrace.ts](src/features/secondBrainTrace.ts) — Second Brain 추적, scanFile/scoreScan 분리 + +### LM Studio Lifecycle +- [src/lmstudio/client.ts](src/lmstudio/client.ts) — SDK wrapper +- [src/lmstudio/activityTracker.ts](src/lmstudio/activityTracker.ts) — 활동 이벤트 버스 +- [src/lmstudio/lifecycleManager.ts](src/lmstudio/lifecycleManager.ts) — 모델 상태 머신 + +### 사이드바 +- [src/sidebarProvider.ts](src/sidebarProvider.ts) — 사이드바 webview provider, 라우터 +- [src/sidebar/chatHandlers.ts](src/sidebar/chatHandlers.ts) — chat domain 메시지 +- [src/sidebar/brainHandlers.ts](src/sidebar/brainHandlers.ts) — brain domain 메시지 +- [src/sidebar/chronicleHandlers.ts](src/sidebar/chronicleHandlers.ts) — chronicle domain 메시지 +- [src/sidebar/agentHandlers.ts](src/sidebar/agentHandlers.ts) — agent domain 메시지 +- [media/sidebar.html](media/sidebar.html) / [media/sidebar.css](media/sidebar.css) / [media/sidebar.js](media/sidebar.js) — 웹뷰 정적 자산 + +### 테스트 +- [tests/lmStudioLifecycle.test.ts](tests/lmStudioLifecycle.test.ts) — 상태 머신 15개 케이스 +- [tests/findBrainFilesCache.test.ts](tests/findBrainFilesCache.test.ts) — TTL 캐시 6개 케이스 +- [tests/secondBrainTrace.test.ts](tests/secondBrainTrace.test.ts) — scanFile/scoreScan 리팩터 후에도 12개 케이스 통과로 동작 보존 확인 + +## 8. 결론 + +`ConnectAI`는 로컬 AI 기반 VS Code 어시스턴트로서 좋은 설계 기반 위에 점진적 모듈화가 진행 중인 상태입니다. 다층 메모리, 멀티 에이전트 파이프라인, 브릿지 서버, LM Studio 라이프사이클 관리, 도메인별 사이드바 핸들러까지 갖춰져 있어 신규 기능 추가의 진입 비용이 낮아졌습니다. + +남은 개선 여지는 (1) 캐시 키 일관성 강화, (2) mission failure UI 메시지 다듬기, (3) PDF 의존성 정책 재검토 정도로, 모두 명확한 trade-off가 있는 항목들입니다. + +--- + +문서 갱신일: 2026-05-08 diff --git a/media/sidebar.css b/media/sidebar.css new file mode 100644 index 0000000..37b9b21 --- /dev/null +++ b/media/sidebar.css @@ -0,0 +1,606 @@ + :root { + --bg: #0d1117; + --bg-secondary: #0d1117; + --surface: #161b22; + --border: #30363d; + --border-bright: #484f58; + --text-primary: #c9d1d9; + --text-bright: #ffffff; + --text-dim: #8b949e; + --accent: #58a6ff; + --accent-glow: rgba(88, 166, 255, 0.15); + --success: #238636; + --warning: #d29922; + --error: #f85149; + --code-bg: #161b22; + --table-header-bg: #161b22; + --table-row-hover: #21262d; + --input-bg: #0d1117; + --control-bg: #161b22; + --control-bg-hover: #21262d; + --control-active-bg: rgba(88, 166, 255, 0.14); + --shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.18); + } + + body.vscode-light { + --bg: #ffffff; + --bg-secondary: #f6f8fa; + --surface: #ffffff; + --border: #d0d7de; + --border-bright: #afb8c1; + --text-primary: #24292f; + --text-bright: #111118; + --text-dim: #57606a; + --accent: #0969da; + --accent-glow: rgba(9, 105, 218, 0.1); + --success: #1a7f37; + --warning: #9a6700; + --error: #cf222e; + --code-bg: #f6f8fa; + --table-header-bg: #f6f8fa; + --table-row-hover: #f3f4f6; + --input-bg: #ffffff; + --control-bg: #ffffff; + --control-bg-hover: #f6f8fa; + --control-active-bg: rgba(9, 105, 218, 0.1); + --shadow-soft: 0 8px 20px rgba(31, 35, 40, 0.08); + } + + * { margin: 0; padding: 0; box-sizing: border-box; } + + html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Helvetica, Arial, sans-serif; + font-size: 13px; + line-height: 1.6; + background: var(--bg); + color: var(--text-primary); + display: flex; + flex-direction: column; + overflow: hidden; + } + + /* --- Header --- */ + .header { + display: flex; + flex-direction: column; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + z-index: 100; + } + + .header-top { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px 6px; + gap: 10px; + } + + .header-controls { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; + padding: 0 12px 12px; + } + + .header-actions, + .tool-group, + .select-stack, + .select-line, + .status-pill { + display: flex; + align-items: center; + } + + .header-actions { gap: 6px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; } + .tool-group { + gap: 4px; + padding: 3px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.02); + } + .select-stack { flex-direction: column; gap: 6px; min-width: 0; } + .select-line { gap: 6px; width: 100%; min-width: 0; } + .paired-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; + width: 100%; + align-items: center; + } + .control-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; + width: 100%; + align-items: center; + } + .record-row { + grid-template-columns: auto minmax(0, 1fr) auto; + } + + .brand { font-weight: 700; font-size: 14px; color: var(--text-bright); letter-spacing: 0; display: flex; align-items: center; gap: 8px; min-width: 0; } + .logo { width: 22px; height: 22px; background: var(--accent); color: #fff; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 900; } + + .status-pill { + height: 28px; + gap: 6px; + padding: 0 9px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--control-bg); + color: var(--text-dim); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + flex-shrink: 0; + } + + .status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-dim); + box-shadow: 0 0 0 2px rgba(139, 148, 158, 0.12); + flex-shrink: 0; + } + .status-dot.ready { + background: #3fb950; + box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.16); + } + + .chat { + flex: 1; + overflow-y: auto; + padding: 16px 14px; + display: flex; + flex-direction: column; + gap: 24px; + scroll-behavior: smooth; + } + + /* --- Messages --- */ + .msg { + display: flex; + flex-direction: column; + gap: 8px; + position: relative; + animation: msgIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); + } + + .msg-user { + align-items: flex-end; + } + + .msg-ai { + align-items: flex-start; + } + + @keyframes msgIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } + + .msg-head { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 11px; color: var(--text-dim); } + .msg-user .msg-head { flex-direction: row-reverse; } + .av { width: 22px; height: 22px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; } + + /* Tooltip System */ + [data-tooltip] { position: relative; } + [data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; bottom: -30px; left: 50%; transform: translateX(-50%); + background: #333; color: #fff; padding: 4px 8px; border-radius: 4px; + font-size: 10px; white-space: nowrap; opacity: 0; pointer-events: none; + transition: all 0.2s ease; z-index: 100; box-shadow: 0 4px 10px rgba(0,0,0,0.3); + } + [data-tooltip]:hover::after { opacity: 1; bottom: -35px; } + + #input::placeholder { color: var(--accent); opacity: 0.6; font-weight: 500; } + + + .msg-body { + color: var(--text-primary); + font-size: 13.5px; + word-break: break-word; + max-width: min(88%, 760px); + } + + .msg-user .msg-body { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px 14px; + white-space: pre-wrap; + text-align: left; + } + + .msg-ai .msg-body { + padding-left: 30px; + width: 100%; + max-width: 100%; + } + + /* --- Markdown Style --- */ + .markdown-body h1, .markdown-body h2, .markdown-body h3 { + color: var(--text-bright); + margin: 1.5em 0 0.8em; + font-weight: 600; + line-height: 1.3; + } + + .markdown-body h1 { font-size: 1.5em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; color: var(--accent); } + .markdown-body h2 { font-size: 1.45em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; } + .markdown-body h3 { font-size: 1.2em; } + .markdown-body p { margin: 0.75em 0 1.1em; } + .markdown-body ol, .markdown-body ul { margin: 0.7em 0 1.1em; padding-left: 1.45em; } + .markdown-body li { margin: 0.35em 0 0.65em; } + .markdown-body li + li { margin-top: 0.55em; } + .markdown-body table { + width: 100%; + border-collapse: collapse; + margin: 1.2em 0; + font-size: 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + } + .markdown-body th { background: var(--table-header-bg); color: var(--accent); font-weight: 600; text-align: left; padding: 10px 12px; border: 1px solid var(--border); } + .markdown-body td { padding: 8px 12px; border: 1px solid var(--border); color: var(--text-primary); } + .markdown-body tr:nth-child(even) { background: rgba(255, 255, 255, 0.02); } + + .markdown-body pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 14px; overflow-x: auto; margin: 12px 0; } + .markdown-body code { font-family: 'SF Mono', monospace; font-size: 11.5px; background: rgba(175, 184, 193, 0.2); padding: 0.2em 0.4em; border-radius: 4px; } + + /* --- UI Elements --- */ + .msg-actions { + position: absolute; bottom: -12px; right: 0; display: flex; gap: 4px; opacity: 0; transition: 0.2s; z-index: 20; + } + .msg:hover .msg-actions { opacity: 1; } + .action-btn { + background: var(--bg-secondary); border: 1px solid var(--border); + color: var(--text-dim); padding: 4px 10px; border-radius: 6px; font-size: 10px; cursor: pointer; transition: 0.2s; + display: flex; align-items: center; gap: 4px; + } + .action-btn:hover { color: var(--text-bright); border-color: var(--accent); background: var(--accent-glow); } + + .icon-btn { + background: var(--control-bg); + border: 1px solid var(--border); + color: var(--text-dim); + min-width: 28px; + height: 28px; + padding: 0 8px; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; + font-size: 12px; + font-weight: 700; + line-height: 1; + flex: 0 0 auto; + } + .icon-btn:hover { color: var(--text-bright); border-color: var(--border-bright); background: var(--control-bg-hover); box-shadow: var(--shadow-soft); } + .icon-btn.active { color: var(--accent); border-color: var(--accent); background: var(--control-active-bg); } + + .select-wrap { + position: relative; + min-width: 0; + flex: 1 1 0; + } + + .select-wrap::after { + content: ''; + position: absolute; + right: 10px; + top: 50%; + width: 6px; + height: 6px; + border-right: 1.5px solid var(--text-dim); + border-bottom: 1.5px solid var(--text-dim); + transform: translateY(-65%) rotate(45deg); + pointer-events: none; + } + + select { + width: 100%; + min-width: 0; + height: 30px; + background: var(--control-bg); + color: var(--text-primary); + border: 1px solid var(--border); + padding: 0 28px 0 10px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + outline: none; + appearance: none; + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; + } + + select:hover { border-color: var(--border-bright); background: var(--control-bg-hover); } + select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); } + + /* --- Input & Attachments --- */ + .input-wrap { + padding: 12px 14px 16px; background: var(--bg); border-top: 1px solid var(--border); flex-shrink: 0; + } + + .input-box { + background: var(--input-bg); border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px; + display: flex; flex-direction: column; gap: 8px; transition: 0.2s; + position: relative; /* Toast 위치 앙커 */ + } + .input-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); } + + textarea { + width: 100%; background: transparent; border: none; color: var(--text-bright); + font-family: inherit; font-size: 13.5px; resize: none; outline: none; min-height: 24px; max-height: 160px; + } + + .attachment-preview { + display: none; gap: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border); flex-wrap: wrap; + } + .attachment-preview.visible { display: flex; } + + .file-chip { + display: flex; align-items: center; gap: 6px; background: var(--surface); border: 1px solid var(--border); + padding: 4px 8px; border-radius: 6px; font-size: 11px; color: var(--text-primary); + } + .file-chip .remove { cursor: pointer; color: var(--text-dim); } + .file-chip .remove:hover { color: var(--error); } + + .input-footer { display: flex; align-items: center; justify-content: space-between; } + .footer-left { display: flex; align-items: center; gap: 8px; } + + .send-btn { + background: var(--accent); color: #fff; border: none; padding: 6px 14px; border-radius: 6px; + font-weight: 600; font-size: 12px; cursor: pointer; + } + .send-btn:disabled { opacity: 0.3; cursor: not-allowed; } + + .footer-right { display: flex; align-items: center; gap: 6px; } + + .cancel-btn { + background: transparent; color: var(--text-dim); border: 1px solid var(--border); + padding: 6px 10px; border-radius: 6px; font-weight: 600; font-size: 11px; + cursor: pointer; align-items: center; gap: 4px; transition: all 0.15s ease; + } + .cancel-btn:hover { background: rgba(248, 81, 73, 0.12); color: var(--error); border-color: var(--error); } + + .stop-btn { + background: rgba(248, 81, 73, 0.15); color: var(--error); border: 1px solid var(--error); + padding: 6px 14px; border-radius: 6px; font-weight: 700; font-size: 12px; + cursor: pointer; display: inline-flex; align-items: center; gap: 5px; + animation: stopPulse 1.2s ease-in-out infinite; + } + .stop-btn:hover { background: rgba(248, 81, 73, 0.3); } + @keyframes stopPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(248,81,73,0.4); } + 50% { box-shadow: 0 0 0 5px rgba(248,81,73,0); } + } + + /* --- Drag and Drop Overlay --- */ + body.drag-over::after { + content: '📂 파일을 여기에 놓으세요'; + position: fixed; top: 0; left: 0; width: 100%; height: 100%; + background: rgba(88, 166, 255, 0.2); + backdrop-filter: blur(4px); + border: 2px dashed var(--accent); + display: flex; align-items: center; justify-content: center; + color: var(--text-bright); font-size: 18px; font-weight: 700; + z-index: 9999; pointer-events: none; + animation: fadeIn 0.2s ease-out; + } + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + + /* --- Toast Notification --- */ + .toast-notif { + position: absolute; bottom: calc(100% + 8px); left: 50%; + transform: translateX(-50%) translateY(4px); + background: var(--surface); border: 1px solid var(--border-bright); + color: var(--text-primary); font-size: 11px; font-weight: 600; + padding: 7px 14px; border-radius: 20px; white-space: nowrap; + opacity: 0; pointer-events: none; + transition: opacity 0.2s ease, transform 0.2s ease; + z-index: 500; box-shadow: 0 4px 16px rgba(0,0,0,0.3); + } + .toast-notif.toast-visible { opacity: 1; transform: translateX(-50%) translateY(0); } + .toast-notif.toast-warn { border-color: var(--error); color: var(--error); background: rgba(248,81,73,0.08); } + .toast-notif.toast-success { border-color: var(--success); color: var(--success); background: rgba(35,134,54,0.08); } + + /* --- Overlays & Others --- */ + .thinking-bar { height: 2px; background: transparent; position: relative; overflow: hidden; margin-top: -1px; } + .thinking-bar.active { + display: block; + background: linear-gradient(90deg, transparent, #2196f3, #bb86fc, transparent); + background-size: 200% 100%; + animation: thinking 1.5s infinite linear; + } + @keyframes thinking { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } + + .history-overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); + backdrop-filter: blur(10px); z-index: 1000; display: none; flex-direction: column; padding: 20px; + } + .history-overlay.visible { display: flex; } + + .stream-active::after { + content: ''; display: inline-block; width: 6px; height: 14px; background: var(--accent); + margin-left: 4px; animation: blink 0.8s step-end infinite; vertical-align: middle; + } + @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } + + .welcome { text-align: center; padding: 40px 20px; color: var(--text-dim); } + .welcome-logo { font-size: 48px; color: var(--accent); margin-bottom: 16px; opacity: 0.8; } + .welcome-title { font-size: 20px; font-weight: 700; color: var(--text-bright); margin-bottom: 8px; } + + /* --- History List --- */ + .history-item { + padding: 12px; border-radius: 8px; background: var(--surface); border: 1px solid var(--border); + margin-bottom: 10px; cursor: pointer; transition: 0.2s; + } + .history-item:hover { border-color: var(--accent); background: var(--accent-glow); } + + /* --- Approval UI --- */ + .approval-box { + display: flex; + flex-direction: column; + gap: 12px; + margin: 15px 0; + padding: 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-bright); + border-radius: 12px; + animation: msgIn 0.3s ease-out; + } + .approval-title { font-weight: 700; color: var(--accent); font-size: 12px; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; } + .approval-btns { display: flex; gap: 10px; } + .btn-approve { flex: 1; background: var(--success); color: white; border: none; padding: 10px; border-radius: 8px; cursor: pointer; font-weight: 700; font-size: 12px; transition: 0.2s; } + .btn-approve:hover { filter: brightness(1.1); transform: translateY(-1px); } + .btn-reject { flex: 1; background: var(--error); color: white; border: none; padding: 10px; border-radius: 8px; cursor: pointer; font-weight: 700; font-size: 12px; transition: 0.2s; } + .btn-reject:hover { filter: brightness(1.1); transform: translateY(-1px); } + + .panel { + display: none; + flex-direction: column; + gap: 8px; + padding-bottom: 10px; + } + + .field-label { + color: var(--text-dim); + font-size: 10px; + font-weight: 700; + line-height: 1.2; + } + + .panel textarea { + font-size: 11.5px; + padding: 8px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--text-bright); + width: 100%; + resize: vertical; + font-family: inherit; + outline: none; + } + + .panel textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); + } + + .secondary-btn { + height: 30px; + background: var(--control-bg); + border: 1px solid var(--border); + color: var(--text-primary); + padding: 0 10px; + font-size: 11px; + font-weight: 700; + border-radius: 6px; + cursor: pointer; + } + + .secondary-btn:hover { + border-color: var(--accent); + background: var(--control-active-bg); + color: var(--text-bright); + } + + /* --- Physics & Micro-interactions --- */ + button { + transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); + user-select: none; + outline: none; + } + button:active { + transform: scale(0.96); + filter: brightness(0.9); + } + + /* --- Hierarchical Grouping --- */ + .input-group { + background: rgba(255, 255, 255, 0.02); + border-radius: 12px; + padding: 8px; + margin-top: 10px; + border: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + gap: 8px; + } + + /* --- Storytelling Stepper --- */ + .stepper-container { + display: none; + margin: 12px 16px; + padding: 12px; + background: rgba(var(--accent-rgb), 0.05); + border-radius: 8px; + border: 1px solid rgba(var(--accent-rgb), 0.1); + animation: slideIn 0.3s ease-out; + } + .stepper-container.active { display: block; } + .steps { + display: flex; + justify-content: space-between; + position: relative; + } + .step { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + flex: 1; + } + .step-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-dim); + transition: 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + } + .step.active .step-dot { + background: var(--accent); + box-shadow: 0 0 12px var(--accent); + transform: scale(1.5); + } + .step.complete .step-dot { + background: var(--success); + box-shadow: 0 0 8px var(--success); + } + .step-label { + font-size: 9px; + color: var(--text-dim); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + } + .step.active .step-label { color: var(--accent); } + .step.complete .step-label { color: var(--success); } + + @media (min-width: 360px) { + .header-controls { + grid-template-columns: minmax(0, 1fr); + align-items: start; + } + } + @media (max-width: 520px) { + .paired-row { + grid-template-columns: minmax(0, 1fr); + } + .header-top { + align-items: flex-start; + } + } + @keyframes slideIn { + from { opacity: 0; transform: translateX(-10px); } + to { opacity: 1; transform: translateX(0); } + } diff --git a/media/sidebar.html b/media/sidebar.html new file mode 100644 index 0000000..56215cb --- /dev/null +++ b/media/sidebar.html @@ -0,0 +1,133 @@ + + + + + + Astra + + + + +
+
+
Astra
+
+ + + + + + + +
+
+
+
+
+
Engine
+
+
+
+
+
+
+ + + + +
+
+
+
+
+ + + +
+
+
+
+
+
+ + +
+
+
+
+ Auto Records +
+
+
+ + +
+
+
+
+
+ +
+
+

Chat History

+ +
+
+
+ +
+ +
+
+
Analyze
+
Plan
+
Execute
+
Verify
+
+
+ +
+
+ +
Welcome to Astra
+

Your premium local AI assistant.
Ready to analyze projects and build reports.

+
+
+ +
+
+
Agent Persona/Instructions
+ + +
Negative Prompt (Strict Rules)
+ + + +
+
+
+ + +
+
+
+ + +
+
+ + + + + + diff --git a/media/sidebar.js b/media/sidebar.js new file mode 100644 index 0000000..99aaba5 --- /dev/null +++ b/media/sidebar.js @@ -0,0 +1,729 @@ + const vscode = acquireVsCodeApi(); + const chat = document.getElementById('chat'); + const input = document.getElementById('input'); + + // [State Persistence - Tier 0] 즉시 복원 (Instant Restore from WebView State) + const previousState = vscode.getState(); + if (previousState && previousState.history && previousState.history.length > 0) { + console.log('[Astra] Restoring from Webview State...'); + renderHistory(previousState.history); + } + + function saveWebviewState(history) { + const current = vscode.getState() || {}; + vscode.setState({ ...current, history }); + } + + function saveUiState() { + const current = vscode.getState() || {}; + vscode.setState({ ...current, secondBrainTraceEnabled, secondBrainTraceDebug }); + } + + function renderHistory(history) { + if (!history || history.length === 0) return; + chat.innerHTML = ''; + history.forEach(m => { + if (!m) return; + // Only skip truly internal system messages, keep assistant thoughts + if (m.role === 'system' && m.internal) return; + addMsg(m.content, m.role === 'assistant' ? 'assistant' : 'user', m.rationale); + }); + chat.scrollTop = chat.scrollHeight; + } + const sendBtn = document.getElementById('sendBtn'); + const stopBtn = document.getElementById('stopBtn'); + const cancelBtn = document.getElementById('cancelBtn'); + const toastNotif = document.getElementById('toastNotif'); + const thinkingBar = document.getElementById('thinkingBar'); + const statusLabel = document.getElementById('statusLabel'); + const stepper = document.getElementById('stepper'); + + // --- Draft State Management --- + let isDraftActive = false; + let _toastTimer = null; + + function showToast(msg, type = 'info') { + toastNotif.textContent = msg; + toastNotif.className = 'toast-notif toast-' + type + ' toast-visible'; + if (_toastTimer) clearTimeout(_toastTimer); + _toastTimer = setTimeout(() => { + toastNotif.classList.remove('toast-visible'); + }, 2500); + } + + function setDraftActive(active) { + isDraftActive = active; + cancelBtn.style.display = active ? 'inline-flex' : 'none'; + } + + // 생성 중/완료 시 Send ⇔ Stop 전환 + function setGenerating(generating) { + if (generating) { + sendBtn.style.display = 'none'; + stopBtn.style.display = 'inline-flex'; + // 생성 중에는 Clear 버튼 숨김 + cancelBtn.style.display = 'none'; + } else { + stopBtn.style.display = 'none'; + sendBtn.style.display = 'inline-flex'; + sendBtn.disabled = false; + // Draft 상태에 따라 Clear 버튼 복원 + if (isDraftActive) cancelBtn.style.display = 'inline-flex'; + } + } + + function clearDraft() { + // Step 1: 상태 초기화 (Draft State Reset) + setDraftActive(false); + // Step 2: UI 반영 (Input + Attachments 초기화) + input.value = ''; + input.style.height = 'auto'; + pendingFiles = []; + renderAttachments(); + input.focus(); + // Step 3: Toast 알림으로 즉각적 피드백 + showToast('✕ 작성 내용이 초기화되었습니다.', 'warn'); + Sound.warn(); + } + + + // --- Sound Manager --- + const Sound = { + ctx: null, + init() { if (!this.ctx) this.ctx = new (window.AudioContext || window.webkitAudioContext)(); }, + play(freq, type, dur) { + try { + this.init(); + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, this.ctx.currentTime); + gain.gain.setValueAtTime(0.05, this.ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + dur); + osc.connect(gain); + gain.connect(this.ctx.destination); + osc.start(); + osc.stop(this.ctx.currentTime + dur); + } catch(e) {} + }, + success() { this.play(880, 'sine', 0.1); setTimeout(() => this.play(1109, 'sine', 0.15), 80); }, + warn() { this.play(440, 'triangle', 0.3); } + }; + + function setStep(stepId, state = 'active') { + stepper.classList.add('active'); + const step = document.getElementById('step-' + stepId); + if (step) { + if (state === 'active') { + document.querySelectorAll('.step').forEach(s => s.classList.remove('active')); + step.classList.add('active'); + } else if (state === 'complete') { + step.classList.remove('active'); + step.classList.add('complete'); + } + } + } + + function resetStepper() { + stepper.classList.remove('active'); + document.querySelectorAll('.step').forEach(s => { + s.classList.remove('active'); + s.classList.remove('complete'); + }); + } + const modelSel = document.getElementById('modelSel'); + const brainSel = document.getElementById('brainSel'); + const historyOverlay = document.getElementById('historyOverlay'); + const historyList = document.getElementById('historyList'); + const statusDot = document.getElementById('statusDot'); + const engineStatusText = document.getElementById('engineStatusText'); + const attachBtn = document.getElementById('attachBtn'); + const fileInput = document.getElementById('fileInput'); + const attachPreview = document.getElementById('attachPreview'); + const agentSel = document.getElementById('agentSel'); + const designerSel = document.getElementById('designerSel'); + const chronicleRecordSel = document.getElementById('chronicleRecordSel'); + const editAgentBtn = document.getElementById('editAgentBtn'); + const addAgentBtn = document.getElementById('addAgentBtn'); + const deleteAgentBtn = document.getElementById('deleteAgentBtn'); + const addBrainBtn = document.getElementById('addBrainBtn'); + const editBrainBtn = document.getElementById('editBrainBtn'); + const deleteBrainBtn = document.getElementById('deleteBrainBtn'); + const saveWikiRawBtn = document.getElementById('saveWikiRawBtn'); + const agentConfigPanel = document.getElementById('agentConfigPanel'); + const agentPrompt = document.getElementById('agentPrompt'); + const negativePrompt = document.getElementById('negativePrompt'); + const updateAgentBtn = document.getElementById('updateAgentBtn'); + + let streamBody = null; + let internetEnabled = false; + let secondBrainTraceEnabled = true; + let secondBrainTraceDebug = false; + let pendingFiles = []; + let editMode = false; + if (previousState && typeof previousState.secondBrainTraceEnabled === 'boolean') { + secondBrainTraceEnabled = previousState.secondBrainTraceEnabled; + } + if (previousState && typeof previousState.secondBrainTraceDebug === 'boolean') { + secondBrainTraceDebug = previousState.secondBrainTraceDebug; + } + const initialTraceBtn = document.getElementById('brainTraceBtn'); + initialTraceBtn.classList.toggle('active', secondBrainTraceEnabled); + initialTraceBtn.setAttribute('data-tooltip', secondBrainTraceEnabled ? 'Second Brain Trace Mode: On' : 'Second Brain Trace Mode: Off'); + const initialTraceDebugBtn = document.getElementById('brainTraceDebugBtn'); + initialTraceDebugBtn.classList.toggle('active', secondBrainTraceDebug); + initialTraceDebugBtn.setAttribute('data-tooltip', secondBrainTraceDebug ? 'Second Brain Debug JSON: On' : 'Second Brain Debug JSON: Off'); + + function fmt(text) { return marked.parse(text || ''); } + + function copyToClipboard(text, btn) { + const textarea = document.createElement('textarea'); + textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; + document.body.appendChild(textarea); textarea.select(); + try { + if (document.execCommand('copy')) { + btn.innerText = '✅ Copied!'; setTimeout(() => { btn.innerText = '📋 Copy'; }, 2000); + } + } catch (err) { console.error('Copy failed', err); } + document.body.removeChild(textarea); + } + + window.approve = () => { + const box = document.querySelector('.approval-box'); + if (box) box.remove(); + vscode.postMessage({ type: 'approveAction' }); + }; + window.reject = () => { + const box = document.querySelector('.approval-box'); + if (box) box.remove(); + vscode.postMessage({ type: 'rejectAction' }); + }; + + function exportToMD(text) { + vscode.postMessage({ type: 'exportResponse', text: text }); + } + + function addMsg(text, role, rationale) { + const isUser = role === 'user'; + const msgEl = document.createElement('div'); + msgEl.className = 'msg ' + (isUser ? 'msg-user' : 'msg-ai'); + msgEl._raw = text; + + const head = document.createElement('div'); + head.className = 'msg-head'; + head.innerHTML = isUser ? '
U
You' : '
Astra'; + + const body = document.createElement('div'); + body.className = 'msg-body markdown-body'; + + if (isUser) { + body.innerText = text; + } else { + body.innerHTML = fmt(text); + } + + const actions = document.createElement('div'); + actions.className = 'msg-actions'; + + const copyBtn = document.createElement('button'); + copyBtn.className = 'action-btn'; copyBtn.innerText = '📋 Copy'; + copyBtn.onclick = (e) => { e.stopPropagation(); copyToClipboard(msgEl._raw, copyBtn); }; + + const exportBtn = document.createElement('button'); + exportBtn.className = 'action-btn'; exportBtn.innerText = '💾 Export'; + exportBtn.onclick = (e) => { e.stopPropagation(); exportToMD(msgEl._raw); }; + + actions.appendChild(copyBtn); + actions.appendChild(exportBtn); + + msgEl.appendChild(head); msgEl.appendChild(body); + msgEl.appendChild(actions); + chat.appendChild(msgEl); chat.scrollTop = chat.scrollHeight; + return { body, msgEl }; + } + + window.addEventListener('message', e => { + const msg = e.data; + switch(msg.type) { + case 'addMessage': + addMsg(msg.value, msg.role, msg.rationale); + // Update state for non-streamed messages + const s = vscode.getState() || { history: [] }; + s.history.push({ role: msg.role === 'assistant' ? 'assistant' : 'user', content: msg.value, rationale: msg.rationale }); + saveWebviewState(s.history); + break; + case 'streamStart': + thinkingBar.classList.remove('active'); + if (document.querySelector('.welcome')) document.querySelector('.welcome').remove(); + const res = addMsg('', 'assistant'); + streamBody = res.body; streamBody._parent = res.msgEl; streamBody._parent._raw = ''; + streamBody.classList.add('stream-active'); + break; + case 'streamChunk': + if (streamBody) { + streamBody._parent._raw += msg.value; + streamBody.innerHTML = fmt(streamBody._parent._raw); + chat.scrollTop = chat.scrollHeight; + } + break; + case 'streamEnd': + if (streamBody) { + streamBody.classList.remove('stream-active'); + // Update state after stream finishes + const state = vscode.getState() || { history: [] }; + state.history.push({ role: 'assistant', content: streamBody._parent._raw }); + saveWebviewState(state.history); + } + streamBody = null; + // 생성 완료 시 Stop 버튼 숨기고 Send 복구 + setGenerating(false); + resetStepper(); + Sound.success(); + break; + case 'restoreHistory': + case 'sessionLoaded': + const historyPayload = msg.type === 'sessionLoaded' ? msg.value : msg.value; + const history = Array.isArray(historyPayload) + ? historyPayload + : (Array.isArray(historyPayload?.history) ? historyPayload.history : []); + + if (history && history.length > 0) { + renderHistory(history); + saveWebviewState(history); + } + if (historyPayload?.negativePrompt !== undefined) { + negativePrompt.value = historyPayload.negativePrompt; + } + historyOverlay.classList.remove('visible'); + break; + case 'clearChat': + chat.innerHTML = '
Welcome to Astra

Your premium local AI assistant.
Ready to analyze projects and build reports.

'; + break; + case 'focusInput': + input.focus(); + break; + case 'modelsList': { + modelSel.innerHTML = ''; + // [State Persistence - Tier 2] LocalStorage에서 마지막 선택 모델 복원 시도 + const _savedModel = localStorage.getItem('g1nation_last_model'); + // 서버 추천 모델 vs 로컬 저장 모델 중 우선순위 결정 + // LocalStorage에 저장된 값이 현재 목록에 있으면 그것을 우선 사용 (Tier 2 우선) + const _preferredModel = (_savedModel && msg.value.models.includes(_savedModel)) + ? _savedModel + : msg.value.selected; + msg.value.models.forEach(m => { + const o = document.createElement('option'); o.value = m; o.innerText = m; + if (m === _preferredModel) o.selected = true; + modelSel.appendChild(o); + }); + // LocalStorage에 저장된 모델이 실제로 적용된 경우, 백엔드 설정도 동기화 + if (_savedModel && _savedModel !== msg.value.selected && msg.value.models.includes(_savedModel)) { + vscode.postMessage({ type: 'model', value: _savedModel }); + } + if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder(); + statusLabel.innerText = `Model: ${_preferredModel}`; + break; + } + case 'brainProfiles': + brainSel.innerHTML = ''; + msg.value.profiles.forEach(p => { + const o = document.createElement('option'); o.value = p.id; o.innerText = p.name; + if (p.id === msg.value.activeBrainId) o.selected = true; + brainSel.appendChild(o); + }); + const addOpt = document.createElement('option'); + addOpt.value = 'new'; addOpt.innerText = '+ Add New Brain...'; + brainSel.appendChild(addOpt); + break; + case 'sessionList': + historyList.innerHTML = ''; + msg.value.forEach(s => { + const el = document.createElement('div'); el.className = 'history-item'; + el.setAttribute('role', 'button'); + el.tabIndex = 0; + el.dataset.sessionId = s.id; + el.innerHTML = `
${s.title}
${new Date(s.timestamp).toLocaleString()} · ${s.messageCount} msgs
`; + const load = () => { + if (!el.dataset.sessionId) return; + vscode.postMessage({ type: 'loadSession', id: el.dataset.sessionId }); + }; + el.addEventListener('click', load); + el.addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + load(); + } + }); + historyList.appendChild(el); + }); + break; + case 'engineStatus': + statusDot.style.background = msg.value.online ? 'var(--success)' : 'var(--error)'; + engineStatusText.innerText = msg.value.online ? 'Online' : 'Offline'; + break; + case 'autoContinue': + statusLabel.innerText = msg.value; thinkingBar.classList.add('active'); + if (msg.value.includes('Analyzing')) setStep('analyze'); + if (msg.value.includes('Planning')) setStep('plan'); + if (msg.value.includes('Executing')) setStep('execute'); + setTimeout(() => { thinkingBar.classList.remove('active'); }, 3000); + break; + case 'agentsList': + agentSel.innerHTML = ''; + msg.value.forEach(a => { + const o = document.createElement('option'); o.value = a.path; o.innerText = a.name; + if (a.path === msg.selected) o.selected = true; + agentSel.appendChild(o); + }); + if (msg.selected && msg.selected !== 'none') { + vscode.postMessage({ type: 'getAgentContent', path: msg.selected }); + } + break; + case 'chronicleProjects': + designerSel.innerHTML = ''; + msg.value.projects.forEach(p => { + const o = document.createElement('option'); + o.value = p.id; + o.innerText = p.name; + o.title = p.recordRoot; + if (p.id === msg.value.activeProjectId) o.selected = true; + designerSel.appendChild(o); + }); + const newDesignerOpt = document.createElement('option'); + newDesignerOpt.value = 'new'; + newDesignerOpt.innerText = '+ Add Designer Project...'; + designerSel.appendChild(newDesignerOpt); + vscode.postMessage({ type: 'getChronicleRecords' }); + break; + case 'chronicleRecords': + chronicleRecordSel.innerHTML = ''; + if (!msg.value || msg.value.length === 0) { + const emptyRecordOpt = document.createElement('option'); + emptyRecordOpt.value = ''; + emptyRecordOpt.innerText = 'No records yet'; + chronicleRecordSel.appendChild(emptyRecordOpt); + break; + } + msg.value.forEach(record => { + const o = document.createElement('option'); + o.value = record.path; + o.innerText = record.relativePath; + o.title = record.path; + chronicleRecordSel.appendChild(o); + }); + break; + case 'agentContent': + agentPrompt.value = msg.value; + negativePrompt.value = msg.negativePrompt || ''; + break; + case 'agentDeleted': + agentConfigPanel.style.display = 'none'; + editMode = false; + editAgentBtn.classList.remove('active'); + agentPrompt.value = ''; + negativePrompt.value = ''; + break; + case 'error': + thinkingBar.classList.remove('active'); sendBtn.disabled = false; + addMsg(msg.value, 'error'); + break; + case 'lmStudioError': + showToast('LM Studio: ' + msg.value, 'warn'); + break; + case 'requiresApproval': + const box = document.createElement('div'); + box.className = 'approval-box'; + box.innerHTML = '
🛡️ 작업 승인 대기 중 (Action Approval Required)
' + + '
위의 변경 사항을 프로젝트에 반영할까요?
' + + '
' + + ' ' + + ' ' + + '
'; + chat.appendChild(box); + chat.scrollTop = chat.scrollHeight; + break; + } + }); + + function renderAttachments() { + attachPreview.innerHTML = ''; + if (pendingFiles.length === 0) { attachPreview.classList.remove('visible'); return; } + attachPreview.classList.add('visible'); + pendingFiles.forEach((f, i) => { + const chip = document.createElement('div'); chip.className = 'file-chip'; + chip.innerHTML = `📎 ${f.name} `; + attachPreview.appendChild(chip); + }); + } + window.removeFile = (i) => { + pendingFiles.splice(i, 1); + renderAttachments(); + // 파일 삭제 후 Draft State 재평가 + setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0); + }; + function processFiles(files) { + if (!files || files.length === 0) return; + + Array.from(files).forEach(file => { + const reader = new FileReader(); + reader.onload = () => { + const base64 = reader.result.split(',')[1]; + pendingFiles.push({ name: file.name, type: file.type, data: base64 }); + renderAttachments(); + setDraftActive(true); + }; + reader.readAsDataURL(file); + }); + showToast(`${files.length}개의 파일이 추가되었습니다.`, 'success'); + Sound.success(); + } + + attachBtn.onclick = () => fileInput.click(); + fileInput.onchange = () => { + processFiles(fileInput.files); + fileInput.value = ''; + }; + + // --- Drag and Drop Implementation --- + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + document.body.addEventListener(eventName, e => { + e.preventDefault(); + e.stopPropagation(); + }, false); + }); + + ['dragenter', 'dragover'].forEach(eventName => { + document.body.addEventListener(eventName, () => { + document.body.classList.add('drag-over'); + }, false); + }); + + ['dragleave', 'drop'].forEach(eventName => { + document.body.addEventListener(eventName, () => { + document.body.classList.remove('drag-over'); + }, false); + }); + + document.body.addEventListener('drop', e => { + const dt = e.dataTransfer; + const files = dt.files; + + // ⭐ Kodari PD 가이드 반영: Input 요소의 상태를 드롭된 파일로 강제 동기화 + if (files && files.length > 0) { + fileInput.files = files; // Input의 files 속성 업데이트 + console.log(`✅ [DnD] Input 상태 동기화 성공: ${files[0].name} 외 ${files.length - 1}개`); + } + + processFiles(files); + }, false); + + function send() { + const val = input.value.trim(); + if (!val && pendingFiles.length === 0) return; + addMsg(val || (pendingFiles.length > 0 ? `[Sent ${pendingFiles.length} files]` : ''), 'user'); + vscode.postMessage({ + type: 'prompt', + value: val, + model: modelSel.value, + internet: internetEnabled, + files: pendingFiles.length > 0 ? pendingFiles : undefined, + agentFile: agentSel.value === 'none' ? undefined : agentSel.value, + brainProfileId: brainSel.value && brainSel.value !== 'new' ? brainSel.value : undefined, + negativePrompt: negativePrompt.value.trim() || undefined, + secondBrainTrace: secondBrainTraceEnabled, + secondBrainTraceDebug + }); + input.value = ''; input.style.height = 'auto'; pendingFiles = []; renderAttachments(); + // 전송 완료 후 Draft State 리셋 + Stop 버튼 표시 + setDraftActive(false); + setGenerating(true); + thinkingBar.classList.add('active'); + + // Save state after sending + const currentState = vscode.getState() || { history: [] }; + currentState.history.push({ role: 'user', content: val }); + saveWebviewState(currentState.history); + } + + sendBtn.onclick = send; + input.addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + if (e.isComposing) return; + e.preventDefault(); + send(); + } + }); + let _lastActivityBump = 0; + const ACTIVITY_BUMP_INTERVAL_MS = 5000; + const bumpActivity = () => { + const now = Date.now(); + if (now - _lastActivityBump < ACTIVITY_BUMP_INTERVAL_MS) return; + _lastActivityBump = now; + vscode.postMessage({ type: 'activity' }); + }; + input.addEventListener('input', () => { + input.style.height = 'auto'; + input.style.height = input.scrollHeight + 'px'; + // Draft State: 내용이 있으면 cancelBtn 표시 + setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0); + bumpActivity(); + }); + + cancelBtn.onclick = () => clearDraft(); + stopBtn.onclick = () => { + vscode.postMessage({ type: 'stopGeneration' }); + setGenerating(false); + thinkingBar.classList.remove('active'); + showToast('■ 생성이 중단되었습니다.', 'warn'); + Sound.warn(); + }; + + const startNewChat = () => { Sound.play(660, 'sine', 0.1); vscode.postMessage({ type: 'newChat' }); }; + document.getElementById('newChatBtn').onclick = startNewChat; + document.getElementById('inputNewChatBtn').onclick = startNewChat; + + document.getElementById('settingsBtn').onclick = () => vscode.postMessage({ type: 'openSettings' }); + document.getElementById('internetBtn').onclick = () => { + internetEnabled = !internetEnabled; document.getElementById('internetBtn').classList.toggle('active', internetEnabled); + }; + document.getElementById('brainTraceBtn').onclick = () => { + secondBrainTraceEnabled = !secondBrainTraceEnabled; + const btn = document.getElementById('brainTraceBtn'); + btn.classList.toggle('active', secondBrainTraceEnabled); + btn.setAttribute('data-tooltip', secondBrainTraceEnabled ? 'Second Brain Trace Mode: On' : 'Second Brain Trace Mode: Off'); + saveUiState(); + }; + document.getElementById('brainTraceDebugBtn').onclick = () => { + secondBrainTraceDebug = !secondBrainTraceDebug; + const btn = document.getElementById('brainTraceDebugBtn'); + btn.classList.toggle('active', secondBrainTraceDebug); + btn.setAttribute('data-tooltip', secondBrainTraceDebug ? 'Second Brain Debug JSON: On' : 'Second Brain Debug JSON: Off'); + saveUiState(); + }; + + const syncBrain = () => { Sound.play(550, 'sine', 0.1); vscode.postMessage({ type: 'syncBrain' }); }; + document.getElementById('brainBtn').onclick = syncBrain; + saveWikiRawBtn.onclick = () => vscode.postMessage({ type: 'saveWikiRaw' }); + addBrainBtn.onclick = () => vscode.postMessage({ type: 'addBrain' }); + editBrainBtn.onclick = () => { + if (!brainSel.value || brainSel.value === 'new') return; + vscode.postMessage({ type: 'editBrain', id: brainSel.value }); + }; + deleteBrainBtn.onclick = () => { + if (!brainSel.value || brainSel.value === 'new') return; + vscode.postMessage({ type: 'deleteBrain', id: brainSel.value }); + }; + document.getElementById('inputSyncBtn').onclick = syncBrain; + document.getElementById('historyBtn').onclick = () => vscode.postMessage({ type: 'getSessions' }); + document.getElementById('historyBtn').addEventListener('click', () => historyOverlay.classList.add('visible')); + document.getElementById('closeHistoryBtn').onclick = () => historyOverlay.classList.remove('visible'); + const updateInputPlaceholder = () => { + if (typeof input !== 'undefined' && input) { + input.placeholder = `Ask ${modelSel ? modelSel.value : 'AI'}...`; + } + }; + + modelSel.onchange = () => { + const _selectedModel = modelSel.value; + // [State Persistence - Tier 2] 모델 변경 시 LocalStorage에 즉시 저장 (클라이언트 측 지속성) + try { + localStorage.setItem('g1nation_last_model', _selectedModel); + } catch(e) { + console.warn('[Astra] LocalStorage 저장 실패:', e); + } + // [State Persistence - Tier 1] VS Code 전역 설정에 동기화 (영구 저장) + vscode.postMessage({ type: 'model', value: _selectedModel }); + updateInputPlaceholder(); + // 상태 레이블 즉시 업데이트 + statusLabel.innerText = `Model: ${_selectedModel}`; + }; + brainSel.onchange = () => { + if (brainSel.value === 'new') { + vscode.postMessage({ type: 'addBrain' }); + } else { + vscode.postMessage({ type: 'setBrainProfile', id: brainSel.value }); + } + }; + + designerSel.onchange = () => { + if (designerSel.value === 'new') { + vscode.postMessage({ type: 'createChronicleProject' }); + } else { + vscode.postMessage({ type: 'setChronicleProject', id: designerSel.value }); + vscode.postMessage({ type: 'getChronicleRecords' }); + } + }; + + agentSel.onchange = () => { + if (agentSel.value !== 'none') { + vscode.postMessage({ type: 'getAgentContent', path: agentSel.value }); + // [State Persistence Fix] 에이전트 선택값을 즉시 백엔드에 저장 + vscode.postMessage({ type: 'saveAgentSelection', path: agentSel.value }); + if (editMode) agentConfigPanel.style.display = 'flex'; + } else { + agentConfigPanel.style.display = 'none'; + editMode = false; + editAgentBtn.classList.remove('active'); + agentPrompt.value = ''; + negativePrompt.value = ''; + // [State Persistence Fix] 에이전트 해제도 즉시 저장 + vscode.postMessage({ type: 'saveAgentSelection', path: 'none' }); + } + }; + + editAgentBtn.onclick = () => { + if (agentSel.value === 'none') return; + editMode = !editMode; + editAgentBtn.classList.toggle('active', editMode); + agentConfigPanel.style.display = editMode ? 'flex' : 'none'; + }; + + updateAgentBtn.onclick = () => { + if (agentSel.value !== 'none') { + vscode.postMessage({ + type: 'updateAgent', + path: agentSel.value, + content: agentPrompt.value, + negativePrompt: negativePrompt.value.trim() + }); + } + }; + + addAgentBtn.onclick = () => vscode.postMessage({ type: 'createAgent' }); + deleteAgentBtn.onclick = () => { + if (agentSel.value === 'none') return; + vscode.postMessage({ type: 'deleteAgent', path: agentSel.value }); + }; + + document.getElementById('addDesignerBtn').onclick = () => vscode.postMessage({ type: 'createChronicleProject' }); + document.getElementById('openDesignerBtn').onclick = () => vscode.postMessage({ type: 'openChronicleFolder' }); + document.getElementById('refreshChronicleRecordsBtn').onclick = () => vscode.postMessage({ type: 'getChronicleRecords' }); + document.getElementById('openChronicleRecordBtn').onclick = () => { + if (!chronicleRecordSel.value) return; + vscode.postMessage({ type: 'openChronicleRecord', path: chronicleRecordSel.value }); + }; + + vscode.postMessage({ type: 'getModels' }); + vscode.postMessage({ type: 'getAgents' }); + vscode.postMessage({ type: 'getChronicleProjects' }); + vscode.postMessage({ type: 'getChronicleRecords' }); + vscode.postMessage({ type: 'ready' }); + + // --- Proactive Behavioral Tracking --- + let hoverTimer = null; + const trackBehavior = (elementId, context) => { + const el = document.getElementById(elementId); + if (!el) return; + el.addEventListener('mouseenter', () => { + hoverTimer = setTimeout(() => { + vscode.postMessage({ type: 'proactiveTrigger', context: context }); + }, 5000); // 5 seconds threshold + }); + el.addEventListener('mouseleave', () => { + if (hoverTimer) clearTimeout(hoverTimer); + }); + }; + + trackBehavior('settingsBtn', 'settings_exploration'); + trackBehavior('brainBtn', 'brain_sync_exploration'); + trackBehavior('agentSel', 'agent_selection_exploration'); diff --git a/package-lock.json b/package-lock.json index 9c8b8ff..48fee06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,11 @@ "version": "2.80.18", "license": "MIT", "dependencies": { - "marked": "^18.0.2", + "@lmstudio/sdk": "^1.5.0", "pdf-parse": "^2.4.5" }, "devDependencies": { "@types/jest": "^29.5.14", - "@types/marked": "^5.0.2", "@types/node": "18.x", "@types/vscode": "^1.80.0", "@vercel/ncc": "^0.38.4", @@ -1334,6 +1333,28 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lmstudio/lms-isomorphic": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@lmstudio/lms-isomorphic/-/lms-isomorphic-0.4.6.tgz", + "integrity": "sha512-v0LIjXKnDe3Ff3XZO5eQjlVxTjleUHXaom14MV7QU9bvwaoo3l5p71+xJ3mmSaqZq370CQ6pTKCn1Bb7Jf+VwQ==", + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.16.0" + } + }, + "node_modules/@lmstudio/sdk": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@lmstudio/sdk/-/sdk-1.5.0.tgz", + "integrity": "sha512-fdY12x4hb14PEjYijh7YeCqT1ZDY5Ok6VR4l4+E/dI+F6NW8oB+P83Sxed5vqE4XgTzbgyPuSR2ZbMNxxF+6jA==", + "license": "Apache-2.0", + "dependencies": { + "@lmstudio/lms-isomorphic": "^0.4.6", + "chalk": "^4.1.2", + "jsonschema": "^1.5.0", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.5" + } + }, "node_modules/@napi-rs/canvas": { "version": "0.1.80", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", @@ -1638,13 +1659,6 @@ "pretty-format": "^29.0.0" } }, - "node_modules/@types/marked": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", - "integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "18.19.130", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", @@ -1726,7 +1740,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2031,7 +2044,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -2114,7 +2126,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2127,7 +2138,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -2591,7 +2601,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3483,6 +3492,15 @@ "node": ">=6" } }, + "node_modules/jsonschema": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.5.0.tgz", + "integrity": "sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -3586,18 +3604,6 @@ "tmpl": "1.0.5" } }, - "node_modules/marked": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.2.tgz", - "integrity": "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -4230,7 +4236,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -4542,6 +4547,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -4600,6 +4626,24 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 2d641fc..6ab869f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.80.18", + "version": "2.80.19", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -138,6 +138,17 @@ "default": 300, "description": "Request timeout in seconds. Default: 300" }, + "g1nation.lmStudio.idleTimeoutMs": { + "type": "number", + "default": 300000, + "minimum": 0, + "description": "Auto-eject the loaded LM Studio model after this many milliseconds of inactivity. Set to 0 to disable. Default: 300000 (5 minutes)." + }, + "g1nation.lmStudio.autoLoadOnSelect": { + "type": "boolean", + "default": true, + "description": "Automatically load LM Studio models into memory when selected from the Astra sidebar." + }, "g1nation.localBrainPath": { "type": "string", "default": "", @@ -216,7 +227,6 @@ }, "devDependencies": { "@types/jest": "^29.5.14", - "@types/marked": "^5.0.2", "@types/node": "18.x", "@types/vscode": "^1.80.0", "@vercel/ncc": "^0.38.4", @@ -226,7 +236,7 @@ "typescript": "^5.1.3" }, "dependencies": { - "marked": "^18.0.2", + "@lmstudio/sdk": "^1.5.0", "pdf-parse": "^2.4.5" } } diff --git a/src/agent.ts b/src/agent.ts index 6a8fd7a..52aa1f0 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -53,6 +53,14 @@ export interface ChatMessage { type HistoryChangeListener = (history: ChatMessage[]) => void | Promise; +export interface AgentExecutorOptions { + /** Hooks fired around any LLM streaming run so external systems (LM Studio idle eject) can pause/resume. */ + onStreamLifecycle?: { + start: () => void; + end: () => void; + }; +} + // --- Agent Roles & Workflows --- export type AgentRole = 'planner' | 'researcher' | 'writer'; type LocalProjectIntent = 'review-evaluation' | 'knowledge-creation' | 'implementation' | 'documentation' | 'thinking' | 'general'; @@ -86,9 +94,13 @@ export class AgentExecutor { private retrievalOrchestrator: RetrievalOrchestrator; private currentTaskId: string = 'default_session'; + private readonly options: AgentExecutorOptions; + constructor( - private context: vscode.ExtensionContext + private context: vscode.ExtensionContext, + options: AgentExecutorOptions = {} ) { + this.options = options; this.transactionManager = new TransactionManager(); this.sessionManager = new SessionManager(this.context); this.statusBarManager = new StatusBarManager(); @@ -454,7 +466,10 @@ export class AgentExecutor { const reader = response.body?.getReader(); if (!reader) throw new Error("Response body is not readable."); - if (loopDepth === 0) this.webview.postMessage({ type: 'streamStart' }); + if (loopDepth === 0) { + this.webview.postMessage({ type: 'streamStart' }); + this.options.onStreamLifecycle?.start(); + } let buffer = ''; const decoder = new TextDecoder(); @@ -618,6 +633,7 @@ export class AgentExecutor { } if (loopDepth === 0 && !this.isStaleRun(runId)) { this.webview.postMessage({ type: 'streamEnd' }); + this.options.onStreamLifecycle?.end(); } } } @@ -634,6 +650,7 @@ export class AgentExecutor { this.statusBarManager.updateStatus(AgentStatus.Thinking, 'Multi-Agent Workflow Running'); this.webview.postMessage({ type: 'streamStart' }); + this.options.onStreamLifecycle?.start(); try { let brainContext = 'No specific context available'; @@ -693,6 +710,8 @@ export class AgentExecutor { value: `### ${friendly.title}\n\n**상태:** ${friendly.message}\n\n**해결 방법:** ${friendly.action}` }); this.statusBarManager.updateStatus(AgentStatus.Idle, 'Error occurred'); + } finally { + this.options.onStreamLifecycle?.end(); } } diff --git a/src/extension.ts b/src/extension.ts index 3df19ba..4429d84 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,14 +2,15 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; // axios removed in favor of native fetch -import { - _getBrainDir, +import { + _getBrainDir, _isBrainDirExplicitlySet, findBrainFiles, SYSTEM_PROMPT, buildApiUrl, logError, - logInfo + logInfo, + resolveEngine } from './utils'; import { getConfig, validateConfig } from './config'; import { AgentExecutor } from './agent'; @@ -17,6 +18,11 @@ import { BridgeServer } from './bridge'; import { SidebarChatProvider } from './sidebarProvider'; import { HealthCheckMonitor } from './core/health'; import { initAstraPathResolver } from './core/astraPath'; +import { LMStudioClient } from './lmstudio/client'; +import { ActivityTracker } from './lmstudio/activityTracker'; +import { ModelLifecycleManager } from './lmstudio/lifecycleManager'; + +let _lifecycleManager: ModelLifecycleManager | undefined; /** * Astra Extension Entry Point @@ -40,12 +46,52 @@ export async function activate(context: vscode.ExtensionContext) { // 1. Ensure Brain Directory await _ensureBrainDir(context); - - // 2. Initialize Agent Executor - const agent = new AgentExecutor(context); - // 3. Initialize Sidebar Provider - const provider = new SidebarChatProvider(context.extensionUri, context, agent); + // 2. Initialize LM Studio Lifecycle Subsystem + let provider: SidebarChatProvider | undefined; + const initialUrl = getConfig().ollamaUrl; + const activityTracker = new ActivityTracker(); + const lmStudioClient = new LMStudioClient(initialUrl); + const lifecycle = new ModelLifecycleManager({ + client: lmStudioClient, + activity: activityTracker, + getConfig: () => { + const cfg = vscode.workspace.getConfiguration('g1nation'); + return { + idleTimeoutMs: cfg.get('lmStudio.idleTimeoutMs', 300000), + autoLoadOnSelect: cfg.get('lmStudio.autoLoadOnSelect', true), + }; + }, + notifyError: (msg) => provider?.postLmStudioError(msg), + initialEngine: resolveEngine(initialUrl), + }); + _lifecycleManager = lifecycle; + context.subscriptions.push({ dispose: () => activityTracker.dispose() }); + context.subscriptions.push({ dispose: () => lifecycle.dispose() }); + + // React to engine URL changes — re-target the SDK and reset state. + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (!e.affectsConfiguration('g1nation.ollamaUrl')) return; + const newUrl = vscode.workspace.getConfiguration('g1nation').get('ollamaUrl', ''); + lmStudioClient.setBaseUrl(newUrl); + lifecycle.setEngine(resolveEngine(newUrl)); + }) + ); + + // 3. Initialize Agent Executor (with stream lifecycle hooks) + const agent = new AgentExecutor(context, { + onStreamLifecycle: { + start: () => lifecycle.onStreamStart(), + end: () => lifecycle.onStreamEnd(), + }, + }); + + // 4. Initialize Sidebar Provider + provider = new SidebarChatProvider(context.extensionUri, context, agent, { + lifecycle, + activity: activityTracker, + }); context.subscriptions.push( vscode.window.registerWebviewViewProvider(SidebarChatProvider.viewType, provider) ); @@ -85,8 +131,16 @@ export async function activate(context: vscode.ExtensionContext) { } } -export function deactivate() { +export async function deactivate() { HealthCheckMonitor.dispose(); + if (_lifecycleManager) { + try { + await _lifecycleManager.disposeAndUnload(2000); + } catch (e) { + logError('Lifecycle dispose during deactivate failed.', e); + } + _lifecycleManager = undefined; + } } async function runInitialSetup(context: vscode.ExtensionContext) { diff --git a/src/features/secondBrainTrace.ts b/src/features/secondBrainTrace.ts index f091363..681c196 100644 --- a/src/features/secondBrainTrace.ts +++ b/src/features/secondBrainTrace.ts @@ -85,7 +85,12 @@ export function buildSecondBrainTrace(userQuery: string, brainRoot: string, opti const terms = tokenize(retrievalQuery); const knowledgeSlots = buildKnowledgeSlots(query, queryIntent); const targetProject = inferTargetProject(query); - const scored = files.map((file) => scoreFile(file, brainRoot, terms, queryIntent, targetProject)) + + // Read each file from disk only once per request and reuse the parsed scan + // for every (query terms, slot terms…) re-scoring pass below. + const scans = files.map((file) => scanFile(file, brainRoot)); + + const scored = scans.map((scan) => scoreScan(scan, terms, queryIntent, targetProject)) .filter((doc) => doc.score >= 0.25) .sort((a, b) => b.score - a.score) .slice(0, options.limit || (knowledgeSlots.length > 0 ? 8 : 5)); @@ -94,9 +99,9 @@ export function buildSecondBrainTrace(userQuery: string, brainRoot: string, opti const slotDocByPath = new Map(); const slotSelections = knowledgeSlots.map((slot) => { const slotTerms = tokenize(slot.retrievalQuery); - const slotCandidates = files - .map((file) => { - const doc = scoreFile(file, brainRoot, slotTerms, queryIntent, targetProject); + const slotCandidates = scans + .map((scan) => { + const doc = scoreScan(scan, slotTerms, queryIntent, targetProject); // 슬롯 ID와 문서 디렉토리명 매칭 보너스 (e.g. ontology 슬롯 → Ontology/ 디렉토리) const dirName = path.dirname(doc.path).toLowerCase(); if (dirName.includes(slot.id.toLowerCase())) { @@ -567,10 +572,21 @@ function inferTargetProject(query: string): string | undefined { return namedProject?.[1]?.toLowerCase(); } -function scoreFile(file: string, brainRoot: string, terms: string[], intent: SecondBrainQueryIntent, targetProject?: string): SecondBrainTraceDocument { +interface FileScan { + file: string; + relative: string; + title: string; + titleWithPath: string; + content: string; + lower: string; + sourceType: SecondBrainSourceType; + knowledgeRole: SecondBrainKnowledgeRole; + documentProject: string | undefined; +} + +function scanFile(file: string, brainRoot: string): FileScan { const relative = path.relative(brainRoot, file); const title = path.basename(file, path.extname(file)); - const basename = relative.toLowerCase(); let content = ''; try { content = fs.readFileSync(file, 'utf8'); @@ -579,37 +595,36 @@ function scoreFile(file: string, brainRoot: string, terms: string[], intent: Sec } const sourceType = classifySourceType(relative, content); const knowledgeRole = classifyKnowledgeRole(relative, content, sourceType); - const lower = content.toLowerCase(); const documentProject = inferDocumentProject(relative, lower); - const projectMatchesTarget = !targetProject || !documentProject || documentProject === targetProject; - const canSupportProjectClaim = projectMatchesTarget && (sourceType === 'Project Evidence' || sourceType === 'User Decision'); - let score = pathPriority(relative, intent); + const titleWithPath = `${relative.replace(/[\\/]/g, ' ')} ${title}`; + return { file, relative, title, titleWithPath, content, lower, sourceType, knowledgeRole, documentProject }; +} + +function scoreScan(scan: FileScan, terms: string[], intent: SecondBrainQueryIntent, targetProject?: string): SecondBrainTraceDocument { + const projectMatchesTarget = !targetProject || !scan.documentProject || scan.documentProject === targetProject; + const canSupportProjectClaim = projectMatchesTarget && (scan.sourceType === 'Project Evidence' || scan.sourceType === 'User Decision'); + let score = pathPriority(scan.relative, intent); if (targetProject) { - score += projectRelevanceScore(relative, lower, targetProject, documentProject); + score += projectRelevanceScore(scan.relative, scan.lower, targetProject, scan.documentProject); } const expandedTerms = expandQuery(terms); - // 디렉토리 경로를 title에 포함하여 카테고리 키워드 매칭 향상 (e.g. Ontology/ → 'ontology' 토큰) - const titleWithPath = `${relative.replace(/[\\/]/g, ' ')} ${title}`; - const scoredTfIdf = scoreTfIdf(expandedTerms, [{ title: titleWithPath, content, lastModified: Date.now() }])[0]; - + const scoredTfIdf = scoreTfIdf(expandedTerms, [{ title: scan.titleWithPath, content: scan.content, lastModified: Date.now() }])[0]; score += scoredTfIdf.score; - - if (knowledgeRole === 'routing-hint') { + if (scan.knowledgeRole === 'routing-hint') { score -= 8; } - - const finalExcerpt = extractBestExcerpt(content, expandedTerms, 420); + const finalExcerpt = extractBestExcerpt(scan.content, expandedTerms, 420); return { - title, - path: relative, - absolutePath: file, + title: scan.title, + path: scan.relative, + absolutePath: scan.file, // sqrt 정규화: 동의어 확장으로 분모가 과도하게 커지는 것을 방지 score: Number((Math.max(score, 0) / Math.max(Math.sqrt(expandedTerms.length), 1)).toFixed(2)), excerpt: summarizeText(finalExcerpt, 420), - sourceType, - knowledgeRole, + sourceType: scan.sourceType, + knowledgeRole: scan.knowledgeRole, canSupportProjectClaim, warning: canSupportProjectClaim ? undefined : '이 문서는 현재 프로젝트의 실제 구현 근거가 아닙니다.', usedInAnswer: false, diff --git a/src/lmstudio/activityTracker.ts b/src/lmstudio/activityTracker.ts new file mode 100644 index 0000000..3d09775 --- /dev/null +++ b/src/lmstudio/activityTracker.ts @@ -0,0 +1,19 @@ +import * as vscode from 'vscode'; + +export interface IActivityTracker { + onActivity: vscode.Event; + bump(): void; +} + +export class ActivityTracker implements IActivityTracker { + private readonly _emitter = new vscode.EventEmitter(); + public readonly onActivity = this._emitter.event; + + bump(): void { + this._emitter.fire(); + } + + dispose(): void { + this._emitter.dispose(); + } +} diff --git a/src/lmstudio/client.ts b/src/lmstudio/client.ts new file mode 100644 index 0000000..16495a9 --- /dev/null +++ b/src/lmstudio/client.ts @@ -0,0 +1,100 @@ +import { LMStudioClient as SDKClient } from '@lmstudio/sdk'; +import { logError, logInfo } from '../utils'; + +export interface ILMStudioClient { + load(modelKey: string, signal?: AbortSignal): Promise; + unload(modelKey: string): Promise; + listLoaded(): Promise; + isReachable(): Promise; + setBaseUrl(httpBaseUrl: string): void; +} + +export class LMStudioLifecycleError extends Error { + constructor(message: string, public readonly cause?: unknown) { + super(message); + this.name = 'LMStudioLifecycleError'; + } +} + +export function httpToWebSocketUrl(httpBaseUrl: string): string | undefined { + const trimmed = (httpBaseUrl || '').trim(); + if (!trimmed) return undefined; + try { + const url = new URL(trimmed); + if (url.protocol === 'http:') url.protocol = 'ws:'; + else if (url.protocol === 'https:') url.protocol = 'wss:'; + else if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return undefined; + if (url.pathname.endsWith('/v1')) url.pathname = url.pathname.slice(0, -3); + if (url.pathname.endsWith('/api')) url.pathname = url.pathname.slice(0, -4); + const out = url.toString().replace(/\/+$/, ''); + return out; + } catch { + return undefined; + } +} + +export class LMStudioClient implements ILMStudioClient { + private _sdk: SDKClient | undefined; + private _wsUrl: string | undefined; + + constructor(httpBaseUrl: string) { + this.setBaseUrl(httpBaseUrl); + } + + setBaseUrl(httpBaseUrl: string): void { + const ws = httpToWebSocketUrl(httpBaseUrl); + if (ws !== this._wsUrl) { + this._wsUrl = ws; + this._sdk = undefined; + } + } + + private getSdk(): SDKClient { + if (!this._sdk) { + this._sdk = new SDKClient(this._wsUrl ? { baseUrl: this._wsUrl } : {}); + } + return this._sdk; + } + + async load(modelKey: string, signal?: AbortSignal): Promise { + try { + await this.getSdk().llm.load(modelKey, signal ? { signal } : undefined); + logInfo('LM Studio model loaded.', { modelKey }); + } catch (e: any) { + const msg = e?.message ?? String(e); + throw new LMStudioLifecycleError(`Failed to load LM Studio model "${modelKey}": ${msg}`, e); + } + } + + async unload(modelKey: string): Promise { + try { + await this.getSdk().llm.unload(modelKey); + logInfo('LM Studio model unloaded.', { modelKey }); + } catch (e: any) { + const msg = e?.message ?? String(e); + throw new LMStudioLifecycleError(`Failed to unload LM Studio model "${modelKey}": ${msg}`, e); + } + } + + async listLoaded(): Promise { + try { + const items: any[] = await this.getSdk().llm.listLoaded(); + return items + .map((m) => m?.identifier ?? m?.modelKey ?? m?.path ?? null) + .filter((id): id is string => typeof id === 'string' && id.length > 0); + } catch (e: any) { + const msg = e?.message ?? String(e); + throw new LMStudioLifecycleError(`Failed to list loaded LM Studio models: ${msg}`, e); + } + } + + async isReachable(): Promise { + try { + await this.getSdk().llm.listLoaded(); + return true; + } catch (e: any) { + logError('LM Studio not reachable.', { error: e?.message ?? String(e) }); + return false; + } + } +} diff --git a/src/lmstudio/lifecycleManager.ts b/src/lmstudio/lifecycleManager.ts new file mode 100644 index 0000000..e434494 --- /dev/null +++ b/src/lmstudio/lifecycleManager.ts @@ -0,0 +1,247 @@ +import type { ILMStudioClient } from './client'; +import type { IActivityTracker } from './activityTracker'; +import type { EngineKind } from '../utils'; +import { logError, logInfo } from '../utils'; + +export type LifecycleState = 'idle' | 'loading' | 'loaded' | 'streaming' | 'unloading'; + +export interface LifecycleConfig { + idleTimeoutMs: number; + autoLoadOnSelect: boolean; +} + +export interface LifecycleManagerDeps { + client: ILMStudioClient; + activity: IActivityTracker; + getConfig: () => LifecycleConfig; + notifyError?: (msg: string) => void; + /** Debounce window for rapid model switches. Default 300ms. Use 0 in tests for synchronous behavior. */ + switchDebounceMs?: number; + /** Initial engine. Default 'lmstudio'. */ + initialEngine?: EngineKind; +} + +export class ModelLifecycleManager { + private state: LifecycleState = 'idle'; + private currentModel: string | null = null; + private pendingModel: string | null = null; + private engine: EngineKind; + + private idleTimer: ReturnType | undefined; + private switchDebounce: ReturnType | undefined; + private loadAbort: AbortController | undefined; + + private readonly activitySub: { dispose(): void }; + private disposed = false; + + constructor(private readonly deps: LifecycleManagerDeps) { + this.engine = deps.initialEngine ?? 'lmstudio'; + this.activitySub = deps.activity.onActivity(() => this.onActivity()); + } + + setEngine(engine: EngineKind): void { + if (engine === this.engine) return; + const wasLmStudio = this.engine === 'lmstudio'; + this.engine = engine; + if (wasLmStudio && engine !== 'lmstudio') { + this.clearIdleTimer(); + this.cancelPendingSwitch(); + this.cancelLoad(); + this.state = 'idle'; + this.currentModel = null; + this.pendingModel = null; + } + } + + onModelSelected(modelKey: string): void { + if (this.disposed) return; + if (this.engine !== 'lmstudio') return; + if (!this.deps.getConfig().autoLoadOnSelect) return; + const trimmed = (modelKey || '').trim(); + if (!trimmed) return; + + // Mid-stream: queue the latest selection, apply on streamEnd. + if (this.state === 'streaming') { + this.pendingModel = trimmed; + return; + } + + // Same model already in flight or active — keep timer fresh, no reload. + if ((this.state === 'loaded' || this.state === 'loading') && this.currentModel === trimmed) { + if (this.state === 'loaded') this.resetIdleTimer(); + return; + } + + this.cancelPendingSwitch(); + const delay = this.deps.switchDebounceMs ?? 300; + if (delay <= 0) { + void this.doSwitch(trimmed); + return; + } + this.switchDebounce = setTimeout(() => { + this.switchDebounce = undefined; + void this.doSwitch(trimmed); + }, delay); + } + + onStreamStart(): void { + if (this.disposed) return; + if (this.engine !== 'lmstudio') return; + this.clearIdleTimer(); + if (this.state === 'loaded') this.state = 'streaming'; + } + + onStreamEnd(): void { + if (this.disposed) return; + if (this.engine !== 'lmstudio') return; + if (this.state === 'streaming') { + this.state = 'loaded'; + if (this.pendingModel && this.pendingModel !== this.currentModel) { + const next = this.pendingModel; + this.pendingModel = null; + void this.doSwitch(next); + } else { + this.pendingModel = null; + this.resetIdleTimer(); + } + } + } + + /** Best-effort eject before extension shutdown. Bounded by timeoutMs. */ + async disposeAndUnload(timeoutMs: number = 2000): Promise { + if (this.disposed) return; + this.disposed = true; + this.clearIdleTimer(); + this.cancelPendingSwitch(); + this.cancelLoad(); + this.activitySub.dispose(); + + const shouldUnload = + this.engine === 'lmstudio' && + (this.state === 'loaded' || this.state === 'streaming') && + this.currentModel !== null; + if (!shouldUnload) { + this.state = 'idle'; + this.currentModel = null; + return; + } + + const target = this.currentModel as string; + this.state = 'unloading'; + try { + await Promise.race([ + this.deps.client.unload(target), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`unload timed out after ${timeoutMs}ms`)), timeoutMs) + ), + ]); + } catch (e: any) { + logError('LM Studio unload during dispose failed.', { model: target, error: e?.message ?? String(e) }); + } + this.state = 'idle'; + this.currentModel = null; + } + + /** vscode.Disposable shape — fire and forget. */ + dispose(): void { + void this.disposeAndUnload(); + } + + // Test/inspection helpers + public _getState(): LifecycleState { return this.state; } + public _getCurrentModel(): string | null { return this.currentModel; } + public _hasIdleTimer(): boolean { return this.idleTimer !== undefined; } + + // ---------- internals ---------- + + private onActivity(): void { + if (this.disposed) return; + if (this.engine !== 'lmstudio') return; + if (this.state !== 'loaded') return; + this.resetIdleTimer(); + } + + private clearIdleTimer(): void { + if (this.idleTimer) { + clearTimeout(this.idleTimer); + this.idleTimer = undefined; + } + } + + private cancelPendingSwitch(): void { + if (this.switchDebounce) { + clearTimeout(this.switchDebounce); + this.switchDebounce = undefined; + } + } + + private resetIdleTimer(): void { + this.clearIdleTimer(); + const ms = this.deps.getConfig().idleTimeoutMs; + if (!Number.isFinite(ms) || ms <= 0) return; + this.idleTimer = setTimeout(() => { + this.idleTimer = undefined; + void this.doIdleEject(); + }, ms); + } + + private async doIdleEject(): Promise { + if (this.state !== 'loaded' || !this.currentModel) return; + const target = this.currentModel; + this.state = 'unloading'; + try { + await this.deps.client.unload(target); + logInfo('LM Studio model auto-ejected after idle.', { model: target }); + } catch (e: any) { + logError('LM Studio auto-eject failed.', { model: target, error: e?.message ?? String(e) }); + this.deps.notifyError?.(`LM Studio auto-eject failed: ${e?.message ?? e}`); + } + this.state = 'idle'; + this.currentModel = null; + } + + private cancelLoad(): void { + if (this.loadAbort) { + try { this.loadAbort.abort(); } catch { /* noop */ } + this.loadAbort = undefined; + } + } + + private async doSwitch(modelKey: string): Promise { + if (this.disposed) return; + if (this.engine !== 'lmstudio') return; + + this.cancelLoad(); + this.clearIdleTimer(); + + if (this.state === 'loaded' && this.currentModel && this.currentModel !== modelKey) { + const prev = this.currentModel; + this.state = 'unloading'; + try { + await this.deps.client.unload(prev); + } catch (e: any) { + logError('LM Studio unload before switch failed.', { prev, error: e?.message ?? String(e) }); + } + this.currentModel = null; + } + + this.state = 'loading'; + this.currentModel = modelKey; + const ac = new AbortController(); + this.loadAbort = ac; + try { + await this.deps.client.load(modelKey, ac.signal); + if (this.loadAbort !== ac) return; // superseded by a newer switch + this.loadAbort = undefined; + this.state = 'loaded'; + this.resetIdleTimer(); + } catch (e: any) { + if (ac.signal.aborted) return; // superseded — newer switch owns state + logError('LM Studio model load failed.', { model: modelKey, error: e?.message ?? String(e) }); + this.deps.notifyError?.(`LM Studio load failed: ${e?.message ?? e}`); + if (this.loadAbort === ac) this.loadAbort = undefined; + this.state = 'idle'; + this.currentModel = null; + } + } +} diff --git a/src/sidebar/agentHandlers.ts b/src/sidebar/agentHandlers.ts new file mode 100644 index 0000000..20a921a --- /dev/null +++ b/src/sidebar/agentHandlers.ts @@ -0,0 +1,32 @@ +import { SidebarChatProvider } from '../sidebarProvider'; +import { logInfo } from '../utils'; + +/** + * Handles agent-skill messages: the per-conversation agent picker, agent CRUD, + * and persisting the user's last selected agent. + */ +export async function handleAgentMessage(provider: SidebarChatProvider, data: any): Promise { + switch (data.type) { + case 'getAgents': + await provider._sendAgentsList(); + return true; + case 'createAgent': + await provider._createAgent(); + return true; + case 'getAgentContent': + await provider._sendAgentContent(data.path); + return true; + case 'updateAgent': + await provider._updateAgent(data.path, data.content, data.negativePrompt); + return true; + case 'deleteAgent': + await provider._deleteAgent(data.path); + return true; + case 'saveAgentSelection': + await provider._context.globalState.update(SidebarChatProvider.lastAgentStateKey, data.path || 'none'); + logInfo(`Agent selection saved: ${data.path}`); + return true; + default: + return false; + } +} diff --git a/src/sidebar/brainHandlers.ts b/src/sidebar/brainHandlers.ts new file mode 100644 index 0000000..091b988 --- /dev/null +++ b/src/sidebar/brainHandlers.ts @@ -0,0 +1,33 @@ +import { SidebarChatProvider } from '../sidebarProvider'; + +/** + * Handles brain-profile / wiki sync messages from the sidebar webview. + */ +export async function handleBrainMessage(provider: SidebarChatProvider, data: any): Promise { + switch (data.type) { + case 'manageBrains': + await provider._manageBrains(); + return true; + case 'syncBrain': + await provider.syncBrain(); + await provider._sendBrainStatus(); + return true; + case 'addBrain': + await provider._addBrainProfile(); + return true; + case 'editBrain': + await provider._editBrainProfile(data.id); + return true; + case 'deleteBrain': + await provider._deleteBrainProfile(data.id); + return true; + case 'saveWikiRaw': + await provider._saveWikiRaw(); + return true; + case 'setBrainProfile': + await provider._setActiveBrainProfile(data.id); + return true; + default: + return false; + } +} diff --git a/src/sidebar/chatHandlers.ts b/src/sidebar/chatHandlers.ts new file mode 100644 index 0000000..bb6ab1e --- /dev/null +++ b/src/sidebar/chatHandlers.ts @@ -0,0 +1,99 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { SidebarChatProvider } from '../sidebarProvider'; +import { getActiveBrainProfile, logInfo } from '../utils'; + +/** + * Handles chat-domain messages: prompts, model selection, sessions, streaming control, + * generic webview transport (export, settings, addMessage), action approvals, and the + * cross-cutting `ready` bootstrap. + * + * Returns true when the message was handled by this domain, false otherwise — the + * caller chains domain handlers until one accepts the message. + */ +export async function handleChatMessage(provider: SidebarChatProvider, data: any): Promise { + switch (data.type) { + case 'prompt': + case 'promptWithFile': + provider._lmStudio?.activity.bump(); + await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false); + await provider._handlePrompt(data); + await provider._autoWriteChronicleAfterPrompt(); + await provider._saveCurrentSession(); + return true; + case 'activity': + provider._lmStudio?.activity.bump(); + return true; + case 'ready': + await provider._sendBrainStatus(); + await provider._sendBrainProfiles(); + await provider._sendSessionList(); + await provider._sendModels(); + await provider._sendChronicleProjects(); + await provider._restoreActiveSessionIntoView(); + return true; + case 'getModels': + await provider._sendModels(); + return true; + case 'getSessions': + await provider._sendSessionList(); + return true; + case 'newChat': + provider._currentSessionId = null; + provider._currentSessionBrainId = getActiveBrainProfile().id; + provider._agent.resetConversation(); + await provider._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null); + await provider._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null); + await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, true); + provider.clearChat(); + await provider._sendBrainStatus(); + return true; + case 'stopGeneration': + provider._agent.stop(); + return true; + case 'loadSession': + await provider._loadSession(data.id); + return true; + case 'deleteSession': + await provider._deleteSession(data.id); + return true; + case 'openSettings': + vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation'); + return true; + case 'addMessage': + provider._view?.webview.postMessage({ type: 'addMessage', role: data.role, value: data.value, rationale: data.rationale }); + return true; + case 'refreshModels': + await provider._sendModels(true); + return true; + case 'model': + await vscode.workspace.getConfiguration('g1nation').update('defaultModel', data.value, vscode.ConfigurationTarget.Global); + logInfo(`Default model updated to: ${data.value}`); + provider._lmStudio?.lifecycle.onModelSelected(data.value); + return true; + case 'proactiveTrigger': + await provider._handleProactiveSuggestion(data.context); + return true; + case 'exportResponse': { + const workspacePath = vscode.workspace.workspaceFolders?.[0].uri.fsPath || ''; + const defaultPath = path.join(workspacePath, 'g1_response.md'); + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(defaultPath), + filters: { 'Markdown': ['md'] } + }); + if (uri) { + await vscode.workspace.fs.writeFile(uri, Buffer.from(data.text, 'utf8')); + vscode.window.showInformationMessage(`✅ Exported to ${path.basename(uri.fsPath)}`); + } + return true; + } + case 'approveAction': + await provider._agent.approveTransaction(); + return true; + case 'rejectAction': + await provider._agent.rejectTransaction(); + return true; + default: + return false; + } +} diff --git a/src/sidebar/chronicleHandlers.ts b/src/sidebar/chronicleHandlers.ts new file mode 100644 index 0000000..77e24b8 --- /dev/null +++ b/src/sidebar/chronicleHandlers.ts @@ -0,0 +1,52 @@ +import { SidebarChatProvider } from '../sidebarProvider'; + +/** + * Handles Project Chronicle messages: project CRUD, record listing/opening, + * and the various chronicle-write entry points (planning, discussion, decision, + * development, bug, retrospective). + */ +export async function handleChronicleMessage(provider: SidebarChatProvider, data: any): Promise { + switch (data.type) { + case 'getChronicleProjects': + await provider._sendChronicleProjects(); + return true; + case 'createChronicleProject': + await provider._createChronicleProject(); + return true; + case 'setChronicleProject': + await provider._setActiveChronicleProject(data.id); + return true; + case 'openChronicleFolder': + await provider._openChronicleFolder(); + return true; + case 'getChronicleRecords': + await provider._sendChronicleRecords(); + return true; + case 'openChronicleRecord': + await provider._openChronicleRecord(data.path); + return true; + case 'writeChroniclePlanning': + await provider._writeChroniclePlanningFromCurrentChat(); + return true; + case 'writeChronicleDiscussion': + await provider._writeChronicleDiscussionFromCurrentChat(); + return true; + case 'writeChronicleDecision': + await provider._writeChronicleDecisionFromInput(); + return true; + case 'writeChronicleDevelopment': + await provider._writeChronicleDevelopmentFromCurrentChat(); + return true; + case 'writeChronicleBug': + await provider._writeChronicleBugFromInput(); + return true; + case 'writeChronicleRetrospective': + await provider._writeChronicleRetrospectiveFromInput(); + return true; + case 'writeChronicleRecord': + await provider._writeChronicleRecord(data.recordType); + return true; + default: + return false; + } +} diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index d9332e2..ffc33f9 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -16,6 +16,17 @@ import { getConfig } from './config'; import { AgentExecutor, ChatMessage } from './agent'; import { BridgeInterface } from './bridge'; import { buildProjectChronicleGuardContext, ProjectChronicleManager, ProjectProfile } from './features/projectChronicle'; +import type { ModelLifecycleManager } from './lmstudio/lifecycleManager'; +import type { IActivityTracker } from './lmstudio/activityTracker'; +import { handleChatMessage } from './sidebar/chatHandlers'; +import { handleBrainMessage } from './sidebar/brainHandlers'; +import { handleChronicleMessage } from './sidebar/chronicleHandlers'; +import { handleAgentMessage } from './sidebar/agentHandlers'; + +export interface SidebarLmStudioDeps { + lifecycle: ModelLifecycleManager; + activity: IActivityTracker; +} interface LastVisibleChatSnapshot { history: ChatMessage[]; @@ -39,30 +50,38 @@ interface ChatSession { */ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeInterface { public static readonly viewType = 'g1nation-v2-view'; - private static readonly activeSessionStateKey = 'g1nation.activeSessionId'; - private static readonly lastVisibleChatStateKey = 'g1nation.lastVisibleChat'; - private static readonly blankChatStateKey = 'g1nation.blankChatActive'; - private static readonly lastAgentStateKey = 'g1nation.lastAgentPath'; - private static readonly chronicleProjectsStateKey = 'g1nation.chronicleProjects'; - private static readonly activeChronicleProjectStateKey = 'g1nation.activeChronicleProjectId'; - private static readonly lastAutoChronicleSignatureStateKey = 'g1nation.lastAutoChronicleSignature'; - private _view?: vscode.WebviewView; + static readonly activeSessionStateKey = 'g1nation.activeSessionId'; + static readonly lastVisibleChatStateKey = 'g1nation.lastVisibleChat'; + static readonly blankChatStateKey = 'g1nation.blankChatActive'; + static readonly lastAgentStateKey = 'g1nation.lastAgentPath'; + static readonly chronicleProjectsStateKey = 'g1nation.chronicleProjects'; + static readonly activeChronicleProjectStateKey = 'g1nation.activeChronicleProjectId'; + static readonly lastAutoChronicleSignatureStateKey = 'g1nation.lastAutoChronicleSignature'; + _view?: vscode.WebviewView; public brainEnabled = true; - private _currentSessionBrainId: string | null = null; - private _currentNegativePrompt: string = ''; - private readonly _chronicle = new ProjectChronicleManager(); - private _modelDiscoveryInFlight = false; + _currentSessionBrainId: string | null = null; + _currentNegativePrompt: string = ''; + readonly _chronicle = new ProjectChronicleManager(); + _modelDiscoveryInFlight = false; + _modelsCache: { url: string; models: string[]; online: boolean; fetchedAt: number } | null = null; + static readonly MODELS_CACHE_TTL_MS = 30000; constructor( - private readonly _extensionUri: vscode.Uri, - private readonly _context: vscode.ExtensionContext, - private readonly _agent: AgentExecutor + readonly _extensionUri: vscode.Uri, + readonly _context: vscode.ExtensionContext, + readonly _agent: AgentExecutor, + readonly _lmStudio?: SidebarLmStudioDeps ) { this._agent.setHistoryChangeListener((history) => { void this._persistLastVisibleChat(history); }); } + /** Surface LM Studio lifecycle errors (load/unload failures) to the chat UI as a non-fatal toast. */ + public postLmStudioError(message: string): void { + this._view?.webview.postMessage({ type: 'lmStudioError', value: message }); + } + public resolveWebviewView( webviewView: vscode.WebviewView, context: vscode.WebviewViewResolveContext, @@ -96,169 +115,17 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn void this._restoreActiveSessionIntoView(); webviewView.webview.onDidReceiveMessage(async (data) => { - switch (data.type) { - case 'prompt': - case 'promptWithFile': - await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, false); - await this._handlePrompt(data); - await this._autoWriteChronicleAfterPrompt(); - // After prompt, save the session automatically - await this._saveCurrentSession(); - break; - case 'ready': - await this._sendBrainStatus(); - await this._sendBrainProfiles(); - await this._sendSessionList(); - await this._sendModels(); - await this._sendChronicleProjects(); - await this._restoreActiveSessionIntoView(); - break; - case 'getModels': - await this._sendModels(); - break; - case 'getSessions': - await this._sendSessionList(); - break; - case 'getAgents': - await this._sendAgentsList(); - break; - case 'getChronicleProjects': - await this._sendChronicleProjects(); - break; - case 'createChronicleProject': - await this._createChronicleProject(); - break; - case 'setChronicleProject': - await this._setActiveChronicleProject(data.id); - break; - case 'openChronicleFolder': - await this._openChronicleFolder(); - break; - case 'getChronicleRecords': - await this._sendChronicleRecords(); - break; - case 'openChronicleRecord': - await this._openChronicleRecord(data.path); - break; - case 'writeChroniclePlanning': - await this._writeChroniclePlanningFromCurrentChat(); - break; - case 'writeChronicleDiscussion': - await this._writeChronicleDiscussionFromCurrentChat(); - break; - case 'writeChronicleDecision': - await this._writeChronicleDecisionFromInput(); - break; - case 'writeChronicleDevelopment': - await this._writeChronicleDevelopmentFromCurrentChat(); - break; - case 'writeChronicleBug': - await this._writeChronicleBugFromInput(); - break; - case 'writeChronicleRetrospective': - await this._writeChronicleRetrospectiveFromInput(); - break; - case 'writeChronicleRecord': - await this._writeChronicleRecord(data.recordType); - break; - case 'createAgent': - await this._createAgent(); - break; - case 'newChat': - this._currentSessionId = null; - this._currentSessionBrainId = getActiveBrainProfile().id; - this._agent.resetConversation(); - await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null); - await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null); - await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, true); - this.clearChat(); - await this._sendBrainStatus(); - break; - case 'stopGeneration': - this._agent.stop(); - break; - case 'loadSession': - await this._loadSession(data.id); - break; - case 'deleteSession': - await this._deleteSession(data.id); - break; - case 'openSettings': - vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation'); - break; - case 'manageBrains': - await this._manageBrains(); - break; - case 'syncBrain': - await this.syncBrain(); - await this._sendBrainStatus(); - break; - case 'addMessage': - this._view?.webview.postMessage({ type: 'addMessage', role: data.role, value: data.value, rationale: data.rationale }); - break; - case 'addBrain': - await this._addBrainProfile(); - break; - case 'editBrain': - await this._editBrainProfile(data.id); - break; - case 'deleteBrain': - await this._deleteBrainProfile(data.id); - break; - case 'saveWikiRaw': - await this._saveWikiRaw(); - break; - case 'setBrainProfile': - await this._setActiveBrainProfile(data.id); - break; - case 'getAgentContent': - await this._sendAgentContent(data.path); - break; - case 'updateAgent': - await this._updateAgent(data.path, data.content, data.negativePrompt); - break; - case 'deleteAgent': - await this._deleteAgent(data.path); - break; - case 'saveAgentSelection': - await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, data.path || 'none'); - logInfo(`Agent selection saved: ${data.path}`); - break; - case 'refreshModels': - await this._sendModels(); - break; - case 'model': - await vscode.workspace.getConfiguration('g1nation').update('defaultModel', data.value, vscode.ConfigurationTarget.Global); - logInfo(`Default model updated to: ${data.value}`); - break; - case 'proactiveTrigger': - await this._handleProactiveSuggestion(data.context); - break; - case 'exportResponse': - const workspacePath = vscode.workspace.workspaceFolders?.[0].uri.fsPath || ''; - const defaultPath = path.join(workspacePath, 'g1_response.md'); - const uri = await vscode.window.showSaveDialog({ - defaultUri: vscode.Uri.file(defaultPath), - filters: { 'Markdown': ['md'] } - }); - if (uri) { - await vscode.workspace.fs.writeFile(uri, Buffer.from(data.text, 'utf8')); - vscode.window.showInformationMessage(`✅ Exported to ${path.basename(uri.fsPath)}`); - } - break; - case 'approveAction': - await this._agent.approveTransaction(); - break; - case 'rejectAction': - await this._agent.rejectTransaction(); - break; - } + if (await handleChatMessage(this, data)) return; + if (await handleBrainMessage(this, data)) return; + if (await handleChronicleMessage(this, data)) return; + if (await handleAgentMessage(this, data)) return; + logInfo(`Unhandled sidebar message: ${data?.type}`); }); } - private _currentSessionId: string | null = null; + _currentSessionId: string | null = null; - private async _restoreActiveSessionIntoView() { + async _restoreActiveSessionIntoView() { if (!this._view) return; const blankChatActive = this._context.globalState.get(SidebarChatProvider.blankChatStateKey, false); @@ -297,7 +164,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private async _persistLastVisibleChat(history: ChatMessage[] = this._agent.getHistory()) { + async _persistLastVisibleChat(history: ChatMessage[] = this._agent.getHistory()) { if (history.length === 0) { await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null); return; @@ -313,7 +180,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, snapshot); } - private async _saveCurrentSession() { + async _saveCurrentSession() { const history = this._agent.getHistory(); if (history.length === 0) return; @@ -363,7 +230,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn await this._sendSessionList(); } - private async _sendSessionList() { + async _sendSessionList() { if (!this._view) return; const sessions = this._getSessions(); const list = sessions.map(s => ({ @@ -377,7 +244,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn this._view.webview.postMessage({ type: 'sessionList', value: list }); } - private async _loadSession(id: string, skipSessionListRefresh: boolean = false): Promise { + async _loadSession(id: string, skipSessionListRefresh: boolean = false): Promise { if (!id) { logError('Session load requested without an id.'); this._view?.webview.postMessage({ type: 'error', value: 'Chat session id is missing.' }); @@ -424,7 +291,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn return false; } - private async _deleteSession(id: string) { + async _deleteSession(id: string) { let sessions = this._getSessions(); sessions = sessions.filter(s => s.id !== id); await this._putSessions(sessions); @@ -439,7 +306,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn await this._sendSessionList(); } - private _getSessions(): ChatSession[] { + _getSessions(): ChatSession[] { const rawSessions = this._context.globalState.get('chat_sessions', []) || []; return rawSessions .map((session, index): ChatSession | null => { @@ -472,7 +339,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn .slice(0, 50); } - private _getSessionById(id: string): ChatSession | null { + _getSessionById(id: string): ChatSession | null { const rawSessions = this._context.globalState.get('chat_sessions', []) || []; const raw = rawSessions.find((session: any) => String(session?.id) === String(id)); if (!raw) return null; @@ -501,11 +368,11 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn }; } - private async _putSessions(sessions: ChatSession[]) { + async _putSessions(sessions: ChatSession[]) { await this._context.globalState.update('chat_sessions', sessions.slice(0, 50)); } - private async _sendBrainStatus() { + async _sendBrainStatus() { if (!this._view) return; const activeBrain = getActiveBrainProfile(); const brainDir = activeBrain.localBrainPath; @@ -522,7 +389,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn }); } - private async _sendBrainProfiles() { + async _sendBrainProfiles() { if (!this._view) return; const activeBrain = getActiveBrainProfile(); this._currentSessionBrainId = this._currentSessionBrainId || activeBrain.id; @@ -542,7 +409,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn }); } - private _postBrainProfiles(profiles: any[], activeBrainId: string) { + _postBrainProfiles(profiles: any[], activeBrainId: string) { if (!this._view) return; this._view.webview.postMessage({ type: 'brainProfiles', @@ -559,7 +426,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn }); } - private async _setActiveBrainProfile(profileId: string, silent: boolean = false) { + async _setActiveBrainProfile(profileId: string, silent: boolean = false) { const profiles = getBrainProfiles(); const nextProfile = profiles.find((profile) => profile.id === profileId) || profiles[0]; if (!nextProfile) return; @@ -574,7 +441,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private async _manageBrains() { + async _manageBrains() { const activeBrain = getActiveBrainProfile(); const choice = await vscode.window.showQuickPick([ { @@ -611,7 +478,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation.brainProfiles'); } - private async _addBrainProfile() { + async _addBrainProfile() { const selected = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, @@ -672,7 +539,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn this.injectSystemMessage(`**[Brain Added]** ${name.trim()}\n\`${folder}\``); } - private async _editBrainProfile(profileId?: string) { + async _editBrainProfile(profileId?: string) { const currentProfiles = getBrainProfiles(); const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile(); if (!target) return; @@ -721,7 +588,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn this.injectSystemMessage(`**[Brain Updated]** ${name.trim()}\n\`${folder.trim()}\``); } - private async _deleteBrainProfile(profileId?: string) { + async _deleteBrainProfile(profileId?: string) { const currentProfiles = getBrainProfiles(); const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile(); if (!target) return; @@ -749,7 +616,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn this.injectSystemMessage(`**[Brain Deleted]** ${target.name}`); } - private async _saveWikiRaw() { + async _saveWikiRaw() { const history = this._agent.getHistory(); if (history.length === 0) { vscode.window.showWarningMessage('There is no conversation to save as wiki raw data.'); @@ -791,7 +658,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn this.injectSystemMessage(`**[Wiki Raw Saved]** \`${filePath}\``); } - private _buildWikiRawMarkdown(history: ChatMessage[], meta: { + _buildWikiRawMarkdown(history: ChatMessage[], meta: { category: string; expectedValue: string; activeBrainName: string; @@ -864,11 +731,11 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn ].join('\n'); } - private _formatTimestampForFile(date: Date): string { + _formatTimestampForFile(date: Date): string { return date.toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', ''); } - private _slugify(value: string): string { + _slugify(value: string): string { const slug = value .toLowerCase() .replace(/[^a-z0-9가-힣]+/g, '-') @@ -877,19 +744,19 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn return slug || 'conversation'; } - private _summarizeForTitle(value: string): string { + _summarizeForTitle(value: string): string { const normalized = value.replace(/\s+/g, ' ').trim(); if (!normalized) return 'Astra Conversation Raw Data'; return normalized.length > 80 ? `${normalized.slice(0, 80)}...` : normalized; } - private _summarizeTextForWiki(value: string): string { + _summarizeTextForWiki(value: string): string { const normalized = value.replace(/\s+/g, ' ').trim(); if (!normalized) return 'Not captured.'; return normalized.length > 500 ? `${normalized.slice(0, 500)}...` : normalized; } - private _escapeYamlString(value: string): string { + _escapeYamlString(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } @@ -950,7 +817,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn }); } - private _getChronicleProjects(): ProjectProfile[] { + _getChronicleProjects(): ProjectProfile[] { const raw = this._context.globalState.get(SidebarChatProvider.chronicleProjectsStateKey, []) || []; const valid = raw.filter((profile: ProjectProfile) => profile @@ -981,18 +848,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn }]; } - private async _putChronicleProjects(projects: ProjectProfile[]) { + async _putChronicleProjects(projects: ProjectProfile[]) { await this._context.globalState.update(SidebarChatProvider.chronicleProjectsStateKey, projects); } - private _getActiveChronicleProject(): ProjectProfile | null { + _getActiveChronicleProject(): ProjectProfile | null { const projects = this._getChronicleProjects(); if (projects.length === 0) return null; const activeId = this._context.globalState.get(SidebarChatProvider.activeChronicleProjectStateKey, ''); return projects.find(project => project.projectId === activeId) || projects[0]; } - private async _sendChronicleProjects() { + async _sendChronicleProjects() { if (!this._view) return; const projects = this._getChronicleProjects(); const active = this._getActiveChronicleProject(); @@ -1011,7 +878,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn }); } - private async _createChronicleProject() { + async _createChronicleProject() { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; const defaultName = workspaceRoot ? path.basename(workspaceRoot) : 'New Project'; @@ -1089,7 +956,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn this.injectSystemMessage(`**[Designer Project Created]** ${profile.projectName}\n\`${profile.recordRoot}\``); } - private async _setActiveChronicleProject(projectId: string) { + async _setActiveChronicleProject(projectId: string) { if (!projectId || projectId === 'new') { await this._createChronicleProject(); return; @@ -1103,7 +970,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn this.injectSystemMessage(`**[Designer Project Selected]** ${target.projectName}\n\`${target.recordRoot}\``); } - private async _openChronicleFolder() { + async _openChronicleFolder() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('No Chronicle project is selected.'); @@ -1118,7 +985,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private async _sendChronicleRecords() { + async _sendChronicleRecords() { if (!this._view) return; const profile = this._getActiveChronicleProject(); if (!profile) { @@ -1140,7 +1007,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private async _openChronicleRecord(recordPath: string) { + async _openChronicleRecord(recordPath: string) { const profile = this._getActiveChronicleProject(); if (!profile || !recordPath) { vscode.window.showWarningMessage('Select a Chronicle record first.'); @@ -1164,7 +1031,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn await vscode.window.showTextDocument(doc); } - private async _writeChroniclePlanningFromCurrentChat() { + async _writeChroniclePlanningFromCurrentChat() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('Select or create a Designer project first.'); @@ -1217,7 +1084,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private async _writeChronicleDiscussionFromCurrentChat() { + async _writeChronicleDiscussionFromCurrentChat() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('Select or create a Designer project first.'); @@ -1283,7 +1150,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private async _writeChronicleDecisionFromInput() { + async _writeChronicleDecisionFromInput() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('Select or create a Designer project first.'); @@ -1339,7 +1206,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private async _writeChronicleDevelopmentFromCurrentChat() { + async _writeChronicleDevelopmentFromCurrentChat() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('Select or create a Designer project first.'); @@ -1380,7 +1247,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private async _writeChronicleBugFromInput() { + async _writeChronicleBugFromInput() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('Select or create a Designer project first.'); @@ -1431,7 +1298,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private async _writeChronicleRetrospectiveFromInput() { + async _writeChronicleRetrospectiveFromInput() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('Select or create a Designer project first.'); @@ -1487,7 +1354,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private async _autoWriteChronicleAfterPrompt() { + async _autoWriteChronicleAfterPrompt() { const history = this._agent.getHistory(); const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || ''; const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || ''; @@ -1584,7 +1451,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private _getChronicleProjectForConversation(text: string): ProjectProfile | null { + _getChronicleProjectForConversation(text: string): ProjectProfile | null { const projectPath = this._extractLocalProjectPath(text); if (!projectPath) return null; @@ -1614,7 +1481,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn }; } - private _extractLocalProjectPath(text: string): string | null { + _extractLocalProjectPath(text: string): string | null { const match = text.match(/\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/i); if (!match) return null; const candidate = match[0].replace(/[.,;:)\]]+$/, ''); @@ -1628,7 +1495,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn return null; } - private _inferAutoChronicleRecordType(userText: string, assistantText: string): 'planning' | 'discussion' | 'decision' | 'development' | 'bug' | null { + _inferAutoChronicleRecordType(userText: string, assistantText: string): 'planning' | 'discussion' | 'decision' | 'development' | 'bug' | null { const combined = `${userText}\n${assistantText}`; if (!combined.trim()) return null; if (/(기록하지마|저장하지마|no\s+record|do\s+not\s+record)/i.test(combined)) return null; @@ -1653,7 +1520,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn return 'discussion'; } - private _extractChangedFilesFromText(text: string): string[] { + _extractChangedFilesFromText(text: string): string[] { const files = new Set(); for (const match of text.matchAll(/`([^`\n]+\.(?:ts|tsx|js|jsx|json|md|css|html|py|yml|yaml))`/gi)) { files.add(match[1].trim()); @@ -1661,7 +1528,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn return files.size > 0 ? Array.from(files).slice(0, 12) : ['No explicit changed file list was captured automatically.']; } - private async _writeChronicleRecord(recordType: string) { + async _writeChronicleRecord(recordType: string) { switch (recordType) { case 'planning': await this._writeChroniclePlanningFromCurrentChat(); @@ -1686,7 +1553,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private _getAgentsDir(): string { + _getAgentsDir(): string { const defaultPath = 'E:\\Wiki\\Agent\\.agent\\skills'; if (fs.existsSync(defaultPath)) return defaultPath; @@ -1701,7 +1568,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn return ''; } - private async _sendAgentsList() { + async _sendAgentsList() { if (!this._view) return; const dir = this._getAgentsDir(); const agents = []; @@ -1717,7 +1584,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn this._view.webview.postMessage({ type: 'agentsList', value: agents, selected: lastPath }); } - private async _handleProactiveSuggestion(context: string) { + async _handleProactiveSuggestion(context: string) { if (!this._view) return; let suggestion = ''; @@ -1740,7 +1607,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn this._view.webview.postMessage({ type: 'streamEnd' }); } - private async _createAgent() { + async _createAgent() { const name = await vscode.window.showInputBox({ prompt: 'Name of the new Agent (e.g., frontend_expert)', placeHolder: 'Agent name...' @@ -1766,7 +1633,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn await this._sendAgentsList(); } - private async _sendAgentContent(agentPath: string) { + async _sendAgentContent(agentPath: string) { if (!this._view || !agentPath || agentPath === 'none') return; if (fs.existsSync(agentPath)) { const content = fs.readFileSync(agentPath, 'utf8'); @@ -1780,7 +1647,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private async _updateAgent(agentPath: string, content: string, negativePrompt?: string) { + async _updateAgent(agentPath: string, content: string, negativePrompt?: string) { if (!agentPath || agentPath === 'none') return; try { fs.writeFileSync(agentPath, content, 'utf8'); @@ -1793,7 +1660,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private async _deleteAgent(agentPath: string) { + async _deleteAgent(agentPath: string) { if (!agentPath || agentPath === 'none') return; try { @@ -1827,7 +1694,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private async _handlePrompt(data: any) { + async _handlePrompt(data: any) { if (!this._view) return; const { value, model, internet, files, agentFile, negativePrompt, designerGuard, secondBrainTrace, secondBrainTraceDebug, brainProfileId } = data; @@ -1950,11 +1817,11 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private _buildDesignerGuardContext(): string { + _buildDesignerGuardContext(): string { return buildProjectChronicleGuardContext(this._getActiveChronicleProject()); } - private async _sendModels() { + async _sendModels(force: boolean = false) { if (!this._view) return; if (this._modelDiscoveryInFlight) { logInfo('Model discovery already in progress, skipping.'); @@ -1966,34 +1833,47 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn const url = config.ollamaUrl; let defaultModel = config.defaultModel; let models: string[] = []; + let online = false; - const engine = resolveEngine(url); // 단일 엔진만 - const modelsUrl = buildApiUrl(url, engine, 'models'); - try { - logInfo('Model discovery started.', { engine, modelsUrl }); - const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(5000) }); - const rawText = await res.text(); - if (!res.ok) { - logError('Model discovery returned non-OK status.', { engine, modelsUrl, status: res.status, body: summarizeText(rawText, 300) }); - } else { - const data = rawText ? JSON.parse(rawText) as any : {}; - models = engine === 'lmstudio' - ? (data.data || []).map((m: any) => m.id) - : (data.models || []).map((m: any) => m.name); + const cache = this._modelsCache; + const cacheFresh = !!cache + && cache.url === url + && (Date.now() - cache.fetchedAt) < SidebarChatProvider.MODELS_CACHE_TTL_MS; - if (models.length > 0) { - logInfo('Model discovery succeeded.', { engine, count: models.length, modelsPreview: models.slice(0, 5) }); + if (!force && cacheFresh && cache) { + models = cache.models.slice(); + online = cache.online; + this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } }); + } else { + const engine = resolveEngine(url); // 단일 엔진만 + const modelsUrl = buildApiUrl(url, engine, 'models'); + try { + logInfo('Model discovery started.', { engine, modelsUrl, force }); + const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(5000) }); + const rawText = await res.text(); + if (!res.ok) { + logError('Model discovery returned non-OK status.', { engine, modelsUrl, status: res.status, body: summarizeText(rawText, 300) }); + } else { + const data = rawText ? JSON.parse(rawText) as any : {}; + models = engine === 'lmstudio' + ? (data.data || []).map((m: any) => m.id) + : (data.models || []).map((m: any) => m.name); + + if (models.length > 0) { + logInfo('Model discovery succeeded.', { engine, count: models.length, modelsPreview: models.slice(0, 5) }); + } } + } catch (e: any) { + logError('Model discovery failed.', { engine, modelsUrl, error: e?.message || String(e) }); } - } catch (e: any) { - logError('Model discovery failed.', { engine, modelsUrl, error: e?.message || String(e) }); + + online = models.length > 0; + this._modelsCache = { url, models: models.slice(), online, fetchedAt: Date.now() }; + this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } }); } if (models.length === 0) { models = defaultModel ? [defaultModel] : []; - this._view.webview.postMessage({ type: 'engineStatus', value: { online: false, url } }); - } else { - this._view.webview.postMessage({ type: 'engineStatus', value: { online: true, url } }); } const baseModel = defaultModel?.replace(/:\d+$/, ''); @@ -2027,1464 +1907,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } } - private _getHtml(webview: vscode.Webview): string { - return ` - - - - - Astra - - - - -
-
-
Astra
-
- - - - - - - -
-
-
-
-
-
Engine
-
-
-
-
-
-
- - - - -
-
-
-
-
- - - -
-
-
-
-
-
- - -
-
-
-
- Auto Records -
-
-
- - -
-
-
-
-
- -
-
-

Chat History

- -
-
-
- -
- -
-
-
Analyze
-
Plan
-
Execute
-
Verify
-
-
- -
-
- -
Welcome to Astra
-

Your premium local AI assistant.
Ready to analyze projects and build reports.

-
-
- -
-
-
Agent Persona/Instructions
- - -
Negative Prompt (Strict Rules)
- - - -
-
-
- - -
-
-
- - -
-
- - - - - -`; + static _htmlTemplateCache: string | undefined; + + _getHtml(webview: vscode.Webview): string { + if (!SidebarChatProvider._htmlTemplateCache) { + const tplPath = path.join(this._extensionUri.fsPath, 'media', 'sidebar.html'); + SidebarChatProvider._htmlTemplateCache = fs.readFileSync(tplPath, 'utf8'); + } + const mediaRoot = vscode.Uri.joinPath(this._extensionUri, 'media'); + const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'sidebar.css')).toString(); + const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'sidebar.js')).toString(); + return SidebarChatProvider._htmlTemplateCache + .replace('__STYLES_URI__', stylesUri) + .replace('__SCRIPT_URI__', scriptUri); } } diff --git a/src/utils.ts b/src/utils.ts index 936aa68..cc9284d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -102,7 +102,34 @@ export function _isBrainDirExplicitlySet(): boolean { return getBrainProfiles().length > 0; } +interface BrainFilesCacheEntry { + files: string[]; + expiresAt: number; +} +const _brainFilesCache = new Map(); +const BRAIN_FILES_CACHE_TTL_MS = 5000; + export function findBrainFiles(dir: string): string[] { + const now = Date.now(); + const cached = _brainFilesCache.get(dir); + if (cached && cached.expiresAt > now) { + return cached.files.slice(); + } + const files = _walkBrainFiles(dir); + _brainFilesCache.set(dir, { files, expiresAt: now + BRAIN_FILES_CACHE_TTL_MS }); + return files.slice(); +} + +/** Force-invalidate the brain files cache (e.g. after sync or new file write). */ +export function invalidateBrainFilesCache(dir?: string): void { + if (dir === undefined) { + _brainFilesCache.clear(); + return; + } + _brainFilesCache.delete(dir); +} + +function _walkBrainFiles(dir: string): string[] { let results: string[] = []; if (!fs.existsSync(dir)) return results; const list = fs.readdirSync(dir); @@ -111,7 +138,7 @@ export function findBrainFiles(dir: string): string[] { const stat = fs.statSync(filePath); if (stat && stat.isDirectory()) { if (!EXCLUDED_DIRS.has(file)) { - results = results.concat(findBrainFiles(filePath)); + results = results.concat(_walkBrainFiles(filePath)); } } else if (file.endsWith('.md')) { results.push(filePath); diff --git a/tests/findBrainFilesCache.test.ts b/tests/findBrainFilesCache.test.ts new file mode 100644 index 0000000..9157eae --- /dev/null +++ b/tests/findBrainFilesCache.test.ts @@ -0,0 +1,80 @@ +/** + * Unit tests for findBrainFiles TTL cache. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { findBrainFiles, invalidateBrainFilesCache } from '../src/utils'; + +function makeBrain(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'astra-brain-cache-')); + fs.writeFileSync(path.join(dir, 'a.md'), '# A'); + fs.writeFileSync(path.join(dir, 'b.md'), '# B'); + return dir; +} + +describe('findBrainFiles TTL cache', () => { + let brain: string; + + beforeEach(() => { + brain = makeBrain(); + invalidateBrainFilesCache(); + }); + + afterEach(() => { + try { fs.rmSync(brain, { recursive: true, force: true }); } catch { /* noop */ } + }); + + test('initial walk lists current files', () => { + const files = findBrainFiles(brain); + expect(files.length).toBe(2); + expect(files.some((f) => f.endsWith('a.md'))).toBe(true); + }); + + test('within TTL: cache returns stale list when files added', () => { + findBrainFiles(brain); // prime cache + fs.writeFileSync(path.join(brain, 'c.md'), '# C'); + const files = findBrainFiles(brain); + // Cache hit returns the previous list + expect(files.length).toBe(2); + }); + + test('explicit invalidation forces fresh walk', () => { + findBrainFiles(brain); // prime + fs.writeFileSync(path.join(brain, 'c.md'), '# C'); + invalidateBrainFilesCache(brain); + const files = findBrainFiles(brain); + expect(files.length).toBe(3); + }); + + test('invalidateBrainFilesCache() with no arg clears all entries', () => { + const a = makeBrain(); + const b = makeBrain(); + try { + findBrainFiles(a); + findBrainFiles(b); + fs.writeFileSync(path.join(a, 'extra.md'), 'x'); + fs.writeFileSync(path.join(b, 'extra.md'), 'x'); + invalidateBrainFilesCache(); + expect(findBrainFiles(a).length).toBe(3); + expect(findBrainFiles(b).length).toBe(3); + } finally { + fs.rmSync(a, { recursive: true, force: true }); + fs.rmSync(b, { recursive: true, force: true }); + } + }); + + test('returned array is a copy — mutations do not poison cache', () => { + const first = findBrainFiles(brain); + first.length = 0; // mutate caller's copy + const second = findBrainFiles(brain); + expect(second.length).toBe(2); + }); + + test('non-existent directory returns empty list and does not throw', () => { + const fake = path.join(os.tmpdir(), 'astra-no-such-dir-' + Date.now()); + const files = findBrainFiles(fake); + expect(files).toEqual([]); + }); +}); diff --git a/tests/lmStudioLifecycle.test.ts b/tests/lmStudioLifecycle.test.ts new file mode 100644 index 0000000..6cc759e --- /dev/null +++ b/tests/lmStudioLifecycle.test.ts @@ -0,0 +1,310 @@ +/** + * Unit tests for ModelLifecycleManager. + * + * Strategy: inject mock ILMStudioClient and a simple in-memory IActivityTracker. + * No real LM Studio or SDK is touched — the manager file does not import the + * SDK directly (only types via `import type`). + */ + +import { + ModelLifecycleManager, + LifecycleConfig, + LifecycleManagerDeps, +} from '../src/lmstudio/lifecycleManager'; +import type { ILMStudioClient } from '../src/lmstudio/client'; +import type { IActivityTracker } from '../src/lmstudio/activityTracker'; + +class FakeActivityTracker implements IActivityTracker { + private listeners: Array<(_: void) => any> = []; + public readonly onActivity = ((listener: (_: void) => any) => { + this.listeners.push(listener); + return { dispose: () => { this.listeners = this.listeners.filter(l => l !== listener); } }; + }) as any; + bump(): void { + for (const l of this.listeners.slice()) l(); + } +} + +class FakeLMStudioClient implements ILMStudioClient { + public loadCalls: string[] = []; + public unloadCalls: string[] = []; + public listLoadedCalls = 0; + public failNextLoad: Error | null = null; + public failNextUnload: Error | null = null; + public loadDelayMs = 0; + public lastLoadSignal: AbortSignal | undefined; + + setBaseUrl(_: string): void { /* noop */ } + + async load(modelKey: string, signal?: AbortSignal): Promise { + this.loadCalls.push(modelKey); + this.lastLoadSignal = signal; + if (this.loadDelayMs > 0) { + await new Promise((resolve, reject) => { + const t = setTimeout(resolve, this.loadDelayMs); + if (signal) { + const onAbort = () => { clearTimeout(t); reject(new Error('aborted')); }; + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort); + } + }); + } + if (this.failNextLoad) { + const err = this.failNextLoad; + this.failNextLoad = null; + throw err; + } + } + + async unload(modelKey: string): Promise { + this.unloadCalls.push(modelKey); + if (this.failNextUnload) { + const err = this.failNextUnload; + this.failNextUnload = null; + throw err; + } + } + + async listLoaded(): Promise { + this.listLoadedCalls++; + return []; + } + + async isReachable(): Promise { + return true; + } +} + +function makeManager(overrides: Partial = {}, depOverrides: Partial = {}) { + const client = new FakeLMStudioClient(); + const activity = new FakeActivityTracker(); + const config: LifecycleConfig = { idleTimeoutMs: 1000, autoLoadOnSelect: true, ...overrides }; + const errors: string[] = []; + const manager = new ModelLifecycleManager({ + client, + activity, + getConfig: () => config, + notifyError: (m) => errors.push(m), + switchDebounceMs: 0, + initialEngine: 'lmstudio', + ...depOverrides, + }); + return { manager, client, activity, config, errors }; +} + +const flush = () => new Promise((r) => setImmediate(r)); + +describe('ModelLifecycleManager', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['setImmediate'] }); + }); + + afterEach(async () => { + jest.useRealTimers(); + }); + + test('loads model on selection and arms idle timer', async () => { + const { manager, client } = makeManager(); + manager.onModelSelected('llama-3.2-3b'); + await flush(); + expect(client.loadCalls).toEqual(['llama-3.2-3b']); + expect(manager._getState()).toBe('loaded'); + expect(manager._getCurrentModel()).toBe('llama-3.2-3b'); + expect(manager._hasIdleTimer()).toBe(true); + }); + + test('idle timer triggers unload after timeout', async () => { + const { manager, client } = makeManager({ idleTimeoutMs: 1000 }); + manager.onModelSelected('m1'); + await flush(); + expect(manager._getState()).toBe('loaded'); + + jest.advanceTimersByTime(1000); + await flush(); + expect(client.unloadCalls).toEqual(['m1']); + expect(manager._getState()).toBe('idle'); + expect(manager._getCurrentModel()).toBe(null); + }); + + test('idleTimeoutMs <= 0 disables auto-eject', async () => { + const { manager, client } = makeManager({ idleTimeoutMs: 0 }); + manager.onModelSelected('m1'); + await flush(); + expect(manager._getState()).toBe('loaded'); + expect(manager._hasIdleTimer()).toBe(false); + + jest.advanceTimersByTime(1_000_000); + await flush(); + expect(client.unloadCalls).toEqual([]); + expect(manager._getState()).toBe('loaded'); + }); + + test('activity bump resets idle timer', async () => { + const { manager, client, activity } = makeManager({ idleTimeoutMs: 1000 }); + manager.onModelSelected('m1'); + await flush(); + + jest.advanceTimersByTime(900); + activity.bump(); + jest.advanceTimersByTime(900); // total 1800ms but timer reset at 900 + await flush(); + expect(client.unloadCalls).toEqual([]); + expect(manager._getState()).toBe('loaded'); + + jest.advanceTimersByTime(200); // 200 + 900 since reset = ~1100 since reset + await flush(); + expect(client.unloadCalls).toEqual(['m1']); + }); + + test('streamStart pauses idle timer; streamEnd resumes', async () => { + const { manager, client } = makeManager({ idleTimeoutMs: 500 }); + manager.onModelSelected('m1'); + await flush(); + expect(manager._hasIdleTimer()).toBe(true); + + manager.onStreamStart(); + expect(manager._getState()).toBe('streaming'); + expect(manager._hasIdleTimer()).toBe(false); + + jest.advanceTimersByTime(10000); + await flush(); + expect(client.unloadCalls).toEqual([]); // never ejected during stream + + manager.onStreamEnd(); + expect(manager._getState()).toBe('loaded'); + expect(manager._hasIdleTimer()).toBe(true); + + jest.advanceTimersByTime(500); + await flush(); + expect(client.unloadCalls).toEqual(['m1']); + }); + + test('model switch unloads previous and loads next', async () => { + const { manager, client } = makeManager(); + manager.onModelSelected('m1'); + await flush(); + + manager.onModelSelected('m2'); + await flush(); + expect(client.unloadCalls).toEqual(['m1']); + expect(client.loadCalls).toEqual(['m1', 'm2']); + expect(manager._getCurrentModel()).toBe('m2'); + expect(manager._getState()).toBe('loaded'); + }); + + test('switch during streaming defers until streamEnd', async () => { + const { manager, client } = makeManager(); + manager.onModelSelected('m1'); + await flush(); + manager.onStreamStart(); + + manager.onModelSelected('m2'); + await flush(); + // No switch yet — still in streaming with m1 + expect(client.unloadCalls).toEqual([]); + expect(client.loadCalls).toEqual(['m1']); + + manager.onStreamEnd(); + await flush(); + expect(client.unloadCalls).toEqual(['m1']); + expect(client.loadCalls).toEqual(['m1', 'm2']); + expect(manager._getCurrentModel()).toBe('m2'); + }); + + test('load failure surfaces via notifyError and resets to idle', async () => { + const { manager, client, errors } = makeManager(); + client.failNextLoad = new Error('LM Studio is not running'); + manager.onModelSelected('m1'); + await flush(); + expect(manager._getState()).toBe('idle'); + expect(manager._getCurrentModel()).toBe(null); + expect(errors.length).toBe(1); + expect(errors[0]).toContain('LM Studio is not running'); + }); + + test('engine change to non-lmstudio clears state without unloading', async () => { + const { manager, client } = makeManager(); + manager.onModelSelected('m1'); + await flush(); + expect(manager._getState()).toBe('loaded'); + + manager.setEngine('ollama'); + expect(manager._getState()).toBe('idle'); + expect(manager._getCurrentModel()).toBe(null); + expect(manager._hasIdleTimer()).toBe(false); + expect(client.unloadCalls).toEqual([]); // explicitly does not call unload + }); + + test('selection while engine is non-lmstudio is a no-op', async () => { + const { manager, client } = makeManager({}, { initialEngine: 'ollama' }); + manager.onModelSelected('m1'); + await flush(); + expect(client.loadCalls).toEqual([]); + expect(manager._getState()).toBe('idle'); + }); + + test('autoLoadOnSelect=false skips loading', async () => { + const { manager, client } = makeManager({ autoLoadOnSelect: false }); + manager.onModelSelected('m1'); + await flush(); + expect(client.loadCalls).toEqual([]); + expect(manager._getState()).toBe('idle'); + }); + + test('rapid switch debounce: only the last selection wins', async () => { + // Re-create manager with non-zero debounce + const client = new FakeLMStudioClient(); + const activity = new FakeActivityTracker(); + const config: LifecycleConfig = { idleTimeoutMs: 1000, autoLoadOnSelect: true }; + const manager = new ModelLifecycleManager({ + client, + activity, + getConfig: () => config, + switchDebounceMs: 300, + initialEngine: 'lmstudio', + }); + manager.onModelSelected('m1'); + manager.onModelSelected('m2'); + manager.onModelSelected('m3'); + // Before debounce expires no load fires + expect(client.loadCalls).toEqual([]); + jest.advanceTimersByTime(300); + await flush(); + expect(client.loadCalls).toEqual(['m3']); + }); + + test('disposeAndUnload ejects loaded model', async () => { + const { manager, client } = makeManager(); + manager.onModelSelected('m1'); + await flush(); + await manager.disposeAndUnload(2000); + expect(client.unloadCalls).toEqual(['m1']); + expect(manager._getState()).toBe('idle'); + }); + + test('disposeAndUnload while idle is a no-op', async () => { + const { manager, client } = makeManager(); + await manager.disposeAndUnload(500); + expect(client.unloadCalls).toEqual([]); + }); + + test('selecting same model does not re-load but refreshes timer', async () => { + const { manager, client } = makeManager({ idleTimeoutMs: 1000 }); + manager.onModelSelected('m1'); + await flush(); + expect(client.loadCalls.length).toBe(1); + + jest.advanceTimersByTime(800); + manager.onModelSelected('m1'); + await flush(); + expect(client.loadCalls.length).toBe(1); // no reload + // Timer was reset; should survive past original 1000ms + jest.advanceTimersByTime(700); + await flush(); + expect(client.unloadCalls).toEqual([]); + + jest.advanceTimersByTime(400); + await flush(); + expect(client.unloadCalls).toEqual(['m1']); + }); +}); diff --git a/tests/mocks/vscode.js b/tests/mocks/vscode.js index 4302cf4..10ca065 100644 --- a/tests/mocks/vscode.js +++ b/tests/mocks/vscode.js @@ -7,10 +7,28 @@ const config = { } }; +class EventEmitter { + constructor() { + this._listeners = []; + this.event = (listener) => { + this._listeners.push(listener); + return { dispose: () => { this._listeners = this._listeners.filter(l => l !== listener); } }; + }; + } + fire(value) { + for (const l of this._listeners.slice()) l(value); + } + dispose() { + this._listeners = []; + } +} + module.exports = { + EventEmitter, workspace: { workspaceFolders: [], getConfiguration: () => config, + onDidChangeConfiguration: () => ({ dispose: () => {} }), fs: { writeFile: async () => {}, delete: async () => {} diff --git a/tsconfig.json b/tsconfig.json index 6f6a02d..b314b4d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "lib": ["ES2022", "DOM"], "sourceMap": true, "strict": true, + "skipLibCheck": true, "types": ["node", "jest"] }, "include": ["src/**/*", "tests/**/*"],