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:
165
app/lib/hooks/useLocalModelHealth.ts
Normal file
165
app/lib/hooks/useLocalModelHealth.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { localModelHealthMonitor, type ModelHealthStatus } from '~/lib/services/localModelHealthMonitor';
|
||||
|
||||
export interface UseLocalModelHealthOptions {
|
||||
autoStart?: boolean;
|
||||
checkInterval?: number;
|
||||
}
|
||||
|
||||
export interface UseLocalModelHealthReturn {
|
||||
healthStatuses: ModelHealthStatus[];
|
||||
getHealthStatus: (provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => ModelHealthStatus | undefined;
|
||||
startMonitoring: (provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string, checkInterval?: number) => void;
|
||||
stopMonitoring: (provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => void;
|
||||
performHealthCheck: (provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => Promise<void>;
|
||||
isHealthy: (provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => boolean;
|
||||
getOverallHealth: () => { healthy: number; unhealthy: number; checking: number; unknown: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for monitoring local model health
|
||||
*/
|
||||
export function useLocalModelHealth(options: UseLocalModelHealthOptions = {}): UseLocalModelHealthReturn {
|
||||
const { checkInterval } = options;
|
||||
const [healthStatuses, setHealthStatuses] = useState<ModelHealthStatus[]>([]);
|
||||
|
||||
// Update health statuses when they change
|
||||
useEffect(() => {
|
||||
const handleStatusChanged = (status: ModelHealthStatus) => {
|
||||
setHealthStatuses((current) => {
|
||||
const index = current.findIndex((s) => s.provider === status.provider && s.baseUrl === status.baseUrl);
|
||||
|
||||
if (index >= 0) {
|
||||
const updated = [...current];
|
||||
updated[index] = status;
|
||||
|
||||
return updated;
|
||||
} else {
|
||||
return [...current, status];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
localModelHealthMonitor.on('statusChanged', handleStatusChanged);
|
||||
|
||||
// Initialize with current statuses
|
||||
setHealthStatuses(localModelHealthMonitor.getAllHealthStatuses());
|
||||
|
||||
return () => {
|
||||
localModelHealthMonitor.off('statusChanged', handleStatusChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get health status for a specific provider
|
||||
const getHealthStatus = useCallback((provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => {
|
||||
return localModelHealthMonitor.getHealthStatus(provider, baseUrl);
|
||||
}, []);
|
||||
|
||||
// Start monitoring a provider
|
||||
const startMonitoring = useCallback(
|
||||
(provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string, interval?: number) => {
|
||||
console.log(`[Health Monitor] Starting monitoring for ${provider} at ${baseUrl}`);
|
||||
localModelHealthMonitor.startMonitoring(provider, baseUrl, interval || checkInterval);
|
||||
},
|
||||
[checkInterval],
|
||||
);
|
||||
|
||||
// Stop monitoring a provider
|
||||
const stopMonitoring = useCallback((provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => {
|
||||
console.log(`[Health Monitor] Stopping monitoring for ${provider} at ${baseUrl}`);
|
||||
localModelHealthMonitor.stopMonitoring(provider, baseUrl);
|
||||
|
||||
// Remove from local state
|
||||
setHealthStatuses((current) => current.filter((s) => !(s.provider === provider && s.baseUrl === baseUrl)));
|
||||
}, []);
|
||||
|
||||
// Perform manual health check
|
||||
const performHealthCheck = useCallback(async (provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => {
|
||||
await localModelHealthMonitor.performHealthCheck(provider, baseUrl);
|
||||
}, []);
|
||||
|
||||
// Check if a provider is healthy
|
||||
const isHealthy = useCallback(
|
||||
(provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => {
|
||||
const status = getHealthStatus(provider, baseUrl);
|
||||
return status?.status === 'healthy';
|
||||
},
|
||||
[getHealthStatus],
|
||||
);
|
||||
|
||||
// Get overall health statistics
|
||||
const getOverallHealth = useCallback(() => {
|
||||
const stats = { healthy: 0, unhealthy: 0, checking: 0, unknown: 0 };
|
||||
|
||||
healthStatuses.forEach((status) => {
|
||||
stats[status.status]++;
|
||||
});
|
||||
|
||||
return stats;
|
||||
}, [healthStatuses]);
|
||||
|
||||
return {
|
||||
healthStatuses,
|
||||
getHealthStatus,
|
||||
startMonitoring,
|
||||
stopMonitoring,
|
||||
performHealthCheck,
|
||||
isHealthy,
|
||||
getOverallHealth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for monitoring a specific provider
|
||||
*/
|
||||
export function useProviderHealth(
|
||||
provider: 'Ollama' | 'LMStudio' | 'OpenAILike',
|
||||
baseUrl: string,
|
||||
options: UseLocalModelHealthOptions = {},
|
||||
) {
|
||||
const { autoStart = true, checkInterval } = options;
|
||||
const { getHealthStatus, startMonitoring, stopMonitoring, performHealthCheck, isHealthy } = useLocalModelHealth();
|
||||
|
||||
const [status, setStatus] = useState<ModelHealthStatus | undefined>();
|
||||
|
||||
// Update status when it changes
|
||||
useEffect(() => {
|
||||
const updateStatus = () => {
|
||||
setStatus(getHealthStatus(provider, baseUrl));
|
||||
};
|
||||
|
||||
const handleStatusChanged = (changedStatus: ModelHealthStatus) => {
|
||||
if (changedStatus.provider === provider && changedStatus.baseUrl === baseUrl) {
|
||||
setStatus(changedStatus);
|
||||
}
|
||||
};
|
||||
|
||||
localModelHealthMonitor.on('statusChanged', handleStatusChanged);
|
||||
updateStatus();
|
||||
|
||||
return () => {
|
||||
localModelHealthMonitor.off('statusChanged', handleStatusChanged);
|
||||
};
|
||||
}, [provider, baseUrl, getHealthStatus]);
|
||||
|
||||
// Auto-start monitoring if enabled
|
||||
useEffect(() => {
|
||||
if (autoStart && baseUrl) {
|
||||
startMonitoring(provider, baseUrl, checkInterval);
|
||||
|
||||
return () => {
|
||||
stopMonitoring(provider, baseUrl);
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [autoStart, provider, baseUrl, checkInterval, startMonitoring, stopMonitoring]);
|
||||
|
||||
return {
|
||||
status,
|
||||
isHealthy: isHealthy(provider, baseUrl),
|
||||
performHealthCheck: () => performHealthCheck(provider, baseUrl),
|
||||
startMonitoring: (interval?: number) => startMonitoring(provider, baseUrl, interval),
|
||||
stopMonitoring: () => stopMonitoring(provider, baseUrl),
|
||||
};
|
||||
}
|
||||
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