feat: v2.12.0 - UI/UX Refinement (Model Sync & Premium Tooltips)

This commit is contained in:
Wonseok Jung
2026-04-30 00:19:06 +09:00
parent f8a57cfbb0
commit 326672cb93
25 changed files with 5606 additions and 363 deletions
+19
View File
@@ -21,6 +21,25 @@
- Removed premature empty stream chunks that were causing UI flickering. - Removed premature empty stream chunks that were causing UI flickering.
- Verified build stability with `v2.2.46` VSIX package. - 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* *Date: 2026-04-25*
*Version: 2.2.46* *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) 적용
+44
View File
@@ -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. 체크리스트
- [ ] 새로운 버튼이 기존의 아이콘 문법을 따르는가?
- [ ] 생성/편집 액션이 정의된 위치에 배치되었는가?
- [ ] 상태 변화에 대해 일관된 색상과 방식으로 피드백을 주는가?
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.ts'],
moduleFileExtensions: ['ts', 'js'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
};
+3812 -2
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -2,7 +2,7 @@
"name": "g1nation", "name": "g1nation",
"displayName": "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.", "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", "publisher": "connectailab",
"license": "MIT", "license": "MIT",
"icon": "assets/icon.png", "icon": "assets/icon.png",
@@ -95,6 +95,11 @@
"configuration": { "configuration": {
"title": "G1nation", "title": "G1nation",
"properties": { "properties": {
"g1nation.multiAgentEnabled": {
"type": "boolean",
"default": true,
"description": "Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks."
},
"g1nation.ollamaUrl": { "g1nation.ollamaUrl": {
"type": "string", "type": "string",
"default": "http://127.0.0.1:11434", "default": "http://127.0.0.1:11434",
@@ -169,6 +174,11 @@
"type": "number", "type": "number",
"default": 50, "default": 50,
"description": "Maximum autonomous steps the agent can take per request. 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" "pretest": "npm run compile"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14",
"@types/marked": "^5.0.2", "@types/marked": "^5.0.2",
"@types/node": "18.x", "@types/node": "18.x",
"@types/vscode": "^1.80.0", "@types/vscode": "^1.80.0",
"@vercel/ncc": "^0.38.4", "@vercel/ncc": "^0.38.4",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"jest": "^29.7.0",
"ts-jest": "^29.4.9",
"typescript": "^5.1.3" "typescript": "^5.1.3"
}, },
"dependencies": { "dependencies": {
+428 -179
View File
@@ -3,10 +3,8 @@ import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
// axios removed // axios removed
import { import {
getConfig,
_getBrainDir, _getBrainDir,
findBrainFiles, findBrainFiles,
EXCLUDED_DIRS,
getSystemPrompt, getSystemPrompt,
shouldAutoPushBrain, shouldAutoPushBrain,
getSecondBrainRepo, getSecondBrainRepo,
@@ -17,16 +15,53 @@ import {
resolveEngine, resolveEngine,
summarizeText summarizeText
} from './utils'; } from './utils';
import { getConfig, EXCLUDED_DIRS } from './config';
import { validatePath, sanitizeCommand } from './security'; 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 { export interface ChatMessage {
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string | any[]; content: string;
internal?: boolean; internal?: boolean;
rationale?: {
problem: string;
goal: string;
reasoning: string;
};
} }
type HistoryChangeListener = (history: ChatMessage[]) => void | Promise<void>; 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 { export class AgentExecutor {
private chatHistory: ChatMessage[] = []; private chatHistory: ChatMessage[] = [];
private abortController: AbortController | null = null; private abortController: AbortController | null = null;
@@ -34,10 +69,44 @@ export class AgentExecutor {
private historyChangeListener: HistoryChangeListener | undefined; private historyChangeListener: HistoryChangeListener | undefined;
private runSerial = 0; private runSerial = 0;
private activeRunId = 0; private activeRunId = 0;
private transactionManager: TransactionManager;
private sessionManager: SessionManager;
private statusBarManager: StatusBarManager;
private currentTaskId: string = 'default_session';
constructor( constructor(
private context: vscode.ExtensionContext 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) { public setWebview(webview: vscode.Webview) {
this.webview = webview; this.webview = webview;
@@ -75,6 +144,20 @@ export class AgentExecutor {
this.emitHistoryChanged(); 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( public async handlePrompt(
prompt: string | null, prompt: string | null,
modelName: string, modelName: string,
@@ -98,19 +181,35 @@ export class AgentExecutor {
temperature = 0.7, temperature = 0.7,
systemPrompt = getSystemPrompt() systemPrompt = getSystemPrompt()
} = options; } = options;
const { ollamaUrl, defaultModel: configDefaultModel, timeout, multiAgentEnabled } = getConfig();
const runId = options.runId ?? (loopDepth === 0 ? ++this.runSerial : this.activeRunId); 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; const hasVisionContent = Array.isArray(visionContent) ? visionContent.length > 0 : !!visionContent;
let requestTimeoutHandle: ReturnType<typeof setTimeout> | undefined; let requestTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
if (!this.webview) return; if (!this.webview) return;
try { 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 (loopDepth === 0) {
if (this.abortController) { if (this.abortController) {
this.abortController.abort(); this.abortController.abort();
this.abortController = null; this.abortController = null;
} }
this.activeRunId = runId; this.activeRunId = runId;
this.currentTaskId = `task_${Date.now()}`;
await this.context.workspaceState.update('lastActionStr', undefined); await this.context.workspaceState.update('lastActionStr', undefined);
} }
@@ -170,7 +269,8 @@ export class AgentExecutor {
} }
// 3. API Request Setup // 3. API Request Setup
const { ollamaUrl, defaultModel, timeout } = getConfig(); const { ollamaUrl, defaultModel: configDefaultModel, timeout } = getConfig();
const actualModel = modelName || configDefaultModel;
const reqMessages = [...this.chatHistory]; const reqMessages = [...this.chatHistory];
// Handle Vision Content Injection // Handle Vision Content Injection
@@ -184,7 +284,7 @@ export class AgentExecutor {
: []; : [];
reqMessages[lastUserIdx] = { reqMessages[lastUserIdx] = {
role: 'user', role: 'user',
content: [...textParts, ...(visionContent || [])] content: JSON.stringify([...textParts, ...(visionContent || [])])
}; };
} }
} }
@@ -208,12 +308,12 @@ export class AgentExecutor {
// 4. Call AI Engine // 4. Call AI Engine
this.abortController = new AbortController(); this.abortController = new AbortController();
requestTimeoutHandle = setTimeout(() => { 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(); this.abortController?.abort();
}, timeout); }, timeout);
const request = await this.createStreamingRequest({ const request = await this.createStreamingRequest({
baseUrl: ollamaUrl, baseUrl: ollamaUrl,
modelName: modelName || defaultModel, modelName: actualModel,
reqMessages: messagesForRequest, reqMessages: messagesForRequest,
temperature temperature
}); });
@@ -283,12 +383,15 @@ export class AgentExecutor {
} }
// 5. Execute Actions // 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.chatHistory.push(assistantMessage);
this.statusBarManager.updateStatus(AgentStatus.Executing);
const report = await this.executeActions(aiResponseText, rootPath); const report = await this.executeActions(aiResponseText, rootPath);
if (!aiResponseText.trim() && report.length === 0) { if (!aiResponseText.trim() && report.length === 0) {
this.chatHistory.pop(); 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.' }); this.webview.postMessage({ type: 'error', value: 'AI model returned an empty response.' });
return; return;
} }
@@ -332,9 +435,11 @@ export class AgentExecutor {
assistantMessage.internal = false; assistantMessage.internal = false;
this.emitHistoryChanged(); this.emitHistoryChanged();
this.statusBarManager.updateStatus(AgentStatus.Success);
this.webview.postMessage({ type: 'streamChunk', value: aiResponseText }); this.webview.postMessage({ type: 'streamChunk', value: aiResponseText });
} catch (error: any) { } catch (error: any) {
this.statusBarManager.updateStatus(AgentStatus.Error, error.message);
logError('Agent prompt failed.', { error: error?.message || String(error), promptPreview: summarizeText(prompt || '', 200) }); logError('Agent prompt failed.', { error: error?.message || String(error), promptPreview: summarizeText(prompt || '', 200) });
if (!this.isStaleRun(runId)) { if (!this.isStaleRun(runId)) {
this.webview.postMessage({ type: "error", value: `[Agent Error]: ${error.message}` }); this.webview.postMessage({ type: "error", value: `[Agent Error]: ${error.message}` });
@@ -383,16 +488,15 @@ export class AgentExecutor {
if (explicitPath) return explicitPath; if (explicitPath) return explicitPath;
const namedProject = prompt.match(/([A-Za-z0-9._-]+)\s*(?:프로젝트|project)/i)?.[1]; 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 = [ const searchRoots = [
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '',
'/Volumes/Data/project/Antigravity', '/Volumes/Data/project/Antigravity',
'/Volumes/Data/project',
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''
].filter(Boolean); ].filter(Boolean);
for (const root of searchRoots) { 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; if (resolved) return resolved;
} }
@@ -424,10 +528,108 @@ export class AgentExecutor {
return null; 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 { private isProjectAnalysisRequest(normalized: string): boolean {
// Intentionally conservative: omit generic words like '조사', '설명', '파악' // Only trigger local analysis if the intent is strictly about a project-level overview.
// that appear in ordinary chat and would incorrectly bypass LM Studio // Avoid generic terms like 'analysis' or 'review' that are common in general coding chat.
return /(분석|리뷰|설계|구조|어떤 프로그램|무슨 프로그램|제품 설명)/.test(normalized); const hasProjectKeyword = /(프로젝트|project|레포|repository)/.test(normalized);
const hasAnalysisIntent = /(전체 요약|제품 설명|어떤 프로그램|구조 파악|훑어보기)/.test(normalized);
return hasProjectKeyword && hasAnalysisIntent;
} }
private buildProjectAnalysisReply(projectPath: string): string { private buildProjectAnalysisReply(projectPath: string): string {
@@ -691,6 +893,13 @@ export class AgentExecutor {
private emitHistoryChanged() { private emitHistoryChanged() {
if (!this.historyChangeListener) return; 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) => { Promise.resolve(this.historyChangeListener(this.getHistory())).catch((error: any) => {
logError('History change listener failed.', { error: error?.message || String(error) }); logError('History change listener failed.', { error: error?.message || String(error) });
}); });
@@ -818,179 +1027,219 @@ export class AgentExecutor {
let brainModified = false; let brainModified = false;
let firstCreatedFile: string | undefined; let firstCreatedFile: string | undefined;
// Action 1: Create File try {
const createRegex = /<create_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/create_file>/gi; this.transactionManager.begin();
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}`); }
}
// Action 2: Edit File // Action 1: Create File
const editRegex = /<edit_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/edit_file>/gi; const createRegex = /<create_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/create_file>/gi;
while ((match = editRegex.exec(aiMessage)) !== null) { let match;
const relPath = match[1].trim(); while ((match = createRegex.exec(aiMessage)) !== null) {
const editContent = match[2].trim(); const relPath = match[1].trim();
try { const content = match[2].trim();
const absPath = validatePath(rootPath, relPath); try {
if (fs.existsSync(absPath)) { const absPath = validatePath(rootPath, relPath);
let currentContent = fs.readFileSync(absPath, 'utf-8'); await this.transactionManager.record(absPath);
const searchMatch = editContent.match(/<search>([\s\S]*?)<\/search>\s*<replace>([\s\S]*?)<\/replace>/i);
if (searchMatch) { fs.mkdirSync(path.dirname(absPath), { recursive: true });
const searchStr = searchMatch[1]; fs.writeFileSync(absPath, content, 'utf-8');
const replaceStr = searchMatch[2];
if (currentContent.includes(searchStr)) { report.push(`✅ Created: ${relPath}`);
currentContent = currentContent.replace(searchStr, replaceStr); if (!firstCreatedFile) firstCreatedFile = absPath;
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; if (absPath.startsWith(_getBrainDir())) brainModified = true;
} else { } catch (err: any) {
report.push(`❌ File not found: ${relPath}`); 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 // Action 2: Edit File
const deleteRegex = /<delete_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/delete_file>)?/gi; const editRegex = /<edit_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/edit_file>/gi;
while ((match = deleteRegex.exec(aiMessage)) !== null) { while ((match = editRegex.exec(aiMessage)) !== null) {
const relPath = match[1].trim(); const relPath = match[1].trim();
try { const editContent = match[2].trim();
const absPath = validatePath(rootPath, relPath); try {
if (fs.existsSync(absPath)) { const absPath = validatePath(rootPath, relPath);
fs.unlinkSync(absPath); if (fs.existsSync(absPath)) {
report.push(`🗑 Deleted: ${relPath}`); await this.transactionManager.record(absPath);
} else {
report.push(`⚠️ Delete failed: ${relPath} not found`); let currentContent = fs.readFileSync(absPath, 'utf-8');
} const searchMatch = editContent.match(/<search>([\s\S]*?)<\/search>\s*<replace>([\s\S]*?)<\/replace>/i);
} catch (err: any) { report.push(`❌ Error Deleting ${relPath}: ${err.message}`); }
} if (searchMatch) {
const searchStr = searchMatch[1];
// Action 4: Read File const replaceStr = searchMatch[2];
const readRegex = /<read_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/read_file>)?/gi; if (currentContent.includes(searchStr)) {
while ((match = readRegex.exec(aiMessage)) !== null) { currentContent = currentContent.replace(searchStr, replaceStr);
const relPath = match[1].trim(); fs.writeFileSync(absPath, currentContent, 'utf-8');
try { report.push(`📝 Updated: ${relPath}`);
const absPath = validatePath(rootPath, relPath); } else {
if (fs.existsSync(absPath)) { report.push(`⚠️ Search string not found in ${relPath}`);
const content = fs.readFileSync(absPath, 'utf-8'); }
const preview = content.length > 8000 ? content.slice(0, 8000) + "\n... (truncated)" : content; } else {
report.push(`📖 Read: ${relPath}`); fs.writeFileSync(absPath, editContent, 'utf-8');
this.chatHistory.push({ role: 'system', content: `[Result of read_file ${relPath}]\n\`\`\`\n${preview}\n\`\`\``, internal: true }); report.push(`📝 Updated (Full): ${relPath}`);
} else { }
report.push(`❌ Read failed: ${relPath} not found`); if (absPath.startsWith(_getBrainDir())) brainModified = true;
} } else {
} catch (err: any) { report.push(`❌ Error Reading ${relPath}: ${err.message}`); } report.push(`❌ File not found: ${relPath}`);
}
// 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)";
} }
} catch (err: any) {
report.push(`📂 Listed: ${relPath}`); throw new FileSystemError(`Failed to edit file ${relPath}: ${err.message}`, relPath, err);
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) // Action 3: Delete File
const listBrainRegex = /<list_brain\s*path=['"]?([^'"]*)['"]?\s*\/?>(?:<\/list_brain>)?/gi; const deleteRegex = /<delete_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/delete_file>)?/gi;
while ((match = listBrainRegex.exec(aiMessage)) !== null) { while ((match = deleteRegex.exec(aiMessage)) !== null) {
const relPath = match[1].trim() || '.'; const relPath = match[1].trim();
try { try {
const brainDir = _getBrainDir(); const absPath = validatePath(rootPath, relPath);
const absPath = path.join(brainDir, relPath); if (fs.existsSync(absPath)) {
if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) { await this.transactionManager.record(absPath);
const entries = fs.readdirSync(absPath, { withFileTypes: true }); fs.unlinkSync(absPath);
let listing = entries report.push(`🗑 Deleted: ${relPath}`);
.filter(e => !e.name.startsWith('.') && !EXCLUDED_DIRS.has(e.name)) } else {
.map(e => e.isDirectory() ? `${e.name}/` : e.name) report.push(`⚠️ Delete failed: ${relPath} not found`);
.join('\n');
if (listing.length > 5000) {
listing = listing.slice(0, 5000) + "\n... (truncated for context)";
} }
} 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}`); if (targetFile && fs.existsSync(targetFile)) {
this.chatHistory.push({ role: 'system', content: `[Result of list_brain ${relPath}]\n${listing}`, internal: true }); const content = fs.readFileSync(targetFile, 'utf-8');
} else { report.push(`🧠 Brain Read: ${fileName}`);
report.push(`❌ Brain List failed: ${relPath} not found`); this.chatHistory.push({ role: 'system', content: `[Result of read_brain ${fileName}]\n\`\`\`\n${content}\n\`\`\``, internal: true });
} } else {
} catch (err: any) { report.push(`❌ Error Listing Brain: ${err.message}`); } 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; // Action 8: Read URL
while ((match = brainRegex.exec(aiMessage)) !== null) { const urlRegex = /<read_url>([\s\S]*?)<\/read_url>/gi;
const fileName = match[1].trim(); while ((match = urlRegex.exec(aiMessage)) !== null) {
try { const url = match[1].trim();
const brainDir = _getBrainDir(); try {
const files = findBrainFiles(brainDir); const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
// Look for direct match or path match const text = await res.text();
const targetFile = files.find((f: string) => path.basename(f) === fileName || f.endsWith(fileName)); const content = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const preview = content.length > 5000 ? content.slice(0, 5000) + "\n... (truncated)" : content;
if (targetFile && fs.existsSync(targetFile)) { report.push(`🌐 Read URL: ${url}`);
const content = fs.readFileSync(targetFile, 'utf-8'); this.chatHistory.push({ role: 'system', content: `[Result of read_url ${url}]\n${preview}`, internal: true });
report.push(`🧠 Brain Read: ${fileName}`); } catch (err: any) { report.push(`❌ URL Read failed: ${err.message}`); }
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 (Simple implementation) if (firstCreatedFile) {
const urlRegex = /<read_url>([\s\S]*?)<\/read_url>/gi; vscode.window.showTextDocument(vscode.Uri.file(firstCreatedFile), { preview: false });
while ((match = urlRegex.exec(aiMessage)) !== null) { }
const url = match[1].trim();
try { // Brain Sync Logic
const res = await fetch(url, { signal: AbortSignal.timeout(10000) }); if (brainModified && shouldAutoPushBrain() && getSecondBrainRepo()) {
const text = await res.text(); this.syncBrain();
// 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; const config = getConfig();
report.push(`🌐 Read URL: ${url}`); if (config.dryRun) {
this.chatHistory.push({ role: 'system', content: `[Result of read_url ${url}]\n${preview}`, internal: true }); report.push(`\n⚠️ **Dry Run Mode Active**: 위 변경 사항을 확인하고 [승인] 또는 [롤백]을 선택해주세요.`);
} catch (err: any) { report.push(`❌ URL Read failed: ${err.message}`); } 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) { if (firstCreatedFile) {
+55
View File
@@ -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
View File
@@ -3,16 +3,17 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
// axios removed // axios removed
import { import {
getConfig,
_getBrainDir, _getBrainDir,
_isBrainDirExplicitlySet, _isBrainDirExplicitlySet,
findBrainFiles, findBrainFiles,
buildApiUrl, buildApiUrl,
logError, logError,
logInfo, logInfo,
logWarn,
resolveEngine, resolveEngine,
summarizeText summarizeText
} from './utils'; } from './utils';
import { getConfig } from './config';
export interface BridgeInterface { export interface BridgeInterface {
injectSystemMessage(msg: string): void; injectSystemMessage(msg: string): void;
+120 -64
View File
@@ -1,97 +1,153 @@
/**
* ============================================================
* Centralized Configuration (중앙 집중식 설정 관리)
*
* 모든 환경 설정(모델 이름, API 엔드포인트, 타임아웃, 보안 정책 등)
* 을 한 곳에서 관리합니다. Single Source of Truth 원칙 적용.
* ============================================================
*/
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs';
// ─── VS Code 설정에서 읽어오는 값 ─── // ─── 브레인 프로필 인터페이스 ───
export function getConfig(): IAgentConfig { export interface BrainProfile {
const cfg = vscode.workspace.getConfiguration('g1nation'); id: string;
return { name: string;
ollamaBase: cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434'), localBrainPath: string;
defaultModel: cfg.get<string>('defaultModel', 'gemma4:e2b'), secondBrainRepo?: string;
maxTreeFiles: cfg.get<number>('maxTreeFiles', 200), description?: string;
timeout: cfg.get<number>('requestTimeout', 300) * 1000,
localBrainPath: cfg.get<string>('localBrainPath', '')
};
} }
// ─── 에이전트 설정 인터페이스 ─── // ─── 에이전트 설정 인터페이스 (통합 버전) ───
export interface IAgentConfig { export interface IAgentConfig {
ollamaBase: string; ollamaUrl: string;
defaultModel: string; defaultModel: string;
maxTreeFiles: number; maxTreeFiles: number;
timeout: number; timeout: number;
localBrainPath: string; localBrainPath: string;
secondBrainRepo: string;
brainProfiles: BrainProfile[];
activeBrainId: string;
maxContextSize: number;
maxAutoSteps: number;
dryRun: boolean;
multiAgentEnabled: boolean;
} }
// ─── 두뇌 폴더 경로 유틸리티 ─── // ─── 경로 정규화 유틸리티 ───
export function getBrainDir(): string { function normalizePath(p: string): string {
const { localBrainPath } = getConfig(); if (!p) return p;
if (localBrainPath && localBrainPath.trim() !== '') { if (p.startsWith('~/')) {
if (localBrainPath.startsWith('~/')) { return path.join(os.homedir(), p.substring(2));
return path.join(os.homedir(), localBrainPath.substring(2));
}
return localBrainPath.trim();
} }
return path.join(os.homedir(), '.g1nation-brain'); return p.trim();
} }
export function isBrainDirExplicitlySet(): boolean { function toBrainProfile(raw: Partial<BrainProfile> | undefined, fallbackIndex: number): BrainProfile | null {
const { localBrainPath } = getConfig(); if (!raw) return null;
return !!(localBrainPath && localBrainPath.trim() !== ''); 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) ─── // ─── 보안 정책 (Security Policy) ───
export const SECURITY_POLICY = { export const SECURITY_POLICY = {
// 허용된 터미널 명령어 프리픽스 (Whitelist)
allowedCommandPrefixes: [ allowedCommandPrefixes: [
'npm', 'yarn', 'pnpm', 'npx', 'npm', 'yarn', 'pnpm', 'npx', 'node', 'ts-node', 'git', 'python', 'python3', 'pip', 'pip3',
'node', 'ts-node', 'docker', 'docker-compose', 'ls', 'dir', 'cat', 'type', 'echo', 'print', 'cargo', 'go', 'rustc',
'git', 'java', 'javac', 'mvn', 'gradle', 'flutter', 'dart', 'pub', 'webpack', 'vite', 'esbuild', 'parcel',
'python', 'python3', 'pip', 'pip3', 'jest', 'mocha', 'vitest', 'cypress', 'tsc', 'vue-tsc',
'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: [ forbiddenCommands: [
'rm -rf', 'rm-rf', 'del /f', 'format', 'rm -rf', 'rm-rf', 'del /f', 'format', 'mkfs', 'dd if=', ':(){ :|:& };:',
'mkfs', 'dd if=', ':(){ :|:& };:', 'wget http', 'curl http', 'sudo', 'chmod 777', 'chown root',
'wget http', 'curl http', 'sudo',
'chmod 777', 'chown root',
], ],
// 민감한 파일 패턴 (파일 생성/수정 시 경고)
sensitiveFilePatterns: [ sensitiveFilePatterns: [
'.env', '.env.*', '.env', '.env.*', 'id_rsa', 'id_ed25519', '.gitconfig', '.npmrc', '.pypirc',
'id_rsa', 'id_ed25519',
'.gitconfig', '.npmrc', '.pypirc',
'credentials.json', 'service-account.json', 'credentials.json', 'service-account.json',
], ],
maxFileSize: 10 * 1024 * 1024,
// 파일 생성 시 최대 크기 (bytes)
maxFileSize: 10 * 1024 * 1024, // 10MB
// 맥락 파일 최대 개수
maxContextFiles: 200, maxContextFiles: 200,
}; };
// ─── 시스템 프롬프트 상수 ───
export const MAX_CONTEXT_SIZE = 12_000; export const MAX_CONTEXT_SIZE = 12_000;
export const EXCLUDED_DIRS = new Set([ export const EXCLUDED_DIRS = new Set([
+28
View File
@@ -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;
}
}
+41
View File
@@ -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: '로그를 확인하거나 확장을 재시작해보세요.'
};
}
}
+38
View File
@@ -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'; }
}
+59
View File
@@ -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);
}
}
+38
View File
@@ -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();
+53
View File
@@ -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();
+88
View File
@@ -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();
}
}
+57
View File
@@ -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();
}
}
+127
View File
@@ -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
View File
@@ -3,7 +3,6 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
// axios removed in favor of native fetch // axios removed in favor of native fetch
import { import {
getConfig,
_getBrainDir, _getBrainDir,
_isBrainDirExplicitlySet, _isBrainDirExplicitlySet,
findBrainFiles, findBrainFiles,
@@ -12,15 +11,28 @@ import {
logError, logError,
logInfo logInfo
} from './utils'; } from './utils';
import { getConfig, validateConfig } from './config';
import { AgentExecutor } from './agent'; import { AgentExecutor } from './agent';
import { BridgeServer } from './bridge'; import { BridgeServer } from './bridge';
import { SidebarChatProvider } from './sidebarProvider'; import { SidebarChatProvider } from './sidebarProvider';
import { HealthCheckMonitor } from './core/health';
/** /**
* G1nation Extension Entry Point * G1nation Extension Entry Point
*/ */
export async function activate(context: vscode.ExtensionContext) { 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 // 1. Ensure Brain Directory
await _ensureBrainDir(context); await _ensureBrainDir(context);
+381 -15
View File
@@ -2,7 +2,6 @@ import * as vscode from 'vscode';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { import {
getConfig,
_getBrainDir, _getBrainDir,
findBrainFiles, findBrainFiles,
buildApiUrl, buildApiUrl,
@@ -13,6 +12,7 @@ import {
resolveEngine, resolveEngine,
summarizeText summarizeText
} from './utils'; } from './utils';
import { getConfig } from './config';
import { AgentExecutor, ChatMessage } from './agent'; import { AgentExecutor, ChatMessage } from './agent';
import { BridgeInterface } from './bridge'; import { BridgeInterface } from './bridge';
@@ -88,8 +88,12 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
await this._sendBrainProfiles(); await this._sendBrainProfiles();
await this._sendSessionList(); await this._sendSessionList();
await this._sendModels(); await this._sendModels();
await this._sendConfig();
await this._restoreActiveSessionIntoView(); await this._restoreActiveSessionIntoView();
break; break;
case 'toggleMultiAgent':
await vscode.workspace.getConfiguration('g1nation').update('multiAgentEnabled', data.value, vscode.ConfigurationTarget.Global);
break;
case 'getModels': case 'getModels':
await this._sendModels(); await this._sendModels();
break; break;
@@ -128,6 +132,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
await this.syncBrain(); await this.syncBrain();
await this._sendBrainStatus(); await this._sendBrainStatus();
break; break;
case 'addMessage':
this._view?.webview.postMessage({ type: 'addMessage', role: data.role, value: data.value, rationale: data.rationale });
break;
case 'addBrain': case 'addBrain':
await this._addBrainProfile(); await this._addBrainProfile();
break; break;
@@ -143,6 +150,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
case 'refreshModels': case 'refreshModels':
await this._sendModels(); await this._sendModels();
break; 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': case 'exportResponse':
const workspacePath = vscode.workspace.workspaceFolders?.[0].uri.fsPath || ''; const workspacePath = vscode.workspace.workspaceFolders?.[0].uri.fsPath || '';
const defaultPath = path.join(workspacePath, 'g1_response.md'); 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)}`); vscode.window.showInformationMessage(`✅ Exported to ${path.basename(uri.fsPath)}`);
} }
break; 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)); 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() { private async _sendBrainStatus() {
if (!this._view) return; if (!this._view) return;
const activeBrain = getActiveBrainProfile(); const activeBrain = getActiveBrainProfile();
@@ -631,6 +662,29 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
this._view.webview.postMessage({ type: 'agentsList', value: agents, selected: lastPath }); 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() { private async _createAgent() {
const name = await vscode.window.showInputBox({ const name = await vscode.window.showInputBox({
prompt: 'Name of the new Agent (e.g., frontend_expert)', 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); } .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 { 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); } .icon-btn:hover { background: var(--border); color: var(--text-bright); }
.av-ai { background: var(--accent); color: #fff; } .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 { .msg-body {
padding-left: 30px; padding-left: 30px;
@@ -997,11 +1067,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
/* --- Overlays & Others --- */ /* --- Overlays & Others --- */
.thinking-bar { height: 2px; background: transparent; position: relative; overflow: hidden; margin-top: -1px; } .thinking-bar { height: 2px; background: transparent; position: relative; overflow: hidden; margin-top: -1px; }
.thinking-bar.active::after { .thinking-bar.active {
content: ''; position: absolute; top: 0; left: -40%; width: 40%; height: 100%; display: block;
background: linear-gradient(90deg, transparent, var(--accent), transparent); animation: think 1.5s infinite; 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 { .history-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); 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; margin-bottom: 10px; cursor: pointer; transition: 0.2s;
} }
.history-item:hover { border-color: var(--accent); background: var(--accent-glow); } .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> </style>
</head> </head>
<body> <body>
@@ -1043,9 +1249,10 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
<select id="agentSel" title="Select Agentic Skill"></select> <select id="agentSel" title="Select Agentic Skill"></select>
<button class="icon-btn" id="editAgentBtn" title="Edit Agent Skill">📝</button> <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="addAgentBtn" title="Create Agent">+</button>
<button class="icon-btn" id="internetBtn" title="Internet Access">🌐</button> <button class="icon-btn" id="multiAgentBtn" data-tooltip="Multi-Agent Mode">🤖</button>
<button class="icon-btn" id="brainBtn" title="Sync Knowledge">🧠</button> <button class="icon-btn" id="internetBtn" data-tooltip="Internet Access">🌐</button>
<button class="icon-btn" id="historyBtn" title="History">📜</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>
</div> </div>
@@ -1059,6 +1266,15 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
<div class="thinking-bar" id="thinkingBar"></div> <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="chat" id="chat">
<div class="welcome"> <div class="welcome">
<div class="welcome-logo">✦</div> <div class="welcome-logo">✦</div>
@@ -1088,6 +1304,11 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
<button id="sendBtn" class="send-btn">Send</button> <button id="sendBtn" class="send-btn">Send</button>
</div> </div>
</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"> <input type="file" id="fileInput" multiple hidden accept="image/*,.txt,.md,.pdf,.csv,.json,.js,.ts,.py,.java,.rs,.go">
</div> </div>
@@ -1098,6 +1319,52 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
const sendBtn = document.getElementById('sendBtn'); const sendBtn = document.getElementById('sendBtn');
const thinkingBar = document.getElementById('thinkingBar'); const thinkingBar = document.getElementById('thinkingBar');
const statusLabel = document.getElementById('statusLabel'); 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 modelSel = document.getElementById('modelSel');
const brainSel = document.getElementById('brainSel'); const brainSel = document.getElementById('brainSel');
const historyOverlay = document.getElementById('historyOverlay'); const historyOverlay = document.getElementById('historyOverlay');
@@ -1133,16 +1400,45 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
document.body.removeChild(textarea); 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) { function exportToMD(text) {
vscode.postMessage({ type: 'exportResponse', text: text }); vscode.postMessage({ type: 'exportResponse', text: text });
} }
function addMsg(text, role) { function addMsg(text, role, rationale) {
const isUser = role === 'user'; const isUser = role === 'user';
const msgEl = document.createElement('div'); const msgEl = document.createElement('div');
msgEl.className = 'msg ' + (isUser ? 'msg-user' : 'msg-ai'); msgEl.className = 'msg ' + (isUser ? 'msg-user' : 'msg-ai');
msgEl._raw = text; 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'); const head = document.createElement('div');
head.className = 'msg-head'; head.className = 'msg-head';
head.innerHTML = isUser ? '<div class="av av-user">U</div> You' : '<div class="av av-ai">✦</div> G1nation'; 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 => { window.addEventListener('message', e => {
const msg = e.data; const msg = e.data;
switch(msg.type) { switch(msg.type) {
case 'addMessage':
addMsg(msg.value, msg.role, msg.rationale);
break;
case 'streamStart': case 'streamStart':
thinkingBar.classList.remove('active'); thinkingBar.classList.remove('active');
if (document.querySelector('.welcome')) document.querySelector('.welcome').remove(); if (document.querySelector('.welcome')) document.querySelector('.welcome').remove();
@@ -1196,6 +1495,8 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
case 'streamEnd': case 'streamEnd':
if (streamBody) streamBody.classList.remove('stream-active'); if (streamBody) streamBody.classList.remove('stream-active');
streamBody = null; sendBtn.disabled = false; streamBody = null; sendBtn.disabled = false;
resetStepper();
Sound.success();
break; break;
case 'restoreHistory': case 'restoreHistory':
case 'sessionLoaded': case 'sessionLoaded':
@@ -1204,7 +1505,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
if (history && history.length > 0) { if (history && history.length > 0) {
chat.innerHTML = ''; 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) { if (historyData.negativePrompt !== undefined) {
negativePrompt.value = historyData.negativePrompt; negativePrompt.value = historyData.negativePrompt;
@@ -1224,6 +1525,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
if (m === msg.value.selected) o.selected = true; if (m === msg.value.selected) o.selected = true;
modelSel.appendChild(o); modelSel.appendChild(o);
}); });
if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder();
statusLabel.innerText = \`Model: \${msg.value.selected}\`; statusLabel.innerText = \`Model: \${msg.value.selected}\`;
break; break;
case 'brainProfiles': case 'brainProfiles':
@@ -1251,6 +1553,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
break; break;
case 'autoContinue': case 'autoContinue':
statusLabel.innerText = msg.value; thinkingBar.classList.add('active'); 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); setTimeout(() => { thinkingBar.classList.remove('active'); }, 3000);
break; break;
case 'agentsList': case 'agentsList':
@@ -1272,6 +1577,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
thinkingBar.classList.remove('active'); sendBtn.disabled = false; thinkingBar.classList.remove('active'); sendBtn.disabled = false;
addMsg(msg.value, 'error'); addMsg(msg.value, 'error');
break; 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'; }); 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('settingsBtn').onclick = () => vscode.postMessage({ type: 'openSettings' });
document.getElementById('internetBtn').onclick = () => { document.getElementById('internetBtn').onclick = () => {
internetEnabled = !internetEnabled; document.getElementById('internetBtn').classList.toggle('active', internetEnabled); 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').onclick = () => vscode.postMessage({ type: 'getSessions' });
document.getElementById('historyBtn').addEventListener('click', () => historyOverlay.classList.add('visible')); document.getElementById('historyBtn').addEventListener('click', () => historyOverlay.classList.add('visible'));
document.getElementById('closeHistoryBtn').onclick = () => historyOverlay.classList.remove('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 = () => { brainSel.onchange = () => {
if (brainSel.value === 'new') { if (brainSel.value === 'new') {
vscode.postMessage({ type: 'addBrain' }); 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 = () => { agentSel.onchange = () => {
if (agentSel.value !== 'none') { if (agentSel.value !== 'none') {
vscode.postMessage({ type: 'getAgentContent', path: agentSel.value }); 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: 'getModels' });
vscode.postMessage({ type: 'getAgents' }); vscode.postMessage({ type: 'getAgents' });
vscode.postMessage({ type: 'ready' }); 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> </script>
</body> </body>
</html>`; </html>`;
+7 -97
View File
@@ -3,103 +3,7 @@ import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import * as fs from 'fs'; import * as fs from 'fs';
export const EXCLUDED_DIRS = new Set([ import { getConfig, BrainProfile, EXCLUDED_DIRS } from './config';
'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)
};
}
export type EngineKind = 'lmstudio' | 'ollama'; 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. - 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. - 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. - 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: Available action tags:
+68
View File
@@ -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);
});
});
+60
View File
@@ -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
View File
@@ -3,9 +3,11 @@
"module": "commonjs", "module": "commonjs",
"target": "ES2022", "target": "ES2022",
"outDir": "out", "outDir": "out",
"lib": ["ES2022"], "lib": ["ES2022", "DOM"],
"sourceMap": true, "sourceMap": true,
"strict": true "strict": true,
"types": ["node", "jest"]
}, },
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", ".vscode-test"] "exclude": ["node_modules", ".vscode-test"]
} }