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:
389
app/lib/services/localModelHealthMonitor.ts
Normal file
389
app/lib/services/localModelHealthMonitor.ts
Normal 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();
|
||||
Reference in New Issue
Block a user