feat: local providers refactor & enhancement (#1968)

* feat: improve local providers health monitoring and model management

- Add automatic health monitoring initialization for enabled providers
- Add LM Studio model management and display functionality
- Fix endpoint status detection by setting default base URLs
- Replace mixed icon libraries with consistent Lucide icons only
- Fix button styling with transparent backgrounds
- Add comprehensive setup guides with web-researched content
- Add proper navigation with back buttons between views
- Fix all TypeScript and linting issues in LocalProvidersTab

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove Service Status tab and related code

The Service Status tab and all associated files, components, and provider checkers have been deleted. References to 'service-status' have been removed from tab constants, types, and the control panel. This simplifies the settings UI and codebase by eliminating the service status monitoring feature.

* Update LocalProvidersTab.tsx

* Fix all linter and TypeScript errors in local providers components

- Remove unused imports and fix import formatting
- Fix type-only imports for OllamaModel and LMStudioModel
- Fix Icon component usage in ProviderCard.tsx
- Clean up unused imports across all local provider files
- Ensure all TypeScript and ESLint checks pass

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Stijnus
2025-09-06 19:03:25 +02:00
committed by GitHub
parent 3ea96506ea
commit a44de8addc
37 changed files with 2794 additions and 3706 deletions

View File

@@ -0,0 +1,389 @@
// Simple EventEmitter implementation for browser compatibility
class SimpleEventEmitter {
private _events: Record<string, ((...args: any[]) => void)[]> = {};
on(event: string, listener: (...args: any[]) => void): void {
if (!this._events[event]) {
this._events[event] = [];
}
this._events[event].push(listener);
}
off(event: string, listener: (...args: any[]) => void): void {
if (!this._events[event]) {
return;
}
this._events[event] = this._events[event].filter((l) => l !== listener);
}
emit(event: string, ...args: any[]): void {
if (!this._events[event]) {
return;
}
this._events[event].forEach((listener) => listener(...args));
}
removeAllListeners(): void {
this._events = {};
}
}
export interface ModelHealthStatus {
provider: 'Ollama' | 'LMStudio' | 'OpenAILike';
baseUrl: string;
status: 'healthy' | 'unhealthy' | 'checking' | 'unknown';
lastChecked: Date;
responseTime?: number;
error?: string;
availableModels?: string[];
version?: string;
}
export interface HealthCheckResult {
isHealthy: boolean;
responseTime: number;
error?: string;
availableModels?: string[];
version?: string;
}
export class LocalModelHealthMonitor extends SimpleEventEmitter {
private _healthStatuses = new Map<string, ModelHealthStatus>();
private _checkIntervals = new Map<string, NodeJS.Timeout>();
private readonly _defaultCheckInterval = 30000; // 30 seconds
private readonly _healthCheckTimeout = 10000; // 10 seconds
constructor() {
super();
}
/**
* Start monitoring a local provider
*/
startMonitoring(provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string, checkInterval?: number): void {
const key = this._getProviderKey(provider, baseUrl);
// Stop existing monitoring if any
this.stopMonitoring(provider, baseUrl);
// Initialize status
this._healthStatuses.set(key, {
provider,
baseUrl,
status: 'unknown',
lastChecked: new Date(),
});
// Start periodic health checks
const interval = setInterval(async () => {
await this.performHealthCheck(provider, baseUrl);
}, checkInterval || this._defaultCheckInterval);
this._checkIntervals.set(key, interval);
// Perform initial health check
this.performHealthCheck(provider, baseUrl);
}
/**
* Stop monitoring a local provider
*/
stopMonitoring(provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string): void {
const key = this._getProviderKey(provider, baseUrl);
const interval = this._checkIntervals.get(key);
if (interval) {
clearInterval(interval);
this._checkIntervals.delete(key);
}
this._healthStatuses.delete(key);
}
/**
* Get current health status for a provider
*/
getHealthStatus(provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string): ModelHealthStatus | undefined {
const key = this._getProviderKey(provider, baseUrl);
return this._healthStatuses.get(key);
}
/**
* Get all health statuses
*/
getAllHealthStatuses(): ModelHealthStatus[] {
return Array.from(this._healthStatuses.values());
}
/**
* Perform a manual health check
*/
async performHealthCheck(
provider: 'Ollama' | 'LMStudio' | 'OpenAILike',
baseUrl: string,
): Promise<HealthCheckResult> {
const key = this._getProviderKey(provider, baseUrl);
const startTime = Date.now();
// Update status to checking
const currentStatus = this._healthStatuses.get(key);
if (currentStatus) {
currentStatus.status = 'checking';
currentStatus.lastChecked = new Date();
this.emit('statusChanged', currentStatus);
}
try {
const result = await this._checkProviderHealth(provider, baseUrl);
const responseTime = Date.now() - startTime;
// Update health status
const healthStatus: ModelHealthStatus = {
provider,
baseUrl,
status: result.isHealthy ? 'healthy' : 'unhealthy',
lastChecked: new Date(),
responseTime,
error: result.error,
availableModels: result.availableModels,
version: result.version,
};
this._healthStatuses.set(key, healthStatus);
this.emit('statusChanged', healthStatus);
return {
isHealthy: result.isHealthy,
responseTime,
error: result.error,
availableModels: result.availableModels,
version: result.version,
};
} catch (error) {
const responseTime = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const healthStatus: ModelHealthStatus = {
provider,
baseUrl,
status: 'unhealthy',
lastChecked: new Date(),
responseTime,
error: errorMessage,
};
this._healthStatuses.set(key, healthStatus);
this.emit('statusChanged', healthStatus);
return {
isHealthy: false,
responseTime,
error: errorMessage,
};
}
}
/**
* Check health of a specific provider
*/
private async _checkProviderHealth(
provider: 'Ollama' | 'LMStudio' | 'OpenAILike',
baseUrl: string,
): Promise<HealthCheckResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this._healthCheckTimeout);
try {
switch (provider) {
case 'Ollama':
return await this._checkOllamaHealth(baseUrl, controller.signal);
case 'LMStudio':
return await this._checkLMStudioHealth(baseUrl, controller.signal);
case 'OpenAILike':
return await this._checkOpenAILikeHealth(baseUrl, controller.signal);
default:
throw new Error(`Unsupported provider: ${provider}`);
}
} finally {
clearTimeout(timeoutId);
}
}
/**
* Check Ollama health
*/
private async _checkOllamaHealth(baseUrl: string, signal: AbortSignal): Promise<HealthCheckResult> {
try {
console.log(`[Health Check] Checking Ollama at ${baseUrl}`);
// Check if Ollama is running
const response = await fetch(`${baseUrl}/api/tags`, {
method: 'GET',
signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as { models?: Array<{ name: string }> };
const models = data.models?.map((model) => model.name) || [];
console.log(`[Health Check] Ollama healthy with ${models.length} models`);
// Try to get version info
let version: string | undefined;
try {
const versionResponse = await fetch(`${baseUrl}/api/version`, { signal });
if (versionResponse.ok) {
const versionData = (await versionResponse.json()) as { version?: string };
version = versionData.version;
}
} catch {
// Version endpoint might not be available in older versions
}
return {
isHealthy: true,
responseTime: 0, // Will be calculated by caller
availableModels: models,
version,
};
} catch (error) {
console.error(`[Health Check] Ollama health check failed:`, error);
return {
isHealthy: false,
responseTime: 0,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Check LM Studio health
*/
private async _checkLMStudioHealth(baseUrl: string, signal: AbortSignal): Promise<HealthCheckResult> {
try {
// Normalize URL to ensure /v1 prefix
const normalizedUrl = baseUrl.includes('/v1') ? baseUrl : `${baseUrl}/v1`;
const response = await fetch(`${normalizedUrl}/models`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
signal,
});
if (!response.ok) {
// Check if this is a CORS error
if (response.type === 'opaque' || response.status === 0) {
throw new Error(
'CORS_ERROR: LM Studio server is not configured to allow requests from this origin. Please configure CORS in LM Studio settings.',
);
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as { data?: Array<{ id: string }> };
const models = data.data?.map((model) => model.id) || [];
return {
isHealthy: true,
responseTime: 0,
availableModels: models,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Check if this is a CORS error
if (
errorMessage.includes('CORS') ||
errorMessage.includes('NetworkError') ||
errorMessage.includes('Failed to fetch')
) {
return {
isHealthy: false,
responseTime: 0,
error:
'CORS_ERROR: LM Studio server is blocking cross-origin requests. Try enabling CORS in LM Studio settings or use Bolt desktop app.',
};
}
return {
isHealthy: false,
responseTime: 0,
error: errorMessage,
};
}
}
/**
* Check OpenAI-like provider health
*/
private async _checkOpenAILikeHealth(baseUrl: string, signal: AbortSignal): Promise<HealthCheckResult> {
try {
// Normalize URL to include /v1 if needed
const normalizedUrl = baseUrl.includes('/v1') ? baseUrl : `${baseUrl}/v1`;
const response = await fetch(`${normalizedUrl}/models`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as { data?: Array<{ id: string }> };
const models = data.data?.map((model) => model.id) || [];
return {
isHealthy: true,
responseTime: 0,
availableModels: models,
};
} catch (error) {
return {
isHealthy: false,
responseTime: 0,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Generate a unique key for a provider
*/
private _getProviderKey(provider: string, baseUrl: string): string {
return `${provider}:${baseUrl}`;
}
/**
* Clean up all monitoring
*/
destroy(): void {
// Clear all intervals
for (const interval of this._checkIntervals.values()) {
clearInterval(interval);
}
this._checkIntervals.clear();
this._healthStatuses.clear();
this.removeAllListeners();
}
}
// Singleton instance
export const localModelHealthMonitor = new LocalModelHealthMonitor();