feat: v2.12.0 - UI/UX Refinement (Model Sync & Premium Tooltips)
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { logWarn } from '../utils';
|
||||
|
||||
/**
|
||||
* ConflictResolver: Analyzes agent actions for logical contradictions
|
||||
* or goal conflicts (e.g., Security vs. Performance).
|
||||
*/
|
||||
export class ConflictResolver {
|
||||
/**
|
||||
* Analyzes proposed actions and returns a warning message if conflicts are found.
|
||||
*/
|
||||
public static analyze(actions: any[]): string | null {
|
||||
// 1. Resource Conflict: Multiple edits to sensitive core files
|
||||
const securityFiles = actions.filter(a => a.path && (a.path.includes('auth') || a.path.includes('security') || a.path.includes('config')));
|
||||
const performanceChanges = actions.filter(a => a.content && (a.content.includes('async') || a.content.includes('parallel') || a.content.includes('cache')));
|
||||
|
||||
if (securityFiles.length > 0 && performanceChanges.length > 0) {
|
||||
return "⚠️ Goal Conflict Detected: You are attempting to modify security-sensitive files while introducing performance optimizations (async/parallel). This might introduce race conditions or security vulnerabilities. Should I proceed with both, or prioritize Security?";
|
||||
}
|
||||
|
||||
// 2. Structural Conflict: Modifying both interface and implementation in a way that might break contracts
|
||||
const interfaces = actions.filter(a => a.path && a.path.endsWith('.d.ts') || a.path.includes('interface'));
|
||||
if (interfaces.length > 1 && actions.length > 10) {
|
||||
return "⚠️ Structural Complexity Warning: This task involves massive changes to both interfaces and multiple implementations. This may lead to unexpected side effects. Would you like a step-by-step review?";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface UserFriendlyError {
|
||||
title: string;
|
||||
message: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export class ErrorTranslator {
|
||||
public static translate(error: any): UserFriendlyError {
|
||||
const msg = String(error.message || error).toLowerCase();
|
||||
|
||||
if (msg.includes('fetch') || msg.includes('network') || msg.includes('econnrefused')) {
|
||||
return {
|
||||
title: '🌐 연결 오류 (Connection Error)',
|
||||
message: 'AI 엔진(LM Studio 또는 Ollama)에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요.',
|
||||
action: '서버를 실행한 후 "Refresh" 버튼을 눌러주세요.'
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('timeout')) {
|
||||
return {
|
||||
title: '⏱️ 응답 시간 초과 (Timeout)',
|
||||
message: 'AI가 답변을 준비하는 데 너무 오래 걸리고 있습니다.',
|
||||
action: '설정에서 Timeout 시간을 늘리거나, 더 작은 범위로 질문해보세요.'
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('model not found') || msg.includes('404')) {
|
||||
return {
|
||||
title: '🤖 모델 인식 불가 (Model Not Found)',
|
||||
message: '선택하신 모델을 시스템에서 찾을 수 없습니다.',
|
||||
action: '상단 드롭다운에서 다른 모델을 선택하거나 모델 이름을 확인해주세요.'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: '⚠️ 알 수 없는 오류',
|
||||
message: '작업 중 예상치 못한 문제가 발생했습니다.',
|
||||
action: '로그를 확인하거나 확장을 재시작해보세요.'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* g1nation Custom Error Classes
|
||||
*/
|
||||
|
||||
export abstract class G1Error extends Error {
|
||||
constructor(public message: string, public details?: any) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
|
||||
abstract getTypeCode(): string;
|
||||
}
|
||||
|
||||
export class AgentExecutionError extends G1Error {
|
||||
getTypeCode() { return 'AGENT_EXECUTION_ERROR'; }
|
||||
}
|
||||
|
||||
export class FileSystemError extends G1Error {
|
||||
constructor(message: string, public path: string, details?: any) {
|
||||
super(message, details);
|
||||
}
|
||||
getTypeCode() { return 'FILE_SYSTEM_ERROR'; }
|
||||
}
|
||||
|
||||
export class APICommunicationError extends G1Error {
|
||||
constructor(message: string, public engine: string, public status?: number, details?: any) {
|
||||
super(message, details);
|
||||
}
|
||||
getTypeCode() { return 'API_COMMUNICATION_ERROR'; }
|
||||
}
|
||||
|
||||
export class SecurityValidationError extends G1Error {
|
||||
getTypeCode() { return 'SECURITY_VALIDATION_ERROR'; }
|
||||
}
|
||||
|
||||
export class TransactionError extends G1Error {
|
||||
getTypeCode() { return 'TRANSACTION_ERROR'; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import { getConfig } from '../config';
|
||||
import { logInfo, logWarn, logError } from '../utils';
|
||||
|
||||
/**
|
||||
* HealthCheckMonitor: Periodically monitors the environment
|
||||
* (Ollama, Disk, API) to ensure the agent stays functional.
|
||||
*/
|
||||
export class HealthCheckMonitor {
|
||||
public static async runAllChecks(): Promise<{ ok: boolean; reports: string[] }> {
|
||||
const reports: string[] = [];
|
||||
const config = getConfig();
|
||||
|
||||
// 1. Ollama Connectivity Check
|
||||
try {
|
||||
const res = await fetch(`${config.ollamaUrl}/api/tags`, { signal: AbortSignal.timeout(3000) });
|
||||
if (res.ok) {
|
||||
logInfo('Health Check: Ollama connectivity OK.');
|
||||
} else {
|
||||
reports.push('⚠️ AI Server (Ollama) is reachable but returned an error.');
|
||||
}
|
||||
} catch {
|
||||
reports.push('❌ AI Server (Ollama) is NOT reachable. Please check if it is running.');
|
||||
}
|
||||
|
||||
// 2. Workspace Validation
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
if (!workspaceFolders || workspaceFolders.length === 0) {
|
||||
reports.push('⚠️ No workspace folder open. Agent capabilities will be limited.');
|
||||
}
|
||||
|
||||
// 3. Simple Disk/Permissions Check
|
||||
try {
|
||||
const testFile = workspaceFolders ? vscode.Uri.joinPath(workspaceFolders[0].uri, '.g1-health-check') : null;
|
||||
if (testFile) {
|
||||
await vscode.workspace.fs.writeFile(testFile, Buffer.from('ok'));
|
||||
await vscode.workspace.fs.delete(testFile);
|
||||
logInfo('Health Check: Write permissions OK.');
|
||||
}
|
||||
} catch {
|
||||
reports.push('❌ Write permissions denied in the current workspace.');
|
||||
}
|
||||
|
||||
if (reports.length > 0) {
|
||||
logWarn(`Health Check Warnings: ${reports.join(' | ')}`);
|
||||
vscode.window.showWarningMessage(`ConnectAI Health Warning: ${reports[0]}`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: reports.length === 0,
|
||||
reports
|
||||
};
|
||||
}
|
||||
|
||||
public static startInterval(ms: number = 300000) { // Default 5 mins
|
||||
setInterval(() => this.runAllChecks(), ms);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { logInfo } from '../utils';
|
||||
|
||||
/**
|
||||
* AsyncLockManager: Prevents race conditions by ensuring only one task
|
||||
* can access a specific resource (e.g., a file path) at a time.
|
||||
*/
|
||||
export class AsyncLockManager {
|
||||
private locks: Map<string, Promise<void>> = new Map();
|
||||
|
||||
/**
|
||||
* Acquires a lock for a specific resource.
|
||||
* If the resource is already locked, it waits until the previous task finishes.
|
||||
*/
|
||||
public async acquire(resourceId: string): Promise<() => void> {
|
||||
const previousLock = this.locks.get(resourceId) || Promise.resolve();
|
||||
|
||||
let release: () => void;
|
||||
const newLock = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
this.locks.set(resourceId, previousLock.then(() => newLock));
|
||||
|
||||
await previousLock;
|
||||
logInfo(`Lock acquired for: ${resourceId}`);
|
||||
|
||||
return () => {
|
||||
logInfo(`Lock released for: ${resourceId}`);
|
||||
release();
|
||||
if (this.locks.get(resourceId) === newLock) {
|
||||
this.locks.delete(resourceId);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export as a singleton for the entire agent process
|
||||
export const lockManager = new AsyncLockManager();
|
||||
@@ -0,0 +1,53 @@
|
||||
import { logInfo, logError } from '../utils';
|
||||
|
||||
/**
|
||||
* ActionQueueManager: Manages large-scale tasks by processing them
|
||||
* sequentially to prevent resource exhaustion and I/O bottlenecks.
|
||||
*/
|
||||
export class ActionQueueManager {
|
||||
private queue: (() => Promise<void>)[] = [];
|
||||
private isProcessing: boolean = false;
|
||||
|
||||
/**
|
||||
* Adds a task to the queue.
|
||||
*/
|
||||
public async enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.queue.push(async () => {
|
||||
try {
|
||||
const result = await task();
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
this.processNext();
|
||||
});
|
||||
}
|
||||
|
||||
private async processNext() {
|
||||
if (this.isProcessing || this.queue.length === 0) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
const task = this.queue.shift();
|
||||
|
||||
if (task) {
|
||||
try {
|
||||
// Add a micro-delay to allow system breathing room between heavy I/O
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
await task();
|
||||
} catch (error) {
|
||||
logError('Task in queue failed:', error);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
this.processNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getPendingCount(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
}
|
||||
|
||||
export const actionQueue = new ActionQueueManager();
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ChatMessage } from '../agent';
|
||||
import { logInfo, logError } from '../utils';
|
||||
|
||||
interface SessionData {
|
||||
taskId: string;
|
||||
history: ChatMessage[];
|
||||
lastActionStr?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private sessionDir: string;
|
||||
|
||||
constructor(private context: vscode.ExtensionContext) {
|
||||
// Use globalStorageUri for persistence across workspace restarts
|
||||
this.sessionDir = path.join(this.context.globalStorageUri.fsPath, 'sessions');
|
||||
this.ensureDir(this.sessionDir);
|
||||
}
|
||||
|
||||
private ensureDir(dir: string) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current session state.
|
||||
*/
|
||||
public async saveSession(taskId: string, history: ChatMessage[], lastActionStr?: string) {
|
||||
const sessionData: SessionData = {
|
||||
taskId,
|
||||
history,
|
||||
lastActionStr,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
const filePath = path.join(this.sessionDir, `session_${this.sanitizeFilename(taskId)}.json`);
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(sessionData, null, 2), 'utf-8');
|
||||
// Also store the last active taskId in globalState
|
||||
await this.context.globalState.update('activeTaskId', taskId);
|
||||
} catch (error: any) {
|
||||
logError('Failed to save session state', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a specific session by taskId.
|
||||
*/
|
||||
public loadSession(taskId: string): SessionData | null {
|
||||
const filePath = path.join(this.sessionDir, `session_${this.sanitizeFilename(taskId)}.json`);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content) as SessionData;
|
||||
} catch (error: any) {
|
||||
logError(`Failed to load session for taskId: ${taskId}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the last active session.
|
||||
*/
|
||||
public loadLastActiveSession(): SessionData | null {
|
||||
const lastTaskId = this.context.globalState.get<string>('activeTaskId');
|
||||
if (!lastTaskId) return null;
|
||||
return this.loadSession(lastTaskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a specific session.
|
||||
*/
|
||||
public clearSession(taskId: string) {
|
||||
const filePath = path.join(this.sessionDir, `session_${this.sanitizeFilename(taskId)}.json`);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeFilename(name: string): string {
|
||||
return name.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export enum AgentStatus {
|
||||
Idle = 'Idle',
|
||||
Thinking = 'Thinking...',
|
||||
Executing = 'Executing Actions...',
|
||||
Error = 'Error',
|
||||
Success = 'Success'
|
||||
}
|
||||
|
||||
export class StatusBarManager {
|
||||
private statusBarItem: vscode.StatusBarItem;
|
||||
|
||||
constructor() {
|
||||
this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
||||
this.statusBarItem.command = 'g1nation.focusInput';
|
||||
this.updateStatus(AgentStatus.Idle);
|
||||
this.statusBarItem.show();
|
||||
}
|
||||
|
||||
public updateStatus(status: AgentStatus, detail?: string) {
|
||||
let icon = '';
|
||||
switch (status) {
|
||||
case AgentStatus.Idle:
|
||||
icon = '$(pass)';
|
||||
this.statusBarItem.backgroundColor = undefined;
|
||||
break;
|
||||
case AgentStatus.Thinking:
|
||||
icon = '$(sync~spin)';
|
||||
this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
||||
break;
|
||||
case AgentStatus.Executing:
|
||||
icon = '$(gear~spin)';
|
||||
this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
||||
break;
|
||||
case AgentStatus.Error:
|
||||
icon = '$(error)';
|
||||
this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
|
||||
break;
|
||||
case AgentStatus.Success:
|
||||
icon = '$(check)';
|
||||
this.statusBarItem.backgroundColor = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
this.statusBarItem.text = `${icon} G1nation: ${status}`;
|
||||
this.statusBarItem.tooltip = detail || `Current State: ${status}`;
|
||||
|
||||
if (status === AgentStatus.Success || status === AgentStatus.Error) {
|
||||
setTimeout(() => this.updateStatus(AgentStatus.Idle), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.statusBarItem.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { FileSystemError, TransactionError } from './errors';
|
||||
import { logInfo, logError } from '../utils';
|
||||
|
||||
interface BackupEntry {
|
||||
path: string;
|
||||
type: 'modified' | 'created' | 'deleted';
|
||||
originalContent?: string;
|
||||
}
|
||||
|
||||
export class TransactionManager {
|
||||
private backups: Map<string, BackupEntry> = new Map();
|
||||
private externalVerifications: Map<string, boolean> = new Map();
|
||||
private isTransactionActive: boolean = false;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Starts a new transaction.
|
||||
*/
|
||||
public begin() {
|
||||
if (this.isTransactionActive) {
|
||||
throw new TransactionError('A transaction is already in progress.');
|
||||
}
|
||||
this.backups.clear();
|
||||
this.externalVerifications.clear();
|
||||
this.isTransactionActive = true;
|
||||
logInfo('Transaction started.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the success of an external action (e.g., API call).
|
||||
*/
|
||||
public recordExternalAction(actionId: string, success: boolean) {
|
||||
if (!this.isTransactionActive) return;
|
||||
this.externalVerifications.set(actionId, success);
|
||||
logInfo(`External action recorded: ${actionId} (success: ${success})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if all recorded external actions were successful.
|
||||
*/
|
||||
public isFullyVerified(): boolean {
|
||||
for (const success of this.externalVerifications.values()) {
|
||||
if (!success) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a file state before modification.
|
||||
*/
|
||||
public async record(filePath: string) {
|
||||
if (!this.isTransactionActive) return;
|
||||
if (this.backups.has(filePath)) return; // Already backed up in this transaction
|
||||
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
this.backups.set(filePath, {
|
||||
path: filePath,
|
||||
type: 'modified',
|
||||
originalContent: content
|
||||
});
|
||||
} else {
|
||||
this.backups.set(filePath, {
|
||||
path: filePath,
|
||||
type: 'created'
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new FileSystemError(`Failed to backup file: ${error.message}`, filePath, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits the transaction, clearing all backups.
|
||||
*/
|
||||
public commit() {
|
||||
if (!this.isTransactionActive) return;
|
||||
this.backups.clear();
|
||||
this.isTransactionActive = false;
|
||||
logInfo('Transaction committed successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolls back all changes made during the transaction.
|
||||
*/
|
||||
public rollback() {
|
||||
if (!this.isTransactionActive) return;
|
||||
|
||||
logInfo(`Rolling back ${this.backups.size} changes...`);
|
||||
|
||||
for (const entry of this.backups.values()) {
|
||||
try {
|
||||
if (entry.type === 'created') {
|
||||
if (fs.existsSync(entry.path)) {
|
||||
fs.unlinkSync(entry.path);
|
||||
logInfo(`Rollback: Deleted created file ${entry.path}`);
|
||||
}
|
||||
} else if (entry.type === 'modified' || entry.type === 'deleted') {
|
||||
if (entry.originalContent !== undefined) {
|
||||
const dir = path.dirname(entry.path);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(entry.path, entry.originalContent, 'utf-8');
|
||||
logInfo(`Rollback: Restored file ${entry.path}`);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logError(`Failed to rollback change for ${entry.path}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.backups.clear();
|
||||
this.isTransactionActive = false;
|
||||
logInfo('Transaction rollback completed.');
|
||||
}
|
||||
|
||||
public isActive(): boolean {
|
||||
return this.isTransactionActive;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance if needed, or instantiate per AgentExecutor
|
||||
Reference in New Issue
Block a user