* 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>
390 lines
11 KiB
TypeScript
390 lines
11 KiB
TypeScript
// 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();
|