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
+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