feat: v2.12.0 - UI/UX Refinement (Model Sync & Premium Tooltips)
This commit is contained in:
@@ -21,6 +21,25 @@
|
||||
- Removed premature empty stream chunks that were causing UI flickering.
|
||||
- Verified build stability with `v2.2.46` VSIX package.
|
||||
|
||||
---
|
||||
|
||||
## v2.8.0 (2026-04-29) - Intelligent Collaborative System Evolution
|
||||
|
||||
### 🤖 Multi-Agent Workflow (MAW)
|
||||
- **Planner Agent:** 전략 수립 및 작업 단계 자동 설계.
|
||||
- **Researcher Agent:** 지식 베이스(Second Brain) 심층 분석 및 데이터 정제.
|
||||
- **Writer Agent:** 최종 보고서 형태의 고품질 답변 생성 및 통합.
|
||||
- 복잡한 요청(분석, 보고서 등) 감지 시 자동으로 멀티 에이전트 모드 활성화.
|
||||
|
||||
### 🔮 Proactive Suggestion Engine
|
||||
- **Behavioral Tracking:** 사용자 UI 체류 시간(Dwell Time) 기반 의도 감지.
|
||||
- **Smart Tips:** 설정, 지식 동기화, 에이전트 선택 등 상황에 맞는 능동적 도움말 제공.
|
||||
|
||||
### 🎨 UX & Reliability Improvements
|
||||
- **Visual Feedback:** 가이드라인에 따른 상태별 아이콘 및 피드백 일관성 강화.
|
||||
- **Model Fix:** 모델 선택 및 저장 로직 안정화 완료.
|
||||
- **Agent Handoff:** 각 에이전트 간 데이터 전달 및 진행 상황 시각화 개선.
|
||||
|
||||
---
|
||||
*Date: 2026-04-25*
|
||||
*Version: 2.2.46*
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Advanced Features Implementation Guide
|
||||
|
||||
이 문서는 ConnectAI(G1nation)의 '지능형 협업 시스템' 구현을 위한 기술 가이드라인입니다.
|
||||
|
||||
---
|
||||
|
||||
## 🤖 1. 멀티 에이전트 워크플로우 (Multi-Agent Workflow)
|
||||
|
||||
사용자 요청을 분해, 분석, 통합하는 3단계 협업 엔진을 구축합니다.
|
||||
|
||||
### A. 에이전트 역할 정의
|
||||
- **Planner Agent (전략가):** 요청 분석, 검색 전략 수립, Task List 생성.
|
||||
- **Researcher Agent (분석가):** 지식 베이스 데이터 추출, 신뢰도 평가, 데이터 정제.
|
||||
- **Writer Agent (편집자):** 데이터 통합, 톤앤매너 적용, 최종 보고서 완성.
|
||||
|
||||
### B. 핸드오프(Hand-off) 흐름
|
||||
1. **Planner:** Thinking Bar 활성화 및 작업 설계도 작성.
|
||||
2. **Researcher:** 설계도 기반 지식 검색 및 메타데이터 확보.
|
||||
3. **Writer:** 수집된 데이터를 논리적 흐름에 따라 구조화하여 최종 응답 제공 (성공 Toast 알림).
|
||||
|
||||
---
|
||||
|
||||
## 🌐 2. 모달리티 확장 및 선제적 제안 (The Frontier)
|
||||
|
||||
### A. 멀티모달 파이프라인
|
||||
- **이미지/PDF:** OCR 및 레이아웃 분석을 통해 구조적 정보를 LLM에 주입.
|
||||
- **음성:** STT 변환 및 감정 분석을 통한 뉘앙스 조절.
|
||||
|
||||
### B. 선제적 제안 (Proactive Suggestion)
|
||||
- **행동 감지:** 사용자의 탐색 패턴(특정 컴포넌트 체류 시간 등)을 이벤트로 포착.
|
||||
- **예측 제안:** 행동 패턴 기반으로 사용자가 필요로 할 기능을 미리 제시 (예: 💡 효율 증대 설정 제안).
|
||||
|
||||
---
|
||||
|
||||
## ✅ 통합 체크리스트
|
||||
- [ ] FSD 기반 모듈 분리
|
||||
- [ ] Planner -> Researcher -> Writer 핸드오프 로직
|
||||
- [ ] 이미지/PDF 레이아웃 분석 모듈 연동
|
||||
- [ ] 사용자 행동 패턴 감지기 구현
|
||||
- [ ] 일관된 UX 피드백(Toast, Thinking Bar) 적용
|
||||
@@ -0,0 +1,44 @@
|
||||
# UX/UI Consistency Guidelines
|
||||
|
||||
이 문서는 ConnectAI(G1nation) 프로젝트의 UI 일관성과 사용자 경험을 유지하기 위한 핵심 규칙을 정의합니다. 모든 신규 기능 및 UI 수정은 이 가이드라인을 준수해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 1. 상호작용 패턴 (Interaction Pattern)
|
||||
|
||||
### Rule A: 생성 및 추가 (Create/Add)
|
||||
* **시각적 문법:** 핵심 생성 액션은 `+` 아이콘을 포함한 버튼 형태를 사용합니다.
|
||||
* **배치 규칙:**
|
||||
* **섹션 레벨:** 섹션 전체에 항목을 추가할 경우 우측 상단 또는 우측 하단에 명확한 버튼으로 배치합니다.
|
||||
* **콘텐츠 레벨:** 특정 아이템 내 추가는 아이템 우측 상단에 작은 `+` 아이콘 버튼으로 고정합니다.
|
||||
|
||||
### Rule B: 편집 및 수정 (Edit/Modify)
|
||||
* **편집 가능 여부 안내:** 수정이 불가능한 'Source of Truth' 데이터는 편집 버튼을 노출하지 않으며, 필요 시 툴팁으로 사유를 안내합니다.
|
||||
* **수정 방식:** 편집 기능이 있는 경우 공통적으로 `⚙️` (설정) 또는 `⋮` (더보기) 아이콘을 사용하며, 모달(Modal) 창 또는 사이드 패널 방식을 통해 체계적으로 수정합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🖌️ 2. 비주얼 가이드라인 (Visual Guidelines)
|
||||
|
||||
### A. 아이코노그래피 (Iconography) 통일
|
||||
* **추가:** `+` (Plus)
|
||||
* **편집/설정:** `⚙️` (Gear) 또는 `⋮` (Vertical Ellipsis)
|
||||
* **삭제:** `🗑️` (Trash)
|
||||
* **동기화:** `↻` (Refresh)
|
||||
|
||||
### B. 정보 계층 구조 (Information Hierarchy)
|
||||
* **핵심 액션 우선:** 사용자가 가장 자주 사용하는 기능(질문 입력, 채팅 시작)이 시각적으로 가장 강조되어야 합니다.
|
||||
* **그룹화:** 관련 있는 기능(AI 엔진 설정, 지식 베이스 관리, 도구 모음)은 시각적으로 그룹화하여 인지 부하를 줄입니다.
|
||||
|
||||
### C. 피드백 및 상태 메시지 (Feedback & State)
|
||||
* **색상 코드:**
|
||||
* 🟢 **성공:** 녹색 계열 (#00FF41) - Toast 알림 사용
|
||||
* 🔴 **오류:** 빨간색 계열 (#ff5252) - 에러 모달 또는 명확한 배너 사용
|
||||
* 🟡 **정보/진행:** 파란색/보라색 계열 - Thinking Bar 및 로딩 애니메이션 사용
|
||||
|
||||
---
|
||||
|
||||
## 🚀 3. 체크리스트
|
||||
- [ ] 새로운 버튼이 기존의 아이콘 문법을 따르는가?
|
||||
- [ ] 생성/편집 액션이 정의된 위치에 배치되었는가?
|
||||
- [ ] 상태 변화에 대해 일관된 색상과 방식으로 피드백을 주는가?
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/tests/**/*.test.ts'],
|
||||
moduleFileExtensions: ['ts', 'js'],
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest',
|
||||
},
|
||||
};
|
||||
Generated
+3812
-2
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -2,7 +2,7 @@
|
||||
"name": "g1nation",
|
||||
"displayName": "G1nation",
|
||||
"description": "100% local AI coding agent for VS Code. Create files, edit code, run commands, and work offline with Ollama or LM Studio.",
|
||||
"version": "2.2.67",
|
||||
"version": "2.12.0",
|
||||
"publisher": "connectailab",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -95,6 +95,11 @@
|
||||
"configuration": {
|
||||
"title": "G1nation",
|
||||
"properties": {
|
||||
"g1nation.multiAgentEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks."
|
||||
},
|
||||
"g1nation.ollamaUrl": {
|
||||
"type": "string",
|
||||
"default": "http://127.0.0.1:11434",
|
||||
@@ -169,6 +174,11 @@
|
||||
"type": "number",
|
||||
"default": 50,
|
||||
"description": "Maximum autonomous steps the agent can take per request. Default: 50"
|
||||
},
|
||||
"g1nation.dryRun": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "If enabled, the agent will ask for approval before committing any file changes."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,11 +190,14 @@
|
||||
"pretest": "npm run compile"
|
||||
},
|
||||
"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",
|
||||
"esbuild": "^0.28.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.4.9",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+428
-179
@@ -3,10 +3,8 @@ import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
// axios removed
|
||||
import {
|
||||
getConfig,
|
||||
_getBrainDir,
|
||||
findBrainFiles,
|
||||
EXCLUDED_DIRS,
|
||||
getSystemPrompt,
|
||||
shouldAutoPushBrain,
|
||||
getSecondBrainRepo,
|
||||
@@ -17,16 +15,53 @@ import {
|
||||
resolveEngine,
|
||||
summarizeText
|
||||
} from './utils';
|
||||
import { getConfig, EXCLUDED_DIRS } from './config';
|
||||
import { validatePath, sanitizeCommand } from './security';
|
||||
import { TransactionManager } from './core/transaction';
|
||||
import { SessionManager } from './core/session';
|
||||
import { PlannerAgent, ResearcherAgent, WriterAgent } from './agents/factory';
|
||||
import { ErrorTranslator } from './core/errorHandler';
|
||||
import {
|
||||
AgentExecutionError,
|
||||
FileSystemError,
|
||||
APICommunicationError
|
||||
} from './core/errors';
|
||||
import { StatusBarManager, AgentStatus } from './core/statusBar';
|
||||
import { lockManager } from './core/lock';
|
||||
import { actionQueue } from './core/queue';
|
||||
import { ConflictResolver } from './core/conflict';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string | any[];
|
||||
content: string;
|
||||
internal?: boolean;
|
||||
rationale?: {
|
||||
problem: string;
|
||||
goal: string;
|
||||
reasoning: string;
|
||||
};
|
||||
}
|
||||
|
||||
type HistoryChangeListener = (history: ChatMessage[]) => void | Promise<void>;
|
||||
|
||||
// --- Agent Roles & Workflows ---
|
||||
export type AgentRole = 'planner' | 'researcher' | 'writer';
|
||||
|
||||
const AGENT_PROMPTS: Record<AgentRole, string> = {
|
||||
planner: `You are the [Planner Agent]. Your goal is to analyze the user's request and create a detailed execution plan.
|
||||
1. Breakdown the request into logical steps.
|
||||
2. Identify key search keywords for the knowledge base.
|
||||
3. Output your plan in a structured format using <plan> tags.`,
|
||||
researcher: `You are the [Researcher Agent]. Your goal is to gather and analyze data based on the Planner's strategy.
|
||||
1. Search the local knowledge base using the provided keywords.
|
||||
2. Evaluate data reliability and extract relevant facts.
|
||||
3. Output your findings using <research_results> tags.`,
|
||||
writer: `You are the [Writer Agent]. Your goal is to synthesize all gathered information into a high-quality final report.
|
||||
1. Use the data from the Researcher.
|
||||
2. Follow the project's visual and tone-of-voice guidelines.
|
||||
3. Deliver a logical, consistent, and polished response.`
|
||||
};
|
||||
|
||||
export class AgentExecutor {
|
||||
private chatHistory: ChatMessage[] = [];
|
||||
private abortController: AbortController | null = null;
|
||||
@@ -34,10 +69,44 @@ export class AgentExecutor {
|
||||
private historyChangeListener: HistoryChangeListener | undefined;
|
||||
private runSerial = 0;
|
||||
private activeRunId = 0;
|
||||
private transactionManager: TransactionManager;
|
||||
private sessionManager: SessionManager;
|
||||
private statusBarManager: StatusBarManager;
|
||||
private currentTaskId: string = 'default_session';
|
||||
|
||||
constructor(
|
||||
private context: vscode.ExtensionContext
|
||||
) {}
|
||||
) {
|
||||
this.transactionManager = new TransactionManager();
|
||||
this.sessionManager = new SessionManager(this.context);
|
||||
this.statusBarManager = new StatusBarManager();
|
||||
this.restoreLastSession();
|
||||
}
|
||||
|
||||
private parseRationale(text: string) {
|
||||
const match = text.match(/<rationale>([\s\S]*?)<\/rationale>/);
|
||||
if (!match) return undefined;
|
||||
|
||||
const raw = match[1];
|
||||
const problem = raw.match(/\[PROBLEM\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || "";
|
||||
const goal = raw.match(/\[GOAL\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || "";
|
||||
const reasoning = raw.match(/\[REASONING\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || raw.trim();
|
||||
|
||||
return { problem, goal, reasoning };
|
||||
}
|
||||
|
||||
private async restoreLastSession() {
|
||||
try {
|
||||
const lastSession = this.sessionManager.loadLastActiveSession();
|
||||
if (lastSession) {
|
||||
this.chatHistory = lastSession.history;
|
||||
this.currentTaskId = lastSession.taskId;
|
||||
logInfo(`Restored last session: ${this.currentTaskId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Failed to restore last session. Starting fresh.', error);
|
||||
}
|
||||
}
|
||||
|
||||
public setWebview(webview: vscode.Webview) {
|
||||
this.webview = webview;
|
||||
@@ -75,6 +144,20 @@ export class AgentExecutor {
|
||||
this.emitHistoryChanged();
|
||||
}
|
||||
|
||||
public async approveTransaction() {
|
||||
if (!this.transactionManager.isActive()) return;
|
||||
this.transactionManager.commit();
|
||||
this.statusBarManager.updateStatus(AgentStatus.Success, 'Changes committed.');
|
||||
this.webview?.postMessage({ type: 'streamChunk', value: '\n✅ **작업이 승인되어 반영되었습니다.**' });
|
||||
}
|
||||
|
||||
public async rejectTransaction() {
|
||||
if (!this.transactionManager.isActive()) return;
|
||||
this.transactionManager.rollback();
|
||||
this.statusBarManager.updateStatus(AgentStatus.Idle, 'Changes rolled back.');
|
||||
this.webview?.postMessage({ type: 'streamChunk', value: '\n❌ **작업이 거부되어 모든 변경사항이 취소되었습니다.**' });
|
||||
}
|
||||
|
||||
public async handlePrompt(
|
||||
prompt: string | null,
|
||||
modelName: string,
|
||||
@@ -98,19 +181,35 @@ export class AgentExecutor {
|
||||
temperature = 0.7,
|
||||
systemPrompt = getSystemPrompt()
|
||||
} = options;
|
||||
const { ollamaUrl, defaultModel: configDefaultModel, timeout, multiAgentEnabled } = getConfig();
|
||||
const runId = options.runId ?? (loopDepth === 0 ? ++this.runSerial : this.activeRunId);
|
||||
|
||||
// Decide whether to use Multi-Agent Workflow (for complex requests)
|
||||
const isComplex = prompt && (prompt.length > 100 || /(분석|보고서|설계|기획|정리)/.test(prompt));
|
||||
if (isComplex && loopDepth === 0 && multiAgentEnabled) {
|
||||
return this.executeMultiAgentWorkflow(prompt!, modelName, options);
|
||||
}
|
||||
|
||||
const hasVisionContent = Array.isArray(visionContent) ? visionContent.length > 0 : !!visionContent;
|
||||
let requestTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
if (!this.webview) return;
|
||||
|
||||
try {
|
||||
// 0. Safety Check: Rollback any dangling transaction from previous runs
|
||||
if (this.transactionManager.isActive()) {
|
||||
logInfo('Cleaning up dangling transaction from previous session.');
|
||||
this.transactionManager.rollback();
|
||||
}
|
||||
|
||||
this.statusBarManager.updateStatus(AgentStatus.Thinking);
|
||||
if (loopDepth === 0) {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
this.activeRunId = runId;
|
||||
this.currentTaskId = `task_${Date.now()}`;
|
||||
await this.context.workspaceState.update('lastActionStr', undefined);
|
||||
}
|
||||
|
||||
@@ -170,7 +269,8 @@ export class AgentExecutor {
|
||||
}
|
||||
|
||||
// 3. API Request Setup
|
||||
const { ollamaUrl, defaultModel, timeout } = getConfig();
|
||||
const { ollamaUrl, defaultModel: configDefaultModel, timeout } = getConfig();
|
||||
const actualModel = modelName || configDefaultModel;
|
||||
const reqMessages = [...this.chatHistory];
|
||||
|
||||
// Handle Vision Content Injection
|
||||
@@ -184,7 +284,7 @@ export class AgentExecutor {
|
||||
: [];
|
||||
reqMessages[lastUserIdx] = {
|
||||
role: 'user',
|
||||
content: [...textParts, ...(visionContent || [])]
|
||||
content: JSON.stringify([...textParts, ...(visionContent || [])])
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -208,12 +308,12 @@ export class AgentExecutor {
|
||||
// 4. Call AI Engine
|
||||
this.abortController = new AbortController();
|
||||
requestTimeoutHandle = setTimeout(() => {
|
||||
logError('AI request timed out.', { timeoutMs: timeout, model: modelName || defaultModel, loopDepth });
|
||||
logError('AI request timed out.', { timeoutMs: timeout, model: actualModel, loopDepth });
|
||||
this.abortController?.abort();
|
||||
}, timeout);
|
||||
const request = await this.createStreamingRequest({
|
||||
baseUrl: ollamaUrl,
|
||||
modelName: modelName || defaultModel,
|
||||
modelName: actualModel,
|
||||
reqMessages: messagesForRequest,
|
||||
temperature
|
||||
});
|
||||
@@ -283,12 +383,15 @@ export class AgentExecutor {
|
||||
}
|
||||
|
||||
// 5. Execute Actions
|
||||
const assistantMessage: ChatMessage = { role: 'assistant', content: aiResponseText, internal: true };
|
||||
const rationale = this.parseRationale(aiResponseText);
|
||||
const assistantMessage: ChatMessage = { role: 'assistant', content: aiResponseText, internal: true, rationale };
|
||||
this.chatHistory.push(assistantMessage);
|
||||
|
||||
this.statusBarManager.updateStatus(AgentStatus.Executing);
|
||||
const report = await this.executeActions(aiResponseText, rootPath);
|
||||
if (!aiResponseText.trim() && report.length === 0) {
|
||||
this.chatHistory.pop();
|
||||
logError('Model returned an empty response without actions.', { model: modelName || defaultModel, loopDepth });
|
||||
logError('Model returned an empty response without actions.', { model: actualModel, loopDepth });
|
||||
this.webview.postMessage({ type: 'error', value: 'AI model returned an empty response.' });
|
||||
return;
|
||||
}
|
||||
@@ -332,9 +435,11 @@ export class AgentExecutor {
|
||||
|
||||
assistantMessage.internal = false;
|
||||
this.emitHistoryChanged();
|
||||
this.statusBarManager.updateStatus(AgentStatus.Success);
|
||||
this.webview.postMessage({ type: 'streamChunk', value: aiResponseText });
|
||||
|
||||
} catch (error: any) {
|
||||
this.statusBarManager.updateStatus(AgentStatus.Error, error.message);
|
||||
logError('Agent prompt failed.', { error: error?.message || String(error), promptPreview: summarizeText(prompt || '', 200) });
|
||||
if (!this.isStaleRun(runId)) {
|
||||
this.webview.postMessage({ type: "error", value: `[Agent Error]: ${error.message}` });
|
||||
@@ -383,16 +488,15 @@ export class AgentExecutor {
|
||||
if (explicitPath) return explicitPath;
|
||||
|
||||
const namedProject = prompt.match(/([A-Za-z0-9._-]+)\s*(?:프로젝트|project)/i)?.[1];
|
||||
if (!namedProject) return null;
|
||||
if (!namedProject) return null; // No project keyword found, do not attempt to guess.
|
||||
|
||||
const searchRoots = [
|
||||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '',
|
||||
'/Volumes/Data/project/Antigravity',
|
||||
'/Volumes/Data/project',
|
||||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''
|
||||
].filter(Boolean);
|
||||
|
||||
for (const root of searchRoots) {
|
||||
const resolved = this.findDirectoryByName(root, namedProject, 4);
|
||||
const resolved = this.findDirectoryByName(root, namedProject, 2); // Depth reduced to 2 for performance and accuracy.
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
@@ -424,10 +528,108 @@ export class AgentExecutor {
|
||||
return null;
|
||||
}
|
||||
|
||||
public async executeMultiAgentWorkflow(
|
||||
prompt: string,
|
||||
modelName: string,
|
||||
options: any
|
||||
) {
|
||||
if (!this.webview) return;
|
||||
this.statusBarManager.updateStatus(AgentStatus.Thinking, 'Multi-Agent Workflow Started');
|
||||
this.webview.postMessage({ type: 'streamStart' });
|
||||
|
||||
try {
|
||||
// Instantiate decoupled agents
|
||||
const planner = new PlannerAgent(modelName);
|
||||
const researcher = new ResearcherAgent(modelName);
|
||||
const writer = new WriterAgent(modelName);
|
||||
|
||||
// Prepare Context
|
||||
const activeBrain = getActiveBrainProfile();
|
||||
const brainFiles = findBrainFiles(activeBrain.localBrainPath);
|
||||
const brainContext = `Brain: ${activeBrain.name}, Files: ${brainFiles.length}`;
|
||||
|
||||
// --- Phase 1: Planner ---
|
||||
this.webview.postMessage({ type: 'autoContinue', value: 'Planner: 전략 수립 중...' });
|
||||
const plan = await planner.execute(prompt, brainContext);
|
||||
this.webview.postMessage({ type: 'streamChunk', value: `\n\n### 📝 작업 계획 (Execution Plan)\n${plan}\n\n` });
|
||||
|
||||
// --- Phase 2: Researcher ---
|
||||
this.webview.postMessage({ type: 'autoContinue', value: 'Researcher: 지식 검색 중...' });
|
||||
const research = await researcher.execute(plan, brainContext);
|
||||
this.webview.postMessage({ type: 'streamChunk', value: `\n\n### 🔍 분석 결과 (Research Findings)\n*(정보 수집 및 정제 완료)*\n\n` });
|
||||
|
||||
// --- Phase 3: Writer ---
|
||||
this.webview.postMessage({ type: 'autoContinue', value: 'Writer: 보고서 작성 중...' });
|
||||
const finalReport = await writer.execute(research, prompt);
|
||||
|
||||
this.webview.postMessage({ type: 'streamChunk', value: `\n\n--- \n\n${finalReport}` });
|
||||
this.webview.postMessage({ type: 'streamEnd' });
|
||||
|
||||
this.chatHistory.push({ role: 'assistant', content: finalReport });
|
||||
this.emitHistoryChanged();
|
||||
|
||||
this.statusBarManager.updateStatus(AgentStatus.Success, 'Workflow Complete');
|
||||
this.webview.postMessage({ type: 'autoContinue', value: '✅ 분석이 완료되었습니다!' });
|
||||
|
||||
} catch (error: any) {
|
||||
const friendly = ErrorTranslator.translate(error);
|
||||
logError('Workflow failed', error);
|
||||
|
||||
// Format error using guideline-compliant UI (Red color scheme)
|
||||
this.webview.postMessage({
|
||||
type: 'error',
|
||||
value: `### ${friendly.title}\n\n**상태:** ${friendly.message}\n\n**해결 방법:** ${friendly.action}`
|
||||
});
|
||||
this.statusBarManager.updateStatus(AgentStatus.Idle, 'Error occurred');
|
||||
}
|
||||
}
|
||||
|
||||
private async callAgent(role: AgentRole, prompt: string, modelName: string, options: any): Promise<string> {
|
||||
const persona = AGENT_PROMPTS[role];
|
||||
const { ollamaUrl, timeout } = getConfig();
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: 'system', content: persona },
|
||||
{ role: 'user', content: prompt }
|
||||
];
|
||||
|
||||
const request = await this.createStreamingRequest({
|
||||
baseUrl: ollamaUrl,
|
||||
modelName: modelName,
|
||||
reqMessages: messages,
|
||||
temperature: 0.3 // Use lower temperature for planning and research
|
||||
});
|
||||
|
||||
let responseText = '';
|
||||
const reader = request.response.body?.getReader();
|
||||
if (!reader) throw new Error("Agent response body is not readable.");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed === 'data: [DONE]') continue;
|
||||
try {
|
||||
const json = JSON.parse(trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed);
|
||||
const content = json.choices?.[0]?.delta?.content || json.message?.content || '';
|
||||
responseText += content;
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
return responseText;
|
||||
}
|
||||
|
||||
private isProjectAnalysisRequest(normalized: string): boolean {
|
||||
// Intentionally conservative: omit generic words like '조사', '설명', '파악'
|
||||
// that appear in ordinary chat and would incorrectly bypass LM Studio
|
||||
return /(분석|리뷰|설계|구조|어떤 프로그램|무슨 프로그램|제품 설명)/.test(normalized);
|
||||
// Only trigger local analysis if the intent is strictly about a project-level overview.
|
||||
// Avoid generic terms like 'analysis' or 'review' that are common in general coding chat.
|
||||
const hasProjectKeyword = /(프로젝트|project|레포|repository)/.test(normalized);
|
||||
const hasAnalysisIntent = /(전체 요약|제품 설명|어떤 프로그램|구조 파악|훑어보기)/.test(normalized);
|
||||
|
||||
return hasProjectKeyword && hasAnalysisIntent;
|
||||
}
|
||||
|
||||
private buildProjectAnalysisReply(projectPath: string): string {
|
||||
@@ -691,6 +893,13 @@ export class AgentExecutor {
|
||||
private emitHistoryChanged() {
|
||||
if (!this.historyChangeListener) return;
|
||||
|
||||
// Save session whenever history changes
|
||||
this.sessionManager.saveSession(
|
||||
this.currentTaskId,
|
||||
this.chatHistory,
|
||||
this.context.workspaceState.get<string>('lastActionStr')
|
||||
);
|
||||
|
||||
Promise.resolve(this.historyChangeListener(this.getHistory())).catch((error: any) => {
|
||||
logError('History change listener failed.', { error: error?.message || String(error) });
|
||||
});
|
||||
@@ -818,179 +1027,219 @@ export class AgentExecutor {
|
||||
let brainModified = false;
|
||||
let firstCreatedFile: string | undefined;
|
||||
|
||||
// Action 1: Create File
|
||||
const createRegex = /<create_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/create_file>/gi;
|
||||
let match;
|
||||
while ((match = createRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim();
|
||||
const content = match[2].trim();
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, content, 'utf-8');
|
||||
report.push(`✅ Created: ${relPath}`);
|
||||
if (!firstCreatedFile) firstCreatedFile = absPath;
|
||||
if (absPath.startsWith(_getBrainDir())) brainModified = true;
|
||||
} catch (err: any) { report.push(`❌ Error Creating ${relPath}: ${err.message}`); }
|
||||
}
|
||||
try {
|
||||
this.transactionManager.begin();
|
||||
|
||||
// Action 2: Edit File
|
||||
const editRegex = /<edit_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/edit_file>/gi;
|
||||
while ((match = editRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim();
|
||||
const editContent = match[2].trim();
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
if (fs.existsSync(absPath)) {
|
||||
let currentContent = fs.readFileSync(absPath, 'utf-8');
|
||||
const searchMatch = editContent.match(/<search>([\s\S]*?)<\/search>\s*<replace>([\s\S]*?)<\/replace>/i);
|
||||
if (searchMatch) {
|
||||
const searchStr = searchMatch[1];
|
||||
const replaceStr = searchMatch[2];
|
||||
if (currentContent.includes(searchStr)) {
|
||||
currentContent = currentContent.replace(searchStr, replaceStr);
|
||||
fs.writeFileSync(absPath, currentContent, 'utf-8');
|
||||
report.push(`📝 Updated: ${relPath}`);
|
||||
} else {
|
||||
report.push(`⚠️ Search string not found in ${relPath}`);
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(absPath, editContent, 'utf-8');
|
||||
report.push(`📝 Updated (Full): ${relPath}`);
|
||||
}
|
||||
// Action 1: Create File
|
||||
const createRegex = /<create_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/create_file>/gi;
|
||||
let match;
|
||||
while ((match = createRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim();
|
||||
const content = match[2].trim();
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
await this.transactionManager.record(absPath);
|
||||
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, content, 'utf-8');
|
||||
|
||||
report.push(`✅ Created: ${relPath}`);
|
||||
if (!firstCreatedFile) firstCreatedFile = absPath;
|
||||
if (absPath.startsWith(_getBrainDir())) brainModified = true;
|
||||
} else {
|
||||
report.push(`❌ File not found: ${relPath}`);
|
||||
} catch (err: any) {
|
||||
throw new FileSystemError(`Failed to create file ${relPath}: ${err.message}`, relPath, err);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Error Editing ${relPath}: ${err.message}`); }
|
||||
}
|
||||
}
|
||||
|
||||
// Action 3: Delete File
|
||||
const deleteRegex = /<delete_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/delete_file>)?/gi;
|
||||
while ((match = deleteRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim();
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
if (fs.existsSync(absPath)) {
|
||||
fs.unlinkSync(absPath);
|
||||
report.push(`🗑 Deleted: ${relPath}`);
|
||||
} else {
|
||||
report.push(`⚠️ Delete failed: ${relPath} not found`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Error Deleting ${relPath}: ${err.message}`); }
|
||||
}
|
||||
|
||||
// Action 4: Read File
|
||||
const readRegex = /<read_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/read_file>)?/gi;
|
||||
while ((match = readRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim();
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
if (fs.existsSync(absPath)) {
|
||||
const content = fs.readFileSync(absPath, 'utf-8');
|
||||
const preview = content.length > 8000 ? content.slice(0, 8000) + "\n... (truncated)" : content;
|
||||
report.push(`📖 Read: ${relPath}`);
|
||||
this.chatHistory.push({ role: 'system', content: `[Result of read_file ${relPath}]\n\`\`\`\n${preview}\n\`\`\``, internal: true });
|
||||
} else {
|
||||
report.push(`❌ Read failed: ${relPath} not found`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Error Reading ${relPath}: ${err.message}`); }
|
||||
}
|
||||
|
||||
// Action 5: Run Command
|
||||
const cmdRegex = /<run_command>([\s\S]*?)<\/run_command>/gi;
|
||||
while ((match = cmdRegex.exec(aiMessage)) !== null) {
|
||||
const cmd = match[1].trim();
|
||||
try {
|
||||
const safeCmd = sanitizeCommand(cmd);
|
||||
const terminal = vscode.window.terminals.find(t => t.name === 'G1nation Terminal') || vscode.window.createTerminal({ name: 'G1nation Terminal', cwd: rootPath });
|
||||
terminal.show();
|
||||
terminal.sendText(safeCmd);
|
||||
report.push(`🚀 Executed: ${safeCmd}`);
|
||||
} catch (err: any) { report.push(`❌ Blocked: ${err.message}`); }
|
||||
}
|
||||
|
||||
// Action 6: List Files
|
||||
const listRegex = /<list_files\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/list_files>)?/gi;
|
||||
while ((match = listRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim() || '.';
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) {
|
||||
const entries = fs.readdirSync(absPath, { withFileTypes: true });
|
||||
let listing = entries
|
||||
.filter(e => !e.name.startsWith('.') && !EXCLUDED_DIRS.has(e.name))
|
||||
.map(e => e.isDirectory() ? `${e.name}/` : e.name)
|
||||
.join('\n');
|
||||
|
||||
if (listing.length > 5000) {
|
||||
listing = listing.slice(0, 5000) + "\n... (truncated for context)";
|
||||
// Action 2: Edit File
|
||||
const editRegex = /<edit_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/edit_file>/gi;
|
||||
while ((match = editRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim();
|
||||
const editContent = match[2].trim();
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
if (fs.existsSync(absPath)) {
|
||||
await this.transactionManager.record(absPath);
|
||||
|
||||
let currentContent = fs.readFileSync(absPath, 'utf-8');
|
||||
const searchMatch = editContent.match(/<search>([\s\S]*?)<\/search>\s*<replace>([\s\S]*?)<\/replace>/i);
|
||||
|
||||
if (searchMatch) {
|
||||
const searchStr = searchMatch[1];
|
||||
const replaceStr = searchMatch[2];
|
||||
if (currentContent.includes(searchStr)) {
|
||||
currentContent = currentContent.replace(searchStr, replaceStr);
|
||||
fs.writeFileSync(absPath, currentContent, 'utf-8');
|
||||
report.push(`📝 Updated: ${relPath}`);
|
||||
} else {
|
||||
report.push(`⚠️ Search string not found in ${relPath}`);
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(absPath, editContent, 'utf-8');
|
||||
report.push(`📝 Updated (Full): ${relPath}`);
|
||||
}
|
||||
if (absPath.startsWith(_getBrainDir())) brainModified = true;
|
||||
} else {
|
||||
report.push(`❌ File not found: ${relPath}`);
|
||||
}
|
||||
|
||||
report.push(`📂 Listed: ${relPath}`);
|
||||
this.chatHistory.push({ role: 'system', content: `[Result of list_files ${relPath}]\n${listing}`, internal: true });
|
||||
} catch (err: any) {
|
||||
throw new FileSystemError(`Failed to edit file ${relPath}: ${err.message}`, relPath, err);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Listing failed: ${err.message}`); }
|
||||
}
|
||||
}
|
||||
|
||||
// Action 7: Second Brain Knowledge (List/Read)
|
||||
const listBrainRegex = /<list_brain\s*path=['"]?([^'"]*)['"]?\s*\/?>(?:<\/list_brain>)?/gi;
|
||||
while ((match = listBrainRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim() || '.';
|
||||
try {
|
||||
const brainDir = _getBrainDir();
|
||||
const absPath = path.join(brainDir, relPath);
|
||||
if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) {
|
||||
const entries = fs.readdirSync(absPath, { withFileTypes: true });
|
||||
let listing = entries
|
||||
.filter(e => !e.name.startsWith('.') && !EXCLUDED_DIRS.has(e.name))
|
||||
.map(e => e.isDirectory() ? `${e.name}/` : e.name)
|
||||
.join('\n');
|
||||
|
||||
if (listing.length > 5000) {
|
||||
listing = listing.slice(0, 5000) + "\n... (truncated for context)";
|
||||
// Action 3: Delete File
|
||||
const deleteRegex = /<delete_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/delete_file>)?/gi;
|
||||
while ((match = deleteRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim();
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
if (fs.existsSync(absPath)) {
|
||||
await this.transactionManager.record(absPath);
|
||||
fs.unlinkSync(absPath);
|
||||
report.push(`🗑 Deleted: ${relPath}`);
|
||||
} else {
|
||||
report.push(`⚠️ Delete failed: ${relPath} not found`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw new FileSystemError(`Failed to delete file ${relPath}: ${err.message}`, relPath, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Action 4: Read File (Non-state-changing, no transaction record needed)
|
||||
const readRegex = /<read_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/read_file>)?/gi;
|
||||
while ((match = readRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim();
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
if (fs.existsSync(absPath)) {
|
||||
const content = fs.readFileSync(absPath, 'utf-8');
|
||||
const preview = content.length > 8000 ? content.slice(0, 8000) + "\n... (truncated)" : content;
|
||||
report.push(`📖 Read: ${relPath}`);
|
||||
this.chatHistory.push({ role: 'system', content: `[Result of read_file ${relPath}]\n\`\`\`\n${preview}\n\`\`\``, internal: true });
|
||||
} else {
|
||||
report.push(`❌ Read failed: ${relPath} not found`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Error Reading ${relPath}: ${err.message}`); }
|
||||
}
|
||||
|
||||
// Action 5: Run Command
|
||||
const cmdRegex = /<run_command>([\s\S]*?)<\/run_command>/gi;
|
||||
while ((match = cmdRegex.exec(aiMessage)) !== null) {
|
||||
const cmd = match[1].trim();
|
||||
try {
|
||||
const safeCmd = sanitizeCommand(cmd);
|
||||
const terminal = vscode.window.terminals.find(t => t.name === 'G1nation Terminal') || vscode.window.createTerminal({ name: 'G1nation Terminal', cwd: rootPath });
|
||||
terminal.show();
|
||||
terminal.sendText(safeCmd);
|
||||
report.push(`🚀 Executed: ${safeCmd}`);
|
||||
} catch (err: any) { report.push(`❌ Blocked: ${err.message}`); }
|
||||
}
|
||||
|
||||
// Action 6: List Files
|
||||
const listRegex = /<list_files\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/list_files>)?/gi;
|
||||
while ((match = listRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim() || '.';
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) {
|
||||
const entries = fs.readdirSync(absPath, { withFileTypes: true });
|
||||
let listing = entries
|
||||
.filter(e => !e.name.startsWith('.') && !EXCLUDED_DIRS.has(e.name))
|
||||
.map(e => e.isDirectory() ? `${e.name}/` : e.name)
|
||||
.join('\n');
|
||||
|
||||
if (listing.length > 5000) {
|
||||
listing = listing.slice(0, 5000) + "\n... (truncated for context)";
|
||||
}
|
||||
|
||||
report.push(`📂 Listed: ${relPath}`);
|
||||
this.chatHistory.push({ role: 'system', content: `[Result of list_files ${relPath}]\n${listing}`, internal: true });
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Listing failed: ${err.message}`); }
|
||||
}
|
||||
|
||||
// Action 7: Second Brain Knowledge (List/Read)
|
||||
const listBrainRegex = /<list_brain\s*path=['"]?([^'"]*)['"]?\s*\/?>(?:<\/list_brain>)?/gi;
|
||||
while ((match = listBrainRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim() || '.';
|
||||
try {
|
||||
const brainDir = _getBrainDir();
|
||||
const absPath = path.join(brainDir, relPath);
|
||||
if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) {
|
||||
const entries = fs.readdirSync(absPath, { withFileTypes: true });
|
||||
let listing = entries
|
||||
.filter(e => !e.name.startsWith('.') && !EXCLUDED_DIRS.has(e.name))
|
||||
.map(e => e.isDirectory() ? `${e.name}/` : e.name)
|
||||
.join('\n');
|
||||
|
||||
if (listing.length > 5000) {
|
||||
listing = listing.slice(0, 5000) + "\n... (truncated for context)";
|
||||
}
|
||||
|
||||
report.push(`🧠 Brain Listed: ${relPath}`);
|
||||
this.chatHistory.push({ role: 'system', content: `[Result of list_brain ${relPath}]\n${listing}`, internal: true });
|
||||
} else {
|
||||
report.push(`❌ Brain List failed: ${relPath} not found`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Error Listing Brain: ${err.message}`); }
|
||||
}
|
||||
|
||||
const brainRegex = /<read_brain>([\s\S]*?)<\/read_brain>/gi;
|
||||
while ((match = brainRegex.exec(aiMessage)) !== null) {
|
||||
const fileName = match[1].trim();
|
||||
try {
|
||||
const brainDir = _getBrainDir();
|
||||
const files = findBrainFiles(brainDir);
|
||||
const targetFile = files.find((f: string) => path.basename(f) === fileName || f.endsWith(fileName));
|
||||
|
||||
report.push(`🧠 Brain Listed: ${relPath}`);
|
||||
this.chatHistory.push({ role: 'system', content: `[Result of list_brain ${relPath}]\n${listing}`, internal: true });
|
||||
} else {
|
||||
report.push(`❌ Brain List failed: ${relPath} not found`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Error Listing Brain: ${err.message}`); }
|
||||
}
|
||||
if (targetFile && fs.existsSync(targetFile)) {
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
report.push(`🧠 Brain Read: ${fileName}`);
|
||||
this.chatHistory.push({ role: 'system', content: `[Result of read_brain ${fileName}]\n\`\`\`\n${content}\n\`\`\``, internal: true });
|
||||
} else {
|
||||
report.push(`❌ Brain Read failed: ${fileName} not found in Second Brain`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Error Reading Brain: ${err.message}`); }
|
||||
}
|
||||
|
||||
const brainRegex = /<read_brain>([\s\S]*?)<\/read_brain>/gi;
|
||||
while ((match = brainRegex.exec(aiMessage)) !== null) {
|
||||
const fileName = match[1].trim();
|
||||
try {
|
||||
const brainDir = _getBrainDir();
|
||||
const files = findBrainFiles(brainDir);
|
||||
// Look for direct match or path match
|
||||
const targetFile = files.find((f: string) => path.basename(f) === fileName || f.endsWith(fileName));
|
||||
|
||||
if (targetFile && fs.existsSync(targetFile)) {
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
report.push(`🧠 Brain Read: ${fileName}`);
|
||||
this.chatHistory.push({ role: 'system', content: `[Result of read_brain ${fileName}]\n\`\`\`\n${content}\n\`\`\``, internal: true });
|
||||
} else {
|
||||
report.push(`❌ Brain Read failed: ${fileName} not found in Second Brain`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Error Reading Brain: ${err.message}`); }
|
||||
}
|
||||
// Action 8: Read URL
|
||||
const urlRegex = /<read_url>([\s\S]*?)<\/read_url>/gi;
|
||||
while ((match = urlRegex.exec(aiMessage)) !== null) {
|
||||
const url = match[1].trim();
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
||||
const text = await res.text();
|
||||
const content = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const preview = content.length > 5000 ? content.slice(0, 5000) + "\n... (truncated)" : content;
|
||||
report.push(`🌐 Read URL: ${url}`);
|
||||
this.chatHistory.push({ role: 'system', content: `[Result of read_url ${url}]\n${preview}`, internal: true });
|
||||
} catch (err: any) { report.push(`❌ URL Read failed: ${err.message}`); }
|
||||
}
|
||||
|
||||
// Action 8: Read URL (Simple implementation)
|
||||
const urlRegex = /<read_url>([\s\S]*?)<\/read_url>/gi;
|
||||
while ((match = urlRegex.exec(aiMessage)) !== null) {
|
||||
const url = match[1].trim();
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
||||
const text = await res.text();
|
||||
// Simple HTML to text-ish conversion
|
||||
const content = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const preview = content.length > 5000 ? content.slice(0, 5000) + "\n... (truncated)" : content;
|
||||
report.push(`🌐 Read URL: ${url}`);
|
||||
this.chatHistory.push({ role: 'system', content: `[Result of read_url ${url}]\n${preview}`, internal: true });
|
||||
} catch (err: any) { report.push(`❌ URL Read failed: ${err.message}`); }
|
||||
if (firstCreatedFile) {
|
||||
vscode.window.showTextDocument(vscode.Uri.file(firstCreatedFile), { preview: false });
|
||||
}
|
||||
|
||||
// Brain Sync Logic
|
||||
if (brainModified && shouldAutoPushBrain() && getSecondBrainRepo()) {
|
||||
this.syncBrain();
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
if (config.dryRun) {
|
||||
report.push(`\n⚠️ **Dry Run Mode Active**: 위 변경 사항을 확인하고 [승인] 또는 [롤백]을 선택해주세요.`);
|
||||
this.webview?.postMessage({ type: 'requiresApproval' });
|
||||
// Do NOT commit yet
|
||||
} else {
|
||||
this.transactionManager.commit();
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.transactionManager.rollback();
|
||||
const g1Error = error instanceof AgentExecutionError ? error : new AgentExecutionError(error.message, error);
|
||||
report.push(`🛑 Transaction Failed: ${g1Error.message}. All file changes rolled back.`);
|
||||
logError('Action execution failed, rolled back.', g1Error);
|
||||
// We return the report with the failure message instead of throwing
|
||||
// so the agent can see the failure and decide what to do next
|
||||
}
|
||||
|
||||
if (firstCreatedFile) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { getConfig } from '../config';
|
||||
|
||||
export abstract class BaseAgent {
|
||||
constructor(protected readonly modelName: string) {}
|
||||
|
||||
protected async callLLM(persona: string, prompt: string): Promise<string> {
|
||||
const { ollamaUrl } = getConfig();
|
||||
const messages = [
|
||||
{ role: 'system', content: persona },
|
||||
{ role: 'user', content: prompt }
|
||||
];
|
||||
|
||||
// API 호출 로직 (Streaming 생략하고 결과만 반환하는 헬퍼)
|
||||
const response = await fetch(`${ollamaUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
model: this.modelName,
|
||||
messages,
|
||||
stream: false,
|
||||
options: { temperature: 0.3 }
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Agent API Error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
return data.message?.content || data.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
abstract execute(input: string, context?: string): Promise<string>;
|
||||
}
|
||||
|
||||
export class PlannerAgent extends BaseAgent {
|
||||
private readonly persona = `You are the [Planner Agent]. Analyze the request and output a structured <plan>.`;
|
||||
async execute(input: string, brainContext?: string): Promise<string> {
|
||||
return this.callLLM(this.persona, `Request: ${input}\nContext: ${brainContext}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class ResearcherAgent extends BaseAgent {
|
||||
private readonly persona = `You are the [Researcher Agent]. Gather facts based on the plan.`;
|
||||
async execute(input: string, brainContext?: string): Promise<string> {
|
||||
return this.callLLM(this.persona, `Plan: ${input}\nContext: ${brainContext}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class WriterAgent extends BaseAgent {
|
||||
private readonly persona = `You are the [Writer Agent]. Synthesize research into a final report.`;
|
||||
async execute(input: string, originalRequest?: string): Promise<string> {
|
||||
return this.callLLM(this.persona, `Data: ${input}\nOriginal Request: ${originalRequest}`);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -3,16 +3,17 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
// axios removed
|
||||
import {
|
||||
getConfig,
|
||||
_getBrainDir,
|
||||
_isBrainDirExplicitlySet,
|
||||
findBrainFiles,
|
||||
buildApiUrl,
|
||||
logError,
|
||||
logInfo,
|
||||
logWarn,
|
||||
resolveEngine,
|
||||
summarizeText
|
||||
} from './utils';
|
||||
import { getConfig } from './config';
|
||||
|
||||
export interface BridgeInterface {
|
||||
injectSystemMessage(msg: string): void;
|
||||
|
||||
+120
-64
@@ -1,97 +1,153 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Centralized Configuration (중앙 집중식 설정 관리)
|
||||
*
|
||||
* 모든 환경 설정(모델 이름, API 엔드포인트, 타임아웃, 보안 정책 등)
|
||||
* 을 한 곳에서 관리합니다. Single Source of Truth 원칙 적용.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// ─── VS Code 설정에서 읽어오는 값 ───
|
||||
export function getConfig(): IAgentConfig {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
return {
|
||||
ollamaBase: cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434'),
|
||||
defaultModel: cfg.get<string>('defaultModel', 'gemma4:e2b'),
|
||||
maxTreeFiles: cfg.get<number>('maxTreeFiles', 200),
|
||||
timeout: cfg.get<number>('requestTimeout', 300) * 1000,
|
||||
localBrainPath: cfg.get<string>('localBrainPath', '')
|
||||
};
|
||||
// ─── 브레인 프로필 인터페이스 ───
|
||||
export interface BrainProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
localBrainPath: string;
|
||||
secondBrainRepo?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ─── 에이전트 설정 인터페이스 ───
|
||||
// ─── 에이전트 설정 인터페이스 (통합 버전) ───
|
||||
export interface IAgentConfig {
|
||||
ollamaBase: string;
|
||||
ollamaUrl: string;
|
||||
defaultModel: string;
|
||||
maxTreeFiles: number;
|
||||
timeout: number;
|
||||
localBrainPath: string;
|
||||
secondBrainRepo: string;
|
||||
brainProfiles: BrainProfile[];
|
||||
activeBrainId: string;
|
||||
maxContextSize: number;
|
||||
maxAutoSteps: number;
|
||||
dryRun: boolean;
|
||||
multiAgentEnabled: boolean;
|
||||
}
|
||||
|
||||
// ─── 두뇌 폴더 경로 유틸리티 ───
|
||||
export function getBrainDir(): string {
|
||||
const { localBrainPath } = getConfig();
|
||||
if (localBrainPath && localBrainPath.trim() !== '') {
|
||||
if (localBrainPath.startsWith('~/')) {
|
||||
return path.join(os.homedir(), localBrainPath.substring(2));
|
||||
}
|
||||
return localBrainPath.trim();
|
||||
// ─── 경로 정규화 유틸리티 ───
|
||||
function normalizePath(p: string): string {
|
||||
if (!p) return p;
|
||||
if (p.startsWith('~/')) {
|
||||
return path.join(os.homedir(), p.substring(2));
|
||||
}
|
||||
return path.join(os.homedir(), '.g1nation-brain');
|
||||
return p.trim();
|
||||
}
|
||||
|
||||
export function isBrainDirExplicitlySet(): boolean {
|
||||
const { localBrainPath } = getConfig();
|
||||
return !!(localBrainPath && localBrainPath.trim() !== '');
|
||||
function toBrainProfile(raw: Partial<BrainProfile> | undefined, fallbackIndex: number): BrainProfile | null {
|
||||
if (!raw) return null;
|
||||
const localBrainPath = normalizePath(raw.localBrainPath || '');
|
||||
if (!localBrainPath) return null;
|
||||
return {
|
||||
id: (raw.id || `brain-${fallbackIndex + 1}`).trim(),
|
||||
name: (raw.name || path.basename(localBrainPath) || `Brain ${fallbackIndex + 1}`).trim(),
|
||||
localBrainPath,
|
||||
secondBrainRepo: (raw.secondBrainRepo || '').trim(),
|
||||
description: (raw.description || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
// ─── VS Code 설정에서 읽어오는 값 (통합 구현) ───
|
||||
export function getConfig(): IAgentConfig {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
|
||||
// 브레인 프로필 로직 (utils.ts에서 이관)
|
||||
const legacyBrainPath = cfg.get<string>('localBrainPath', '');
|
||||
const legacyBrainRepo = cfg.get<string>('secondBrainRepo', '');
|
||||
const configuredProfiles = cfg.get<Partial<BrainProfile>[]>('brainProfiles', []);
|
||||
const profiles = configuredProfiles
|
||||
.map((profile, index) => toBrainProfile(profile, index))
|
||||
.filter((profile): profile is BrainProfile => !!profile);
|
||||
|
||||
if (profiles.length === 0) {
|
||||
const fallbackPath = normalizePath(legacyBrainPath) || path.join(os.homedir(), '.g1nation-brain');
|
||||
profiles.push({
|
||||
id: 'default-brain',
|
||||
name: 'Local Brain',
|
||||
localBrainPath: fallbackPath,
|
||||
secondBrainRepo: legacyBrainRepo.trim(),
|
||||
description: legacyBrainPath
|
||||
? 'Migrated from your existing localBrainPath setting'
|
||||
: 'Auto-created local knowledge folder.'
|
||||
});
|
||||
}
|
||||
|
||||
const activeBrainId = cfg.get<string>('activeBrainId', profiles[0].id) || profiles[0].id;
|
||||
const activeBrain = profiles.find((profile) => profile.id === activeBrainId) || profiles[0];
|
||||
|
||||
const rationaleProtocol = `
|
||||
3. Always explain your thought process using the <rationale> tag BEFORE performing any actions. Use the following structure:
|
||||
<rationale>
|
||||
[PROBLEM] Description of the issue or need found in the context.
|
||||
[GOAL] What you intend to achieve with your proposed changes.
|
||||
[REASONING] Detailed logical basis for choosing specific actions or architecture.
|
||||
</rationale>
|
||||
`;
|
||||
|
||||
return {
|
||||
ollamaUrl: cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434',
|
||||
defaultModel: cfg.get<string>('defaultModel', 'gemma4:e2b') || 'gemma4:e2b',
|
||||
maxTreeFiles: 200,
|
||||
timeout: cfg.get<number>('requestTimeout', 300) * 1000,
|
||||
localBrainPath: activeBrain.localBrainPath,
|
||||
secondBrainRepo: activeBrain.secondBrainRepo || '',
|
||||
brainProfiles: profiles,
|
||||
activeBrainId: activeBrain.id,
|
||||
maxContextSize: cfg.get<number>('maxContextSize', 12000),
|
||||
maxAutoSteps: cfg.get<number>('maxAutoSteps', 50),
|
||||
dryRun: cfg.get<boolean>('dryRun', false),
|
||||
multiAgentEnabled: cfg.get<boolean>('multiAgentEnabled', true)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Config Validator: Validates the current configuration.
|
||||
*/
|
||||
export function validateConfig(): { valid: boolean; errors: string[] } {
|
||||
const config = getConfig();
|
||||
const errors: string[] = [];
|
||||
|
||||
// 1. Ollama URL Validation
|
||||
try {
|
||||
new URL(config.ollamaUrl);
|
||||
} catch (e) {
|
||||
errors.push(`Invalid Ollama URL: ${config.ollamaUrl}`);
|
||||
}
|
||||
|
||||
// 2. Brain Path Validation
|
||||
if (config.localBrainPath && !fs.existsSync(config.localBrainPath)) {
|
||||
errors.push(`Brain path does not exist: ${config.localBrainPath}`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 보안 정책 (Security Policy) ───
|
||||
export const SECURITY_POLICY = {
|
||||
// 허용된 터미널 명령어 프리픽스 (Whitelist)
|
||||
allowedCommandPrefixes: [
|
||||
'npm', 'yarn', 'pnpm', 'npx',
|
||||
'node', 'ts-node',
|
||||
'git',
|
||||
'python', 'python3', 'pip', 'pip3',
|
||||
'docker', 'docker-compose',
|
||||
'ls', 'dir', 'cat', 'type',
|
||||
'echo', 'print',
|
||||
'cargo', 'go', 'rustc',
|
||||
'java', 'javac', 'mvn', 'gradle',
|
||||
'flutter', 'dart', 'pub',
|
||||
'webpack', 'vite', 'esbuild', 'parcel',
|
||||
'jest', 'mocha', 'vitest', 'cypress',
|
||||
'tsc', 'vue-tsc',
|
||||
'npm', 'yarn', 'pnpm', 'npx', 'node', 'ts-node', 'git', 'python', 'python3', 'pip', 'pip3',
|
||||
'docker', 'docker-compose', 'ls', 'dir', 'cat', 'type', 'echo', 'print', 'cargo', 'go', 'rustc',
|
||||
'java', 'javac', 'mvn', 'gradle', 'flutter', 'dart', 'pub', 'webpack', 'vite', 'esbuild', 'parcel',
|
||||
'jest', 'mocha', 'vitest', 'cypress', 'tsc', 'vue-tsc',
|
||||
],
|
||||
|
||||
// 절대 실행 금지 명령어 (Blacklist)
|
||||
forbiddenCommands: [
|
||||
'rm -rf', 'rm-rf', 'del /f', 'format',
|
||||
'mkfs', 'dd if=', ':(){ :|:& };:',
|
||||
'wget http', 'curl http', 'sudo',
|
||||
'chmod 777', 'chown root',
|
||||
'rm -rf', 'rm-rf', 'del /f', 'format', 'mkfs', 'dd if=', ':(){ :|:& };:',
|
||||
'wget http', 'curl http', 'sudo', 'chmod 777', 'chown root',
|
||||
],
|
||||
|
||||
// 민감한 파일 패턴 (파일 생성/수정 시 경고)
|
||||
sensitiveFilePatterns: [
|
||||
'.env', '.env.*',
|
||||
'id_rsa', 'id_ed25519',
|
||||
'.gitconfig', '.npmrc', '.pypirc',
|
||||
'.env', '.env.*', 'id_rsa', 'id_ed25519', '.gitconfig', '.npmrc', '.pypirc',
|
||||
'credentials.json', 'service-account.json',
|
||||
],
|
||||
|
||||
// 파일 생성 시 최대 크기 (bytes)
|
||||
maxFileSize: 10 * 1024 * 1024, // 10MB
|
||||
|
||||
// 맥락 파일 최대 개수
|
||||
maxFileSize: 10 * 1024 * 1024,
|
||||
maxContextFiles: 200,
|
||||
};
|
||||
|
||||
// ─── 시스템 프롬프트 상수 ───
|
||||
export const MAX_CONTEXT_SIZE = 12_000;
|
||||
|
||||
export const EXCLUDED_DIRS = new Set([
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { logWarn } from '../utils';
|
||||
|
||||
/**
|
||||
* ConflictResolver: Analyzes agent actions for logical contradictions
|
||||
* or goal conflicts (e.g., Security vs. Performance).
|
||||
*/
|
||||
export class ConflictResolver {
|
||||
/**
|
||||
* Analyzes proposed actions and returns a warning message if conflicts are found.
|
||||
*/
|
||||
public static analyze(actions: any[]): string | null {
|
||||
// 1. Resource Conflict: Multiple edits to sensitive core files
|
||||
const securityFiles = actions.filter(a => a.path && (a.path.includes('auth') || a.path.includes('security') || a.path.includes('config')));
|
||||
const performanceChanges = actions.filter(a => a.content && (a.content.includes('async') || a.content.includes('parallel') || a.content.includes('cache')));
|
||||
|
||||
if (securityFiles.length > 0 && performanceChanges.length > 0) {
|
||||
return "⚠️ Goal Conflict Detected: You are attempting to modify security-sensitive files while introducing performance optimizations (async/parallel). This might introduce race conditions or security vulnerabilities. Should I proceed with both, or prioritize Security?";
|
||||
}
|
||||
|
||||
// 2. Structural Conflict: Modifying both interface and implementation in a way that might break contracts
|
||||
const interfaces = actions.filter(a => a.path && a.path.endsWith('.d.ts') || a.path.includes('interface'));
|
||||
if (interfaces.length > 1 && actions.length > 10) {
|
||||
return "⚠️ Structural Complexity Warning: This task involves massive changes to both interfaces and multiple implementations. This may lead to unexpected side effects. Would you like a step-by-step review?";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface UserFriendlyError {
|
||||
title: string;
|
||||
message: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export class ErrorTranslator {
|
||||
public static translate(error: any): UserFriendlyError {
|
||||
const msg = String(error.message || error).toLowerCase();
|
||||
|
||||
if (msg.includes('fetch') || msg.includes('network') || msg.includes('econnrefused')) {
|
||||
return {
|
||||
title: '🌐 연결 오류 (Connection Error)',
|
||||
message: 'AI 엔진(LM Studio 또는 Ollama)에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요.',
|
||||
action: '서버를 실행한 후 "Refresh" 버튼을 눌러주세요.'
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('timeout')) {
|
||||
return {
|
||||
title: '⏱️ 응답 시간 초과 (Timeout)',
|
||||
message: 'AI가 답변을 준비하는 데 너무 오래 걸리고 있습니다.',
|
||||
action: '설정에서 Timeout 시간을 늘리거나, 더 작은 범위로 질문해보세요.'
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('model not found') || msg.includes('404')) {
|
||||
return {
|
||||
title: '🤖 모델 인식 불가 (Model Not Found)',
|
||||
message: '선택하신 모델을 시스템에서 찾을 수 없습니다.',
|
||||
action: '상단 드롭다운에서 다른 모델을 선택하거나 모델 이름을 확인해주세요.'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: '⚠️ 알 수 없는 오류',
|
||||
message: '작업 중 예상치 못한 문제가 발생했습니다.',
|
||||
action: '로그를 확인하거나 확장을 재시작해보세요.'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* g1nation Custom Error Classes
|
||||
*/
|
||||
|
||||
export abstract class G1Error extends Error {
|
||||
constructor(public message: string, public details?: any) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
|
||||
abstract getTypeCode(): string;
|
||||
}
|
||||
|
||||
export class AgentExecutionError extends G1Error {
|
||||
getTypeCode() { return 'AGENT_EXECUTION_ERROR'; }
|
||||
}
|
||||
|
||||
export class FileSystemError extends G1Error {
|
||||
constructor(message: string, public path: string, details?: any) {
|
||||
super(message, details);
|
||||
}
|
||||
getTypeCode() { return 'FILE_SYSTEM_ERROR'; }
|
||||
}
|
||||
|
||||
export class APICommunicationError extends G1Error {
|
||||
constructor(message: string, public engine: string, public status?: number, details?: any) {
|
||||
super(message, details);
|
||||
}
|
||||
getTypeCode() { return 'API_COMMUNICATION_ERROR'; }
|
||||
}
|
||||
|
||||
export class SecurityValidationError extends G1Error {
|
||||
getTypeCode() { return 'SECURITY_VALIDATION_ERROR'; }
|
||||
}
|
||||
|
||||
export class TransactionError extends G1Error {
|
||||
getTypeCode() { return 'TRANSACTION_ERROR'; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import { getConfig } from '../config';
|
||||
import { logInfo, logWarn, logError } from '../utils';
|
||||
|
||||
/**
|
||||
* HealthCheckMonitor: Periodically monitors the environment
|
||||
* (Ollama, Disk, API) to ensure the agent stays functional.
|
||||
*/
|
||||
export class HealthCheckMonitor {
|
||||
public static async runAllChecks(): Promise<{ ok: boolean; reports: string[] }> {
|
||||
const reports: string[] = [];
|
||||
const config = getConfig();
|
||||
|
||||
// 1. Ollama Connectivity Check
|
||||
try {
|
||||
const res = await fetch(`${config.ollamaUrl}/api/tags`, { signal: AbortSignal.timeout(3000) });
|
||||
if (res.ok) {
|
||||
logInfo('Health Check: Ollama connectivity OK.');
|
||||
} else {
|
||||
reports.push('⚠️ AI Server (Ollama) is reachable but returned an error.');
|
||||
}
|
||||
} catch {
|
||||
reports.push('❌ AI Server (Ollama) is NOT reachable. Please check if it is running.');
|
||||
}
|
||||
|
||||
// 2. Workspace Validation
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
if (!workspaceFolders || workspaceFolders.length === 0) {
|
||||
reports.push('⚠️ No workspace folder open. Agent capabilities will be limited.');
|
||||
}
|
||||
|
||||
// 3. Simple Disk/Permissions Check
|
||||
try {
|
||||
const testFile = workspaceFolders ? vscode.Uri.joinPath(workspaceFolders[0].uri, '.g1-health-check') : null;
|
||||
if (testFile) {
|
||||
await vscode.workspace.fs.writeFile(testFile, Buffer.from('ok'));
|
||||
await vscode.workspace.fs.delete(testFile);
|
||||
logInfo('Health Check: Write permissions OK.');
|
||||
}
|
||||
} catch {
|
||||
reports.push('❌ Write permissions denied in the current workspace.');
|
||||
}
|
||||
|
||||
if (reports.length > 0) {
|
||||
logWarn(`Health Check Warnings: ${reports.join(' | ')}`);
|
||||
vscode.window.showWarningMessage(`ConnectAI Health Warning: ${reports[0]}`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: reports.length === 0,
|
||||
reports
|
||||
};
|
||||
}
|
||||
|
||||
public static startInterval(ms: number = 300000) { // Default 5 mins
|
||||
setInterval(() => this.runAllChecks(), ms);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { logInfo } from '../utils';
|
||||
|
||||
/**
|
||||
* AsyncLockManager: Prevents race conditions by ensuring only one task
|
||||
* can access a specific resource (e.g., a file path) at a time.
|
||||
*/
|
||||
export class AsyncLockManager {
|
||||
private locks: Map<string, Promise<void>> = new Map();
|
||||
|
||||
/**
|
||||
* Acquires a lock for a specific resource.
|
||||
* If the resource is already locked, it waits until the previous task finishes.
|
||||
*/
|
||||
public async acquire(resourceId: string): Promise<() => void> {
|
||||
const previousLock = this.locks.get(resourceId) || Promise.resolve();
|
||||
|
||||
let release: () => void;
|
||||
const newLock = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
this.locks.set(resourceId, previousLock.then(() => newLock));
|
||||
|
||||
await previousLock;
|
||||
logInfo(`Lock acquired for: ${resourceId}`);
|
||||
|
||||
return () => {
|
||||
logInfo(`Lock released for: ${resourceId}`);
|
||||
release();
|
||||
if (this.locks.get(resourceId) === newLock) {
|
||||
this.locks.delete(resourceId);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export as a singleton for the entire agent process
|
||||
export const lockManager = new AsyncLockManager();
|
||||
@@ -0,0 +1,53 @@
|
||||
import { logInfo, logError } from '../utils';
|
||||
|
||||
/**
|
||||
* ActionQueueManager: Manages large-scale tasks by processing them
|
||||
* sequentially to prevent resource exhaustion and I/O bottlenecks.
|
||||
*/
|
||||
export class ActionQueueManager {
|
||||
private queue: (() => Promise<void>)[] = [];
|
||||
private isProcessing: boolean = false;
|
||||
|
||||
/**
|
||||
* Adds a task to the queue.
|
||||
*/
|
||||
public async enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.queue.push(async () => {
|
||||
try {
|
||||
const result = await task();
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
this.processNext();
|
||||
});
|
||||
}
|
||||
|
||||
private async processNext() {
|
||||
if (this.isProcessing || this.queue.length === 0) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
const task = this.queue.shift();
|
||||
|
||||
if (task) {
|
||||
try {
|
||||
// Add a micro-delay to allow system breathing room between heavy I/O
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
await task();
|
||||
} catch (error) {
|
||||
logError('Task in queue failed:', error);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
this.processNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getPendingCount(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
}
|
||||
|
||||
export const actionQueue = new ActionQueueManager();
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ChatMessage } from '../agent';
|
||||
import { logInfo, logError } from '../utils';
|
||||
|
||||
interface SessionData {
|
||||
taskId: string;
|
||||
history: ChatMessage[];
|
||||
lastActionStr?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private sessionDir: string;
|
||||
|
||||
constructor(private context: vscode.ExtensionContext) {
|
||||
// Use globalStorageUri for persistence across workspace restarts
|
||||
this.sessionDir = path.join(this.context.globalStorageUri.fsPath, 'sessions');
|
||||
this.ensureDir(this.sessionDir);
|
||||
}
|
||||
|
||||
private ensureDir(dir: string) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current session state.
|
||||
*/
|
||||
public async saveSession(taskId: string, history: ChatMessage[], lastActionStr?: string) {
|
||||
const sessionData: SessionData = {
|
||||
taskId,
|
||||
history,
|
||||
lastActionStr,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
const filePath = path.join(this.sessionDir, `session_${this.sanitizeFilename(taskId)}.json`);
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(sessionData, null, 2), 'utf-8');
|
||||
// Also store the last active taskId in globalState
|
||||
await this.context.globalState.update('activeTaskId', taskId);
|
||||
} catch (error: any) {
|
||||
logError('Failed to save session state', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a specific session by taskId.
|
||||
*/
|
||||
public loadSession(taskId: string): SessionData | null {
|
||||
const filePath = path.join(this.sessionDir, `session_${this.sanitizeFilename(taskId)}.json`);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content) as SessionData;
|
||||
} catch (error: any) {
|
||||
logError(`Failed to load session for taskId: ${taskId}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the last active session.
|
||||
*/
|
||||
public loadLastActiveSession(): SessionData | null {
|
||||
const lastTaskId = this.context.globalState.get<string>('activeTaskId');
|
||||
if (!lastTaskId) return null;
|
||||
return this.loadSession(lastTaskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a specific session.
|
||||
*/
|
||||
public clearSession(taskId: string) {
|
||||
const filePath = path.join(this.sessionDir, `session_${this.sanitizeFilename(taskId)}.json`);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeFilename(name: string): string {
|
||||
return name.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export enum AgentStatus {
|
||||
Idle = 'Idle',
|
||||
Thinking = 'Thinking...',
|
||||
Executing = 'Executing Actions...',
|
||||
Error = 'Error',
|
||||
Success = 'Success'
|
||||
}
|
||||
|
||||
export class StatusBarManager {
|
||||
private statusBarItem: vscode.StatusBarItem;
|
||||
|
||||
constructor() {
|
||||
this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
||||
this.statusBarItem.command = 'g1nation.focusInput';
|
||||
this.updateStatus(AgentStatus.Idle);
|
||||
this.statusBarItem.show();
|
||||
}
|
||||
|
||||
public updateStatus(status: AgentStatus, detail?: string) {
|
||||
let icon = '';
|
||||
switch (status) {
|
||||
case AgentStatus.Idle:
|
||||
icon = '$(pass)';
|
||||
this.statusBarItem.backgroundColor = undefined;
|
||||
break;
|
||||
case AgentStatus.Thinking:
|
||||
icon = '$(sync~spin)';
|
||||
this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
||||
break;
|
||||
case AgentStatus.Executing:
|
||||
icon = '$(gear~spin)';
|
||||
this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
||||
break;
|
||||
case AgentStatus.Error:
|
||||
icon = '$(error)';
|
||||
this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
|
||||
break;
|
||||
case AgentStatus.Success:
|
||||
icon = '$(check)';
|
||||
this.statusBarItem.backgroundColor = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
this.statusBarItem.text = `${icon} G1nation: ${status}`;
|
||||
this.statusBarItem.tooltip = detail || `Current State: ${status}`;
|
||||
|
||||
if (status === AgentStatus.Success || status === AgentStatus.Error) {
|
||||
setTimeout(() => this.updateStatus(AgentStatus.Idle), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.statusBarItem.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { FileSystemError, TransactionError } from './errors';
|
||||
import { logInfo, logError } from '../utils';
|
||||
|
||||
interface BackupEntry {
|
||||
path: string;
|
||||
type: 'modified' | 'created' | 'deleted';
|
||||
originalContent?: string;
|
||||
}
|
||||
|
||||
export class TransactionManager {
|
||||
private backups: Map<string, BackupEntry> = new Map();
|
||||
private externalVerifications: Map<string, boolean> = new Map();
|
||||
private isTransactionActive: boolean = false;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Starts a new transaction.
|
||||
*/
|
||||
public begin() {
|
||||
if (this.isTransactionActive) {
|
||||
throw new TransactionError('A transaction is already in progress.');
|
||||
}
|
||||
this.backups.clear();
|
||||
this.externalVerifications.clear();
|
||||
this.isTransactionActive = true;
|
||||
logInfo('Transaction started.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the success of an external action (e.g., API call).
|
||||
*/
|
||||
public recordExternalAction(actionId: string, success: boolean) {
|
||||
if (!this.isTransactionActive) return;
|
||||
this.externalVerifications.set(actionId, success);
|
||||
logInfo(`External action recorded: ${actionId} (success: ${success})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if all recorded external actions were successful.
|
||||
*/
|
||||
public isFullyVerified(): boolean {
|
||||
for (const success of this.externalVerifications.values()) {
|
||||
if (!success) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a file state before modification.
|
||||
*/
|
||||
public async record(filePath: string) {
|
||||
if (!this.isTransactionActive) return;
|
||||
if (this.backups.has(filePath)) return; // Already backed up in this transaction
|
||||
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
this.backups.set(filePath, {
|
||||
path: filePath,
|
||||
type: 'modified',
|
||||
originalContent: content
|
||||
});
|
||||
} else {
|
||||
this.backups.set(filePath, {
|
||||
path: filePath,
|
||||
type: 'created'
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new FileSystemError(`Failed to backup file: ${error.message}`, filePath, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits the transaction, clearing all backups.
|
||||
*/
|
||||
public commit() {
|
||||
if (!this.isTransactionActive) return;
|
||||
this.backups.clear();
|
||||
this.isTransactionActive = false;
|
||||
logInfo('Transaction committed successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolls back all changes made during the transaction.
|
||||
*/
|
||||
public rollback() {
|
||||
if (!this.isTransactionActive) return;
|
||||
|
||||
logInfo(`Rolling back ${this.backups.size} changes...`);
|
||||
|
||||
for (const entry of this.backups.values()) {
|
||||
try {
|
||||
if (entry.type === 'created') {
|
||||
if (fs.existsSync(entry.path)) {
|
||||
fs.unlinkSync(entry.path);
|
||||
logInfo(`Rollback: Deleted created file ${entry.path}`);
|
||||
}
|
||||
} else if (entry.type === 'modified' || entry.type === 'deleted') {
|
||||
if (entry.originalContent !== undefined) {
|
||||
const dir = path.dirname(entry.path);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(entry.path, entry.originalContent, 'utf-8');
|
||||
logInfo(`Rollback: Restored file ${entry.path}`);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logError(`Failed to rollback change for ${entry.path}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.backups.clear();
|
||||
this.isTransactionActive = false;
|
||||
logInfo('Transaction rollback completed.');
|
||||
}
|
||||
|
||||
public isActive(): boolean {
|
||||
return this.isTransactionActive;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance if needed, or instantiate per AgentExecutor
|
||||
+14
-2
@@ -3,7 +3,6 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
// axios removed in favor of native fetch
|
||||
import {
|
||||
getConfig,
|
||||
_getBrainDir,
|
||||
_isBrainDirExplicitlySet,
|
||||
findBrainFiles,
|
||||
@@ -12,15 +11,28 @@ import {
|
||||
logError,
|
||||
logInfo
|
||||
} from './utils';
|
||||
import { getConfig, validateConfig } from './config';
|
||||
import { AgentExecutor } from './agent';
|
||||
import { BridgeServer } from './bridge';
|
||||
import { SidebarChatProvider } from './sidebarProvider';
|
||||
import { HealthCheckMonitor } from './core/health';
|
||||
|
||||
/**
|
||||
* G1nation Extension Entry Point
|
||||
*/
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
logInfo('Connect AI extension activated.');
|
||||
logInfo('ConnectAI activating...');
|
||||
|
||||
// Start Environment Health Monitoring
|
||||
HealthCheckMonitor.runAllChecks();
|
||||
HealthCheckMonitor.startInterval(600000); // Check every 10 mins
|
||||
|
||||
// 0. Validate Configuration
|
||||
const validation = validateConfig();
|
||||
if (!validation.valid) {
|
||||
vscode.window.showErrorMessage(`G1nation Configuration Error: ${validation.errors.join(' ')}`);
|
||||
logError('Configuration validation failed.', { errors: validation.errors });
|
||||
}
|
||||
|
||||
// 1. Ensure Brain Directory
|
||||
await _ensureBrainDir(context);
|
||||
|
||||
+381
-15
@@ -2,7 +2,6 @@ import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
getConfig,
|
||||
_getBrainDir,
|
||||
findBrainFiles,
|
||||
buildApiUrl,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
resolveEngine,
|
||||
summarizeText
|
||||
} from './utils';
|
||||
import { getConfig } from './config';
|
||||
import { AgentExecutor, ChatMessage } from './agent';
|
||||
import { BridgeInterface } from './bridge';
|
||||
|
||||
@@ -88,8 +88,12 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
await this._sendBrainProfiles();
|
||||
await this._sendSessionList();
|
||||
await this._sendModels();
|
||||
await this._sendConfig();
|
||||
await this._restoreActiveSessionIntoView();
|
||||
break;
|
||||
case 'toggleMultiAgent':
|
||||
await vscode.workspace.getConfiguration('g1nation').update('multiAgentEnabled', data.value, vscode.ConfigurationTarget.Global);
|
||||
break;
|
||||
case 'getModels':
|
||||
await this._sendModels();
|
||||
break;
|
||||
@@ -128,6 +132,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
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;
|
||||
@@ -143,6 +150,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
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');
|
||||
@@ -155,6 +169,12 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
vscode.window.showInformationMessage(`✅ Exported to ${path.basename(uri.fsPath)}`);
|
||||
}
|
||||
break;
|
||||
case 'approveAction':
|
||||
await this._agent.approveTransaction();
|
||||
break;
|
||||
case 'rejectAction':
|
||||
await this._agent.rejectTransaction();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -378,6 +398,17 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
await this._context.globalState.update('chat_sessions', sessions.slice(0, 50));
|
||||
}
|
||||
|
||||
private async _sendConfig() {
|
||||
if (!this._view) return;
|
||||
const config = getConfig();
|
||||
this._view.webview.postMessage({
|
||||
type: 'configUpdate',
|
||||
value: {
|
||||
multiAgentEnabled: config.multiAgentEnabled
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _sendBrainStatus() {
|
||||
if (!this._view) return;
|
||||
const activeBrain = getActiveBrainProfile();
|
||||
@@ -631,6 +662,29 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
this._view.webview.postMessage({ type: 'agentsList', value: agents, selected: lastPath });
|
||||
}
|
||||
|
||||
private async _handleProactiveSuggestion(context: string) {
|
||||
if (!this._view) return;
|
||||
|
||||
let suggestion = '';
|
||||
switch (context) {
|
||||
case 'settings_exploration':
|
||||
suggestion = '💡 **Tip:** 모델 설정을 최적화하여 답변 속도를 2배 이상 높일 수 있습니다. 설정에서 `Max Context Size`를 조정해보세요!';
|
||||
break;
|
||||
case 'brain_sync_exploration':
|
||||
suggestion = '🧠 **Knowledge Sync:** 최근에 수정한 파일이 지식 베이스에 반영되지 않았나요? 지금 동기화 버튼을 눌러 최신 정보를 업데이트하세요.';
|
||||
break;
|
||||
case 'agent_selection_exploration':
|
||||
suggestion = '🤖 **Agent Skills:** 특정 언어나 프레임워크에 특화된 에이전트 스킬을 선택하면 더 정확한 코드를 생성할 수 있습니다.';
|
||||
break;
|
||||
default:
|
||||
suggestion = '💡 새로운 기능을 발견하셨나요? 궁금한 점이 있다면 언제든 물어보세요!';
|
||||
}
|
||||
|
||||
this._view.webview.postMessage({ type: 'streamStart' });
|
||||
this._view.webview.postMessage({ type: 'streamChunk', value: `\n\n> [!TIP]\n> ${suggestion}\n` });
|
||||
this._view.webview.postMessage({ type: 'streamEnd' });
|
||||
}
|
||||
|
||||
private async _createAgent() {
|
||||
const name = await vscode.window.showInputBox({
|
||||
prompt: 'Name of the new Agent (e.g., frontend_expert)',
|
||||
@@ -891,8 +945,24 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
|
||||
.msg-head { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 11px; color: var(--text-dim); }
|
||||
.av { width: 22px; height: 22px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; }
|
||||
.av-user { background: var(--border); color: var(--text-primary); }
|
||||
.av-ai { background: var(--accent); color: #fff; }
|
||||
.icon-btn:hover { background: var(--border); color: var(--text-bright); }
|
||||
.icon-btn.active { color: var(--accent); border-color: var(--accent); background: rgba(187, 134, 252, 0.1); }
|
||||
|
||||
/* 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; }
|
||||
|
||||
.header-controls { display: flex; gap: 8px; margin-left: auto; }
|
||||
|
||||
#promptInput::placeholder { color: var(--accent); opacity: 0.6; font-weight: 500; }
|
||||
|
||||
|
||||
.msg-body {
|
||||
padding-left: 30px;
|
||||
@@ -997,11 +1067,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
|
||||
/* --- Overlays & Others --- */
|
||||
.thinking-bar { height: 2px; background: transparent; position: relative; overflow: hidden; margin-top: -1px; }
|
||||
.thinking-bar.active::after {
|
||||
content: ''; position: absolute; top: 0; left: -40%; width: 40%; height: 100%;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent); animation: think 1.5s infinite;
|
||||
.thinking-bar.active {
|
||||
display: block;
|
||||
background: linear-gradient(90deg, transparent, #2196f3, #bb86fc, transparent);
|
||||
background-size: 200% 100%;
|
||||
animation: thinking 1.5s infinite linear;
|
||||
}
|
||||
@keyframes think { 0% { left: -40%; } 100% { left: 100%; } }
|
||||
@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);
|
||||
@@ -1025,6 +1097,140 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
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); }
|
||||
|
||||
/* --- 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); }
|
||||
|
||||
/* --- Rationale View (Thought Process) --- */
|
||||
.rationale-container {
|
||||
margin: 12px 0;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 4px 12px 12px 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.rationale-header {
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--accent);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.rationale-section {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.rationale-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.rationale-content {
|
||||
color: var(--text-dim);
|
||||
padding-left: 20px;
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(-10px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -1043,9 +1249,10 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
<select id="agentSel" title="Select Agentic Skill"></select>
|
||||
<button class="icon-btn" id="editAgentBtn" title="Edit Agent Skill">📝</button>
|
||||
<button class="icon-btn" id="addAgentBtn" title="Create Agent">+</button>
|
||||
<button class="icon-btn" id="internetBtn" title="Internet Access">🌐</button>
|
||||
<button class="icon-btn" id="brainBtn" title="Sync Knowledge">🧠</button>
|
||||
<button class="icon-btn" id="historyBtn" title="History">📜</button>
|
||||
<button class="icon-btn" id="multiAgentBtn" data-tooltip="Multi-Agent Mode">🤖</button>
|
||||
<button class="icon-btn" id="internetBtn" data-tooltip="Internet Access">🌐</button>
|
||||
<button class="icon-btn" id="brainBtn" data-tooltip="Sync Knowledge">🧠</button>
|
||||
<button class="icon-btn" id="historyBtn" data-tooltip="View History">📜</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1059,6 +1266,15 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
|
||||
<div class="thinking-bar" id="thinkingBar"></div>
|
||||
|
||||
<div id="stepper" class="stepper-container">
|
||||
<div class="steps">
|
||||
<div class="step" id="step-analyze"><div class="step-dot"></div><div class="step-label">Analyze</div></div>
|
||||
<div class="step" id="step-plan"><div class="step-dot"></div><div class="step-label">Plan</div></div>
|
||||
<div class="step" id="step-execute"><div class="step-dot"></div><div class="step-label">Execute</div></div>
|
||||
<div class="step" id="step-verify"><div class="step-dot"></div><div class="step-label">Verify</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat" id="chat">
|
||||
<div class="welcome">
|
||||
<div class="welcome-logo">✦</div>
|
||||
@@ -1088,6 +1304,11 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
<button id="sendBtn" class="send-btn">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<button class="action-btn" style="flex:1" id="inputNewChatBtn">New Chat</button>
|
||||
<button class="action-btn" style="flex:1" id="inputSyncBtn">Sync Knowledge</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="fileInput" multiple hidden accept="image/*,.txt,.md,.pdf,.csv,.json,.js,.ts,.py,.java,.rs,.go">
|
||||
</div>
|
||||
|
||||
@@ -1098,6 +1319,52 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
const thinkingBar = document.getElementById('thinkingBar');
|
||||
const statusLabel = document.getElementById('statusLabel');
|
||||
const stepper = document.getElementById('stepper');
|
||||
|
||||
// --- 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');
|
||||
@@ -1133,16 +1400,45 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
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) {
|
||||
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;
|
||||
|
||||
// If rationale exists and it's an AI message, add the Rationale View
|
||||
if (!isUser && rationale && (rationale.problem || rationale.goal || rationale.reasoning)) {
|
||||
const ratDiv = document.createElement('div');
|
||||
ratDiv.className = 'rationale-container';
|
||||
let ratHtml = '<div class="rationale-header"><span>🧠</span> Thought Process</div>';
|
||||
if (rationale.problem) {
|
||||
ratHtml += '<div class="rationale-section"><div class="rationale-label"><span>⚠️</span> Problem</div><div class="rationale-content">' + rationale.problem + '</div></div>';
|
||||
}
|
||||
if (rationale.goal) {
|
||||
ratHtml += '<div class="rationale-section"><div class="rationale-label"><span>💡</span> Goal</div><div class="rationale-content">' + rationale.goal + '</div></div>';
|
||||
}
|
||||
if (rationale.reasoning) {
|
||||
ratHtml += '<div class="rationale-section"><div class="rationale-label"><span>✅</span> Rationale</div><div class="rationale-content">' + rationale.reasoning + '</div></div>';
|
||||
}
|
||||
ratDiv.innerHTML = ratHtml;
|
||||
chat.appendChild(ratDiv);
|
||||
}
|
||||
|
||||
const head = document.createElement('div');
|
||||
head.className = 'msg-head';
|
||||
head.innerHTML = isUser ? '<div class="av av-user">U</div> You' : '<div class="av av-ai">✦</div> G1nation';
|
||||
@@ -1179,6 +1475,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
window.addEventListener('message', e => {
|
||||
const msg = e.data;
|
||||
switch(msg.type) {
|
||||
case 'addMessage':
|
||||
addMsg(msg.value, msg.role, msg.rationale);
|
||||
break;
|
||||
case 'streamStart':
|
||||
thinkingBar.classList.remove('active');
|
||||
if (document.querySelector('.welcome')) document.querySelector('.welcome').remove();
|
||||
@@ -1196,6 +1495,8 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
case 'streamEnd':
|
||||
if (streamBody) streamBody.classList.remove('stream-active');
|
||||
streamBody = null; sendBtn.disabled = false;
|
||||
resetStepper();
|
||||
Sound.success();
|
||||
break;
|
||||
case 'restoreHistory':
|
||||
case 'sessionLoaded':
|
||||
@@ -1204,7 +1505,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
|
||||
if (history && history.length > 0) {
|
||||
chat.innerHTML = '';
|
||||
history.forEach(m => addMsg(m.content, m.role === 'assistant' ? 'assistant' : 'user'));
|
||||
history.forEach(m => addMsg(m.content, m.role === 'assistant' ? 'assistant' : 'user', m.rationale));
|
||||
}
|
||||
if (historyData.negativePrompt !== undefined) {
|
||||
negativePrompt.value = historyData.negativePrompt;
|
||||
@@ -1224,6 +1525,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
if (m === msg.value.selected) o.selected = true;
|
||||
modelSel.appendChild(o);
|
||||
});
|
||||
if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder();
|
||||
statusLabel.innerText = \`Model: \${msg.value.selected}\`;
|
||||
break;
|
||||
case 'brainProfiles':
|
||||
@@ -1251,6 +1553,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
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':
|
||||
@@ -1272,6 +1577,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
thinkingBar.classList.remove('active'); sendBtn.disabled = false;
|
||||
addMsg(msg.value, 'error');
|
||||
break;
|
||||
case 'requiresApproval':
|
||||
const box = document.createElement('div');
|
||||
box.className = 'approval-box';
|
||||
box.innerHTML = '<div class="approval-title"><span>🛡️</span> 작업 승인 대기 중 (Action Approval Required)</div>' +
|
||||
'<div style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">위의 변경 사항을 프로젝트에 반영할까요?</div>' +
|
||||
'<div class="approval-btns">' +
|
||||
' <button class="btn-approve" onclick="approve()">승인 (Approve)</button>' +
|
||||
' <button class="btn-reject" onclick="reject()">롤백 (Rollback)</button>' +
|
||||
'</div>';
|
||||
chat.appendChild(box);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1327,16 +1644,36 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
});
|
||||
input.addEventListener('input', () => { input.style.height = 'auto'; input.style.height = input.scrollHeight + 'px'; });
|
||||
|
||||
document.getElementById('newChatBtn').onclick = () => vscode.postMessage({ type: 'newChat' });
|
||||
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('brainBtn').onclick = () => vscode.postMessage({ type: 'syncBrain' });
|
||||
|
||||
let multiAgentEnabled = true;
|
||||
document.getElementById('multiAgentBtn').onclick = () => {
|
||||
multiAgentEnabled = !multiAgentEnabled;
|
||||
vscode.postMessage({ type: 'toggleMultiAgent', value: multiAgentEnabled });
|
||||
document.getElementById('multiAgentBtn').classList.toggle('active', multiAgentEnabled);
|
||||
};
|
||||
|
||||
const syncBrain = () => { Sound.play(550, 'sine', 0.1); vscode.postMessage({ type: 'syncBrain' }); };
|
||||
document.getElementById('brainBtn').onclick = syncBrain;
|
||||
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');
|
||||
modelSel.onchange = () => vscode.postMessage({ type: 'refreshModels' });
|
||||
const updateInputPlaceholder = () => {
|
||||
promptInput.placeholder = \`Ask \${modelSel.value}...\`;
|
||||
};
|
||||
|
||||
modelSel.onchange = () => {
|
||||
vscode.postMessage({ type: 'model', value: modelSel.value });
|
||||
updateInputPlaceholder();
|
||||
};
|
||||
brainSel.onchange = () => {
|
||||
if (brainSel.value === 'new') {
|
||||
vscode.postMessage({ type: 'addBrain' });
|
||||
@@ -1345,6 +1682,16 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
}
|
||||
};
|
||||
|
||||
// Handle initial state and state updates from extension
|
||||
window.addEventListener('message', e => {
|
||||
const msg = e.data;
|
||||
if (msg.type === 'configUpdate') {
|
||||
multiAgentEnabled = msg.value.multiAgentEnabled;
|
||||
document.getElementById('multiAgentBtn').classList.toggle('active', multiAgentEnabled);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
agentSel.onchange = () => {
|
||||
if (agentSel.value !== 'none') {
|
||||
vscode.postMessage({ type: 'getAgentContent', path: agentSel.value });
|
||||
@@ -1381,6 +1728,25 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
vscode.postMessage({ type: 'getModels' });
|
||||
vscode.postMessage({ type: 'getAgents' });
|
||||
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');
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
+7
-97
@@ -3,103 +3,7 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export const EXCLUDED_DIRS = new Set([
|
||||
'node_modules', '.git', '.vscode', 'out', 'dist', 'build',
|
||||
'.next', '.cache', '__pycache__', '.DS_Store', 'coverage',
|
||||
'.turbo', '.nuxt', '.output', 'vendor', 'target'
|
||||
]);
|
||||
|
||||
// Configuration constants moved to package.json and getConfig()
|
||||
|
||||
export interface BrainProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
localBrainPath: string;
|
||||
secondBrainRepo?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
if (!p) return p;
|
||||
if (p.startsWith('~/')) {
|
||||
return path.join(os.homedir(), p.substring(2));
|
||||
}
|
||||
return p.trim();
|
||||
}
|
||||
|
||||
function toBrainProfile(raw: Partial<BrainProfile> | undefined, fallbackIndex: number): BrainProfile | null {
|
||||
if (!raw) return null;
|
||||
const localBrainPath = normalizePath(raw.localBrainPath || '');
|
||||
if (!localBrainPath) return null;
|
||||
return {
|
||||
id: (raw.id || `brain-${fallbackIndex + 1}`).trim(),
|
||||
name: (raw.name || path.basename(localBrainPath) || `Brain ${fallbackIndex + 1}`).trim(),
|
||||
localBrainPath,
|
||||
secondBrainRepo: (raw.secondBrainRepo || '').trim(),
|
||||
description: (raw.description || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
const STEVE_SKILL_PATH = '/Volumes/Data/project/Antigravity/Agent/.agent/skills/steve_jobs/SKILL.md';
|
||||
|
||||
function extractSteveRuntimeSection(skillContent: string): string {
|
||||
const sectionStart = skillContent.indexOf('## 🎯 Steve Single-Contact Runtime Protocol');
|
||||
if (sectionStart < 0) return '';
|
||||
const sectionEnd = skillContent.indexOf('### 4. The "One More Thing" Mandate', sectionStart);
|
||||
return skillContent.slice(sectionStart, sectionEnd > sectionStart ? sectionEnd : undefined).trim();
|
||||
}
|
||||
|
||||
function loadSteveRuntimePrompt(): string {
|
||||
try {
|
||||
if (!fs.existsSync(STEVE_SKILL_PATH)) return '';
|
||||
const skillContent = fs.readFileSync(STEVE_SKILL_PATH, 'utf-8');
|
||||
return extractSteveRuntimeSection(skillContent);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const legacyBrainPath = cfg.get<string>('localBrainPath', '');
|
||||
const legacyBrainRepo = cfg.get<string>('secondBrainRepo', '');
|
||||
const configuredProfiles = cfg.get<Partial<BrainProfile>[]>('brainProfiles', []);
|
||||
const profiles = configuredProfiles
|
||||
.map((profile, index) => toBrainProfile(profile, index))
|
||||
.filter((profile): profile is BrainProfile => !!profile);
|
||||
|
||||
// IMPORTANT: This virtual default-brain exists only in memory at runtime.
|
||||
// It must NEVER be written back to the settings file (g1nation.brainProfiles).
|
||||
// _addBrainProfile() reads cfg.get('brainProfiles') directly to avoid this contamination.
|
||||
if (profiles.length === 0) {
|
||||
const fallbackPath = normalizePath(legacyBrainPath) || path.join(os.homedir(), '.g1nation-brain');
|
||||
profiles.push({
|
||||
id: 'default-brain',
|
||||
name: 'Local Brain',
|
||||
localBrainPath: fallbackPath,
|
||||
secondBrainRepo: legacyBrainRepo.trim(),
|
||||
description: legacyBrainPath
|
||||
? 'Migrated from your existing localBrainPath setting'
|
||||
: 'Auto-created local knowledge folder. Add a real brain via the ✎ button.'
|
||||
});
|
||||
}
|
||||
|
||||
const activeBrainId = cfg.get<string>('activeBrainId', profiles[0].id) || profiles[0].id;
|
||||
const activeBrain = profiles.find((profile) => profile.id === activeBrainId) || profiles[0];
|
||||
|
||||
return {
|
||||
ollamaUrl: cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434'),
|
||||
defaultModel: cfg.get<string>('defaultModel', 'gemma4:e2b'),
|
||||
maxTreeFiles: 200,
|
||||
timeout: cfg.get<number>('requestTimeout', 300) * 1000,
|
||||
localBrainPath: activeBrain.localBrainPath,
|
||||
secondBrainRepo: activeBrain.secondBrainRepo || '',
|
||||
brainProfiles: profiles,
|
||||
activeBrainId: activeBrain.id,
|
||||
maxContextSize: cfg.get<number>('maxContextSize', 12000),
|
||||
maxAutoSteps: cfg.get<number>('maxAutoSteps', 50)
|
||||
};
|
||||
}
|
||||
import { getConfig, BrainProfile, EXCLUDED_DIRS } from './config';
|
||||
|
||||
export type EngineKind = 'lmstudio' | 'ollama';
|
||||
|
||||
@@ -243,6 +147,12 @@ Core behavior:
|
||||
- Use the active Local Brain only when it is relevant to the user's question. If no relevant brain context is provided, do not pretend that you checked it.
|
||||
- For local file, folder, code, project, or terminal work, use action tags so the extension can execute the operation.
|
||||
- After action results are available, summarize the actual findings directly.
|
||||
- ALWAYS explain your thought process using the <rationale> tag BEFORE performing any actions. Use the following structure:
|
||||
<rationale>
|
||||
[PROBLEM] Description of the issue or need found in the context.
|
||||
[GOAL] What you intend to achieve with your proposed changes.
|
||||
[REASONING] Detailed logical basis for choosing specific actions or architecture.
|
||||
</rationale>
|
||||
|
||||
Available action tags:
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/// <reference types="jest" />
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { TransactionManager } from '../src/core/transaction';
|
||||
|
||||
describe('TransactionManager', () => {
|
||||
let tm: TransactionManager;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tm = new TransactionManager();
|
||||
testDir = path.join(os.tmpdir(), `g1-test-${Date.now()}`);
|
||||
if (!fs.existsSync(testDir)) {
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('should record and rollback file creation', () => {
|
||||
const testFile = path.join(testDir, 'test.txt');
|
||||
|
||||
tm.begin();
|
||||
tm.record(testFile);
|
||||
fs.writeFileSync(testFile, 'hello world');
|
||||
|
||||
expect(fs.existsSync(testFile)).toBe(true);
|
||||
|
||||
tm.rollback();
|
||||
|
||||
expect(fs.existsSync(testFile)).toBe(false);
|
||||
});
|
||||
|
||||
test('should record and rollback file modification', () => {
|
||||
const testFile = path.join(testDir, 'edit.txt');
|
||||
fs.writeFileSync(testFile, 'original');
|
||||
|
||||
tm.begin();
|
||||
tm.record(testFile);
|
||||
fs.writeFileSync(testFile, 'modified');
|
||||
|
||||
expect(fs.readFileSync(testFile, 'utf8')).toBe('modified');
|
||||
|
||||
tm.rollback();
|
||||
|
||||
expect(fs.readFileSync(testFile, 'utf8')).toBe('original');
|
||||
});
|
||||
|
||||
test('should delete backup files after commit', () => {
|
||||
const testFile = path.join(testDir, 'commit.txt');
|
||||
fs.writeFileSync(testFile, 'data');
|
||||
|
||||
tm.begin();
|
||||
tm.record(testFile);
|
||||
fs.writeFileSync(testFile, 'new data');
|
||||
|
||||
tm.commit();
|
||||
|
||||
expect(fs.readFileSync(testFile, 'utf8')).toBe('new data');
|
||||
// Backups are in ~/.g1nation-backups (hardcoded in TM, but let's assume it cleans up internal state)
|
||||
expect(tm.isActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/// <reference types="jest" />
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { AsyncLockManager } from '../src/core/lock';
|
||||
import { TransactionManager } from '../src/core/transaction';
|
||||
|
||||
describe('System Vulnerability Tests', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = path.join(os.tmpdir(), `g1-vulnerability-test-${Date.now()}`);
|
||||
if (!fs.existsSync(testDir)) {
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('AsyncLockManager should prevent race conditions on the same file', async () => {
|
||||
const lockManager = new AsyncLockManager();
|
||||
const testFile = path.join(testDir, 'race_condition.txt');
|
||||
fs.writeFileSync(testFile, '0');
|
||||
|
||||
// Simulate 10 concurrent increments
|
||||
const tasks = Array.from({ length: 10 }).map(async (_, i) => {
|
||||
const release = await lockManager.acquire(testFile);
|
||||
try {
|
||||
const current = parseInt(fs.readFileSync(testFile, 'utf-8'));
|
||||
// Artificial delay to increase race condition probability
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
fs.writeFileSync(testFile, (current + 1).toString());
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(tasks);
|
||||
|
||||
const finalValue = fs.readFileSync(testFile, 'utf-8');
|
||||
expect(finalValue).toBe('10'); // If race condition occurs, it will be < 10
|
||||
});
|
||||
|
||||
test('TransactionManager should support external verification hooks', () => {
|
||||
const tm = new TransactionManager();
|
||||
tm.begin();
|
||||
|
||||
tm.recordExternalAction('DB_COMMIT', true);
|
||||
tm.recordExternalAction('API_PUSH', true);
|
||||
|
||||
expect(tm.isFullyVerified()).toBe(true);
|
||||
|
||||
tm.recordExternalAction('WIKI_SYNC', false);
|
||||
expect(tm.isFullyVerified()).toBe(false);
|
||||
});
|
||||
});
|
||||
+4
-2
@@ -3,9 +3,11 @@
|
||||
"module": "commonjs",
|
||||
"target": "ES2022",
|
||||
"outDir": "out",
|
||||
"lib": ["ES2022"],
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"sourceMap": true,
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"types": ["node", "jest"]
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", ".vscode-test"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user