diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx index 276151b8fd..76a8bd42e3 100644 --- a/app/components/@settings/core/ControlPanel.tsx +++ b/app/components/@settings/core/ControlPanel.tsx @@ -23,7 +23,6 @@ import { DataTab } from '~/components/@settings/tabs/data/DataTab'; import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab'; import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab'; import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab'; -import ServiceStatusTab from '~/components/@settings/tabs/providers/status/ServiceStatusTab'; import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab'; import McpTab from '~/components/@settings/tabs/mcp/McpTab'; @@ -33,7 +32,7 @@ interface ControlPanelProps { } // Beta status for experimental features -const BETA_TABS = new Set(['service-status', 'local-providers', 'mcp']); +const BETA_TABS = new Set(['local-providers', 'mcp']); const BetaLabel = () => (
@@ -138,8 +137,6 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { return ; case 'event-logs': return ; - case 'service-status': - return ; case 'mcp': return ; diff --git a/app/components/@settings/core/constants.ts b/app/components/@settings/core/constants.ts index fc7088aa3b..db17f1eccd 100644 --- a/app/components/@settings/core/constants.ts +++ b/app/components/@settings/core/constants.ts @@ -8,7 +8,6 @@ export const TAB_ICONS: Record = { data: 'i-ph:database', 'cloud-providers': 'i-ph:cloud', 'local-providers': 'i-ph:laptop', - 'service-status': 'i-ph:activity-bold', connection: 'i-ph:wifi-high', 'event-logs': 'i-ph:list-bullets', mcp: 'i-ph:wrench', @@ -22,7 +21,6 @@ export const TAB_LABELS: Record = { data: 'Data Management', 'cloud-providers': 'Cloud Providers', 'local-providers': 'Local Providers', - 'service-status': 'Service Status', connection: 'Connection', 'event-logs': 'Event Logs', mcp: 'MCP Servers', @@ -36,7 +34,6 @@ export const TAB_DESCRIPTIONS: Record = { data: 'Manage your data and storage', 'cloud-providers': 'Configure cloud AI providers and models', 'local-providers': 'Configure local AI providers and models', - 'service-status': 'Monitor cloud LLM service status', connection: 'Check connection status and settings', 'event-logs': 'View system events and logs', mcp: 'Configure MCP (Model Context Protocol) servers', @@ -54,8 +51,7 @@ export const DEFAULT_TAB_CONFIG = [ { id: 'mcp', visible: true, window: 'user' as const, order: 7 }, { id: 'profile', visible: true, window: 'user' as const, order: 9 }, - { id: 'service-status', visible: true, window: 'user' as const, order: 10 }, - { id: 'settings', visible: true, window: 'user' as const, order: 11 }, + { id: 'settings', visible: true, window: 'user' as const, order: 10 }, // User Window Tabs (In dropdown, initially hidden) ]; diff --git a/app/components/@settings/core/types.ts b/app/components/@settings/core/types.ts index d4a518f4b5..a679e9dd71 100644 --- a/app/components/@settings/core/types.ts +++ b/app/components/@settings/core/types.ts @@ -10,7 +10,6 @@ export type TabType = | 'data' | 'cloud-providers' | 'local-providers' - | 'service-status' | 'connection' | 'event-logs' | 'mcp'; @@ -70,7 +69,6 @@ export const TAB_LABELS: Record = { data: 'Data Management', 'cloud-providers': 'Cloud Providers', 'local-providers': 'Local Providers', - 'service-status': 'Service Status', connection: 'Connections', 'event-logs': 'Event Logs', mcp: 'MCP Servers', diff --git a/app/components/@settings/tabs/providers/local/ErrorBoundary.tsx b/app/components/@settings/tabs/providers/local/ErrorBoundary.tsx new file mode 100644 index 0000000000..a46f1a2be2 --- /dev/null +++ b/app/components/@settings/tabs/providers/local/ErrorBoundary.tsx @@ -0,0 +1,68 @@ +import React, { Component } from 'react'; +import type { ReactNode } from 'react'; +import { AlertCircle } from 'lucide-react'; +import { classNames } from '~/utils/classNames'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; +} + +interface State { + hasError: boolean; + error?: Error; +} + +export default class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('Local Providers Error Boundary caught an error:', error, errorInfo); + this.props.onError?.(error, errorInfo); + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+ +

Something went wrong

+

There was an error loading the local providers section.

+ + {process.env.NODE_ENV === 'development' && this.state.error && ( +
+ Error Details +
+                {this.state.error.stack}
+              
+
+ )} +
+ ); + } + + return this.props.children; + } +} diff --git a/app/components/@settings/tabs/providers/local/HealthStatusBadge.tsx b/app/components/@settings/tabs/providers/local/HealthStatusBadge.tsx new file mode 100644 index 0000000000..863a7dd35e --- /dev/null +++ b/app/components/@settings/tabs/providers/local/HealthStatusBadge.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { CheckCircle, XCircle, Loader2, AlertCircle } from 'lucide-react'; +import { classNames } from '~/utils/classNames'; + +interface HealthStatusBadgeProps { + status: 'healthy' | 'unhealthy' | 'checking' | 'unknown'; + responseTime?: number; + className?: string; +} + +function HealthStatusBadge({ status, responseTime, className }: HealthStatusBadgeProps) { + const getStatusConfig = () => { + switch (status) { + case 'healthy': + return { + color: 'text-green-500', + bgColor: 'bg-green-500/10 border-green-500/20', + Icon: CheckCircle, + label: 'Healthy', + }; + case 'unhealthy': + return { + color: 'text-red-500', + bgColor: 'bg-red-500/10 border-red-500/20', + Icon: XCircle, + label: 'Unhealthy', + }; + case 'checking': + return { + color: 'text-blue-500', + bgColor: 'bg-blue-500/10 border-blue-500/20', + Icon: Loader2, + label: 'Checking', + }; + default: + return { + color: 'text-bolt-elements-textTertiary', + bgColor: 'bg-bolt-elements-background-depth-3 border-bolt-elements-borderColor', + Icon: AlertCircle, + label: 'Unknown', + }; + } + }; + + const config = getStatusConfig(); + const Icon = config.Icon; + + return ( +
+ + {config.label} + {responseTime !== undefined && status === 'healthy' && ({responseTime}ms)} +
+ ); +} + +export default HealthStatusBadge; diff --git a/app/components/@settings/tabs/providers/local/LoadingSkeleton.tsx b/app/components/@settings/tabs/providers/local/LoadingSkeleton.tsx new file mode 100644 index 0000000000..db84458328 --- /dev/null +++ b/app/components/@settings/tabs/providers/local/LoadingSkeleton.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { classNames } from '~/utils/classNames'; + +interface LoadingSkeletonProps { + className?: string; + lines?: number; + height?: string; +} + +export function LoadingSkeleton({ className, lines = 1, height = 'h-4' }: LoadingSkeletonProps) { + return ( +
+ {Array.from({ length: lines }).map((_, i) => ( +
+ ))} +
+ ); +} + +interface ModelCardSkeletonProps { + className?: string; +} + +export function ModelCardSkeleton({ className }: ModelCardSkeletonProps) { + return ( +
+
+
+
+
+ + +
+
+
+
+
+ ); +} + +interface ProviderCardSkeletonProps { + className?: string; +} + +export function ProviderCardSkeleton({ className }: ProviderCardSkeletonProps) { + return ( +
+
+
+
+
+
+ + +
+
+ + +
+
+
+
+
+
+ ); +} + +interface ModelManagerSkeletonProps { + className?: string; + cardCount?: number; +} + +export function ModelManagerSkeleton({ className, cardCount = 3 }: ModelManagerSkeletonProps) { + return ( +
+ {/* Header */} +
+
+ + +
+
+
+
+
+
+ + {/* Model Cards */} +
+ {Array.from({ length: cardCount }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/app/components/@settings/tabs/providers/local/LocalProvidersTab.new.tsx b/app/components/@settings/tabs/providers/local/LocalProvidersTab.new.tsx new file mode 100644 index 0000000000..5e0f61108a --- /dev/null +++ b/app/components/@settings/tabs/providers/local/LocalProvidersTab.new.tsx @@ -0,0 +1,556 @@ +import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import { Switch } from '~/components/ui/Switch'; +import { Card, CardContent, CardHeader } from '~/components/ui/Card'; +import { Button } from '~/components/ui/Button'; +import { useSettings } from '~/lib/hooks/useSettings'; +import { LOCAL_PROVIDERS } from '~/lib/stores/settings'; +import type { IProviderConfig } from '~/types/model'; +import { logStore } from '~/lib/stores/logs'; +import { providerBaseUrlEnvKeys } from '~/utils/constants'; +import { useToast } from '~/components/ui/use-toast'; +import { useLocalModelHealth } from '~/lib/hooks/useLocalModelHealth'; +import ErrorBoundary from './ErrorBoundary'; +import { ModelCardSkeleton } from './LoadingSkeleton'; +import SetupGuide from './SetupGuide'; +import StatusDashboard from './StatusDashboard'; +import ProviderCard from './ProviderCard'; +import ModelCard from './ModelCard'; +import { OLLAMA_API_URL } from './types'; +import type { OllamaModel, LMStudioModel } from './types'; +import { Cpu, Server, BookOpen, Activity, PackageOpen, Monitor, Loader2, RotateCw, ExternalLink } from 'lucide-react'; + +// Type definitions +type ViewMode = 'dashboard' | 'guide' | 'status'; + +export default function LocalProvidersTab() { + const { providers, updateProviderSettings } = useSettings(); + const [viewMode, setViewMode] = useState('dashboard'); + const [editingProvider, setEditingProvider] = useState(null); + const [ollamaModels, setOllamaModels] = useState([]); + const [lmStudioModels, setLMStudioModels] = useState([]); + const [isLoadingModels, setIsLoadingModels] = useState(false); + const [isLoadingLMStudioModels, setIsLoadingLMStudioModels] = useState(false); + const { toast } = useToast(); + const { startMonitoring, stopMonitoring } = useLocalModelHealth(); + + // Memoized filtered providers to prevent unnecessary re-renders + const filteredProviders = useMemo(() => { + return Object.entries(providers || {}) + .filter(([key]) => [...LOCAL_PROVIDERS, 'OpenAILike'].includes(key)) + .map(([key, value]) => { + const provider = value as IProviderConfig; + const envKey = providerBaseUrlEnvKeys[key]?.baseUrlKey; + const envUrl = envKey ? (import.meta.env[envKey] as string | undefined) : undefined; + + // Set default base URLs for local providers + let defaultBaseUrl = provider.settings.baseUrl || envUrl; + + if (!defaultBaseUrl) { + if (key === 'Ollama') { + defaultBaseUrl = 'http://127.0.0.1:11434'; + } else if (key === 'LMStudio') { + defaultBaseUrl = 'http://127.0.0.1:1234'; + } + } + + return { + name: key, + settings: { + ...provider.settings, + baseUrl: defaultBaseUrl, + }, + staticModels: provider.staticModels || [], + getDynamicModels: provider.getDynamicModels, + getApiKeyLink: provider.getApiKeyLink, + labelForGetApiKey: provider.labelForGetApiKey, + icon: provider.icon, + } as IProviderConfig; + }) + .sort((a, b) => { + // Custom sort: Ollama first, then LMStudio, then OpenAILike + const order = { Ollama: 0, LMStudio: 1, OpenAILike: 2 }; + return (order[a.name as keyof typeof order] || 3) - (order[b.name as keyof typeof order] || 3); + }); + }, [providers]); + + const categoryEnabled = useMemo(() => { + return filteredProviders.length > 0 && filteredProviders.every((p) => p.settings.enabled); + }, [filteredProviders]); + + // Start/stop health monitoring for enabled providers + useEffect(() => { + filteredProviders.forEach((provider) => { + const baseUrl = provider.settings.baseUrl; + + if (provider.settings.enabled && baseUrl) { + console.log(`[LocalProvidersTab] Starting monitoring for ${provider.name} at ${baseUrl}`); + startMonitoring(provider.name as 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl); + } else if (!provider.settings.enabled && baseUrl) { + console.log(`[LocalProvidersTab] Stopping monitoring for ${provider.name} at ${baseUrl}`); + stopMonitoring(provider.name as 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl); + } + }); + }, [filteredProviders, startMonitoring, stopMonitoring]); + + // Fetch Ollama models when enabled + useEffect(() => { + const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama'); + + if (ollamaProvider?.settings.enabled) { + fetchOllamaModels(); + } + }, [filteredProviders]); + + // Fetch LM Studio models when enabled + useEffect(() => { + const lmStudioProvider = filteredProviders.find((p) => p.name === 'LMStudio'); + + if (lmStudioProvider?.settings.enabled && lmStudioProvider.settings.baseUrl) { + fetchLMStudioModels(lmStudioProvider.settings.baseUrl); + } + }, [filteredProviders]); + + const fetchOllamaModels = async () => { + try { + setIsLoadingModels(true); + + const response = await fetch(`${OLLAMA_API_URL}/api/tags`); + + if (!response.ok) { + throw new Error('Failed to fetch models'); + } + + const data = (await response.json()) as { models: OllamaModel[] }; + setOllamaModels( + data.models.map((model) => ({ + ...model, + status: 'idle' as const, + })), + ); + } catch { + console.error('Error fetching Ollama models'); + } finally { + setIsLoadingModels(false); + } + }; + + const fetchLMStudioModels = async (baseUrl: string) => { + try { + setIsLoadingLMStudioModels(true); + + const response = await fetch(`${baseUrl}/v1/models`); + + if (!response.ok) { + throw new Error('Failed to fetch LM Studio models'); + } + + const data = (await response.json()) as { data: LMStudioModel[] }; + setLMStudioModels(data.data || []); + } catch { + console.error('Error fetching LM Studio models'); + setLMStudioModels([]); + } finally { + setIsLoadingLMStudioModels(false); + } + }; + + const handleToggleCategory = useCallback( + async (enabled: boolean) => { + filteredProviders.forEach((provider) => { + updateProviderSettings(provider.name, { ...provider.settings, enabled }); + }); + toast(enabled ? 'All local providers enabled' : 'All local providers disabled'); + }, + [filteredProviders, updateProviderSettings, toast], + ); + + const handleToggleProvider = useCallback( + (provider: IProviderConfig, enabled: boolean) => { + updateProviderSettings(provider.name, { + ...provider.settings, + enabled, + }); + + logStore.logProvider(`Provider ${provider.name} ${enabled ? 'enabled' : 'disabled'}`, { + provider: provider.name, + }); + toast(`${provider.name} ${enabled ? 'enabled' : 'disabled'}`); + }, + [updateProviderSettings, toast], + ); + + const handleUpdateBaseUrl = useCallback( + (provider: IProviderConfig, newBaseUrl: string) => { + updateProviderSettings(provider.name, { + ...provider.settings, + baseUrl: newBaseUrl, + }); + toast(`${provider.name} base URL updated`); + }, + [updateProviderSettings, toast], + ); + + const handleUpdateOllamaModel = async (modelName: string) => { + try { + setOllamaModels((prev) => prev.map((m) => (m.name === modelName ? { ...m, status: 'updating' } : m))); + + const response = await fetch(`${OLLAMA_API_URL}/api/pull`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: modelName }), + }); + + if (!response.ok) { + throw new Error(`Failed to update ${modelName}`); + } + + // Handle streaming response + const reader = response.body?.getReader(); + + if (!reader) { + throw new Error('No response reader available'); + } + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + const text = new TextDecoder().decode(value); + const lines = text.split('\n').filter(Boolean); + + for (const line of lines) { + try { + const data = JSON.parse(line); + + if (data.status && data.completed && data.total) { + setOllamaModels((current) => + current.map((m) => + m.name === modelName + ? { + ...m, + progress: { + current: data.completed, + total: data.total, + status: data.status, + }, + } + : m, + ), + ); + } + } catch { + // Ignore parsing errors + } + } + } + + setOllamaModels((prev) => + prev.map((m) => (m.name === modelName ? { ...m, status: 'updated', progress: undefined } : m)), + ); + toast(`Successfully updated ${modelName}`); + } catch { + setOllamaModels((prev) => + prev.map((m) => (m.name === modelName ? { ...m, status: 'error', progress: undefined } : m)), + ); + toast(`Failed to update ${modelName}`, { type: 'error' }); + } + }; + + const handleDeleteOllamaModel = async (modelName: string) => { + if (!window.confirm(`Are you sure you want to delete ${modelName}?`)) { + return; + } + + try { + const response = await fetch(`${OLLAMA_API_URL}/api/delete`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: modelName }), + }); + + if (!response.ok) { + throw new Error(`Failed to delete ${modelName}`); + } + + setOllamaModels((current) => current.filter((m) => m.name !== modelName)); + toast(`Deleted ${modelName}`); + } catch { + toast(`Failed to delete ${modelName}`, { type: 'error' }); + } + }; + + // Render different views based on viewMode + if (viewMode === 'guide') { + return ( + + setViewMode('dashboard')} /> + + ); + } + + if (viewMode === 'status') { + return ( + + setViewMode('dashboard')} /> + + ); + } + + return ( + +
+ {/* Header */} +
+
+
+ +
+
+

Local AI Providers

+

Configure and manage your local AI models

+
+
+
+
+ Enable All + +
+
+ + +
+
+
+ + {/* Provider Cards */} +
+ {filteredProviders.map((provider) => ( +
+ handleToggleProvider(provider, enabled)} + onUpdateBaseUrl={(url) => handleUpdateBaseUrl(provider, url)} + isEditing={editingProvider === provider.name} + onStartEditing={() => setEditingProvider(provider.name)} + onStopEditing={() => setEditingProvider(null)} + /> + + {/* Ollama Models Section */} + {provider.name === 'Ollama' && provider.settings.enabled && ( + + +
+
+ +

Installed Models

+
+ +
+
+ + {isLoadingModels ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : ollamaModels.length === 0 ? ( +
+ +

No Models Installed

+

+ Visit{' '} + + ollama.com/library + + {' '} + to browse available models +

+ +
+ ) : ( +
+ {ollamaModels.map((model) => ( + handleUpdateOllamaModel(model.name)} + onDelete={() => handleDeleteOllamaModel(model.name)} + /> + ))} +
+ )} +
+
+ )} + + {/* LM Studio Models Section */} + {provider.name === 'LMStudio' && provider.settings.enabled && ( + + +
+
+ +

Available Models

+
+ +
+
+ + {isLoadingLMStudioModels ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : lmStudioModels.length === 0 ? ( +
+ +

No Models Available

+

+ Make sure LM Studio is running with the local server started and CORS enabled. +

+ +
+ ) : ( +
+ {lmStudioModels.map((model) => ( + + +
+
+

+ {model.id} +

+ + Available + +
+
+
+ + {model.object} +
+
+ + Owned by: {model.owned_by} +
+ {model.created && ( +
+ + Created: {new Date(model.created * 1000).toLocaleDateString()} +
+ )} +
+
+
+
+ ))} +
+ )} +
+
+ )} +
+ ))} +
+ + {filteredProviders.length === 0 && ( + + + +

No Local Providers Available

+

+ Local providers will appear here when they're configured in the system. +

+
+
+ )} +
+
+ ); +} diff --git a/app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx b/app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx index 70e8d2f517..5e0f61108a 100644 --- a/app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx +++ b/app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx @@ -1,107 +1,63 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { Switch } from '~/components/ui/Switch'; +import { Card, CardContent, CardHeader } from '~/components/ui/Card'; +import { Button } from '~/components/ui/Button'; import { useSettings } from '~/lib/hooks/useSettings'; -import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings'; +import { LOCAL_PROVIDERS } from '~/lib/stores/settings'; import type { IProviderConfig } from '~/types/model'; import { logStore } from '~/lib/stores/logs'; -import { motion, AnimatePresence } from 'framer-motion'; -import { classNames } from '~/utils/classNames'; -import { BsRobot } from 'react-icons/bs'; -import type { IconType } from 'react-icons'; -import { BiChip } from 'react-icons/bi'; -import { TbBrandOpenai } from 'react-icons/tb'; import { providerBaseUrlEnvKeys } from '~/utils/constants'; import { useToast } from '~/components/ui/use-toast'; -import { Progress } from '~/components/ui/Progress'; -import OllamaModelInstaller from './OllamaModelInstaller'; - -// Add type for provider names to ensure type safety -type ProviderName = 'Ollama' | 'LMStudio' | 'OpenAILike'; - -// Update the PROVIDER_ICONS type to use the ProviderName type -const PROVIDER_ICONS: Record = { - Ollama: BsRobot, - LMStudio: BsRobot, - OpenAILike: TbBrandOpenai, -}; - -// Update PROVIDER_DESCRIPTIONS to use the same type -const PROVIDER_DESCRIPTIONS: Record = { - Ollama: 'Run open-source models locally on your machine', - LMStudio: 'Local model inference with LM Studio', - OpenAILike: 'Connect to OpenAI-compatible API endpoints', -}; - -// Add a constant for the Ollama API base URL -const OLLAMA_API_URL = 'http://127.0.0.1:11434'; - -interface OllamaModel { - name: string; - digest: string; - size: number; - modified_at: string; - details?: { - family: string; - parameter_size: string; - quantization_level: string; - }; - status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking'; - error?: string; - newDigest?: string; - progress?: { - current: number; - total: number; - status: string; - }; -} - -interface OllamaPullResponse { - status: string; - completed?: number; - total?: number; - digest?: string; -} - -const isOllamaPullResponse = (data: unknown): data is OllamaPullResponse => { - return ( - typeof data === 'object' && - data !== null && - 'status' in data && - typeof (data as OllamaPullResponse).status === 'string' - ); -}; +import { useLocalModelHealth } from '~/lib/hooks/useLocalModelHealth'; +import ErrorBoundary from './ErrorBoundary'; +import { ModelCardSkeleton } from './LoadingSkeleton'; +import SetupGuide from './SetupGuide'; +import StatusDashboard from './StatusDashboard'; +import ProviderCard from './ProviderCard'; +import ModelCard from './ModelCard'; +import { OLLAMA_API_URL } from './types'; +import type { OllamaModel, LMStudioModel } from './types'; +import { Cpu, Server, BookOpen, Activity, PackageOpen, Monitor, Loader2, RotateCw, ExternalLink } from 'lucide-react'; + +// Type definitions +type ViewMode = 'dashboard' | 'guide' | 'status'; export default function LocalProvidersTab() { const { providers, updateProviderSettings } = useSettings(); - const [filteredProviders, setFilteredProviders] = useState([]); - const [categoryEnabled, setCategoryEnabled] = useState(false); + const [viewMode, setViewMode] = useState('dashboard'); + const [editingProvider, setEditingProvider] = useState(null); const [ollamaModels, setOllamaModels] = useState([]); + const [lmStudioModels, setLMStudioModels] = useState([]); const [isLoadingModels, setIsLoadingModels] = useState(false); - const [editingProvider, setEditingProvider] = useState(null); + const [isLoadingLMStudioModels, setIsLoadingLMStudioModels] = useState(false); const { toast } = useToast(); + const { startMonitoring, stopMonitoring } = useLocalModelHealth(); - // Effect to filter and sort providers - useEffect(() => { - const newFilteredProviders = Object.entries(providers || {}) + // Memoized filtered providers to prevent unnecessary re-renders + const filteredProviders = useMemo(() => { + return Object.entries(providers || {}) .filter(([key]) => [...LOCAL_PROVIDERS, 'OpenAILike'].includes(key)) .map(([key, value]) => { const provider = value as IProviderConfig; const envKey = providerBaseUrlEnvKeys[key]?.baseUrlKey; const envUrl = envKey ? (import.meta.env[envKey] as string | undefined) : undefined; - // Set base URL if provided by environment - if (envUrl && !provider.settings.baseUrl) { - updateProviderSettings(key, { - ...provider.settings, - baseUrl: envUrl, - }); + // Set default base URLs for local providers + let defaultBaseUrl = provider.settings.baseUrl || envUrl; + + if (!defaultBaseUrl) { + if (key === 'Ollama') { + defaultBaseUrl = 'http://127.0.0.1:11434'; + } else if (key === 'LMStudio') { + defaultBaseUrl = 'http://127.0.0.1:1234'; + } } return { name: key, settings: { ...provider.settings, - baseUrl: provider.settings.baseUrl || envUrl, + baseUrl: defaultBaseUrl, }, staticModels: provider.staticModels || [], getDynamicModels: provider.getDynamicModels, @@ -109,36 +65,32 @@ export default function LocalProvidersTab() { labelForGetApiKey: provider.labelForGetApiKey, icon: provider.icon, } as IProviderConfig; + }) + .sort((a, b) => { + // Custom sort: Ollama first, then LMStudio, then OpenAILike + const order = { Ollama: 0, LMStudio: 1, OpenAILike: 2 }; + return (order[a.name as keyof typeof order] || 3) - (order[b.name as keyof typeof order] || 3); }); + }, [providers]); - // Custom sort function to ensure LMStudio appears before OpenAILike - const sorted = newFilteredProviders.sort((a, b) => { - if (a.name === 'LMStudio') { - return -1; - } - - if (b.name === 'LMStudio') { - return 1; - } - - if (a.name === 'OpenAILike') { - return 1; - } + const categoryEnabled = useMemo(() => { + return filteredProviders.length > 0 && filteredProviders.every((p) => p.settings.enabled); + }, [filteredProviders]); - if (b.name === 'OpenAILike') { - return -1; + // Start/stop health monitoring for enabled providers + useEffect(() => { + filteredProviders.forEach((provider) => { + const baseUrl = provider.settings.baseUrl; + + if (provider.settings.enabled && baseUrl) { + console.log(`[LocalProvidersTab] Starting monitoring for ${provider.name} at ${baseUrl}`); + startMonitoring(provider.name as 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl); + } else if (!provider.settings.enabled && baseUrl) { + console.log(`[LocalProvidersTab] Stopping monitoring for ${provider.name} at ${baseUrl}`); + stopMonitoring(provider.name as 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl); } - - return a.name.localeCompare(b.name); }); - setFilteredProviders(sorted); - }, [providers, updateProviderSettings]); - - // Add effect to update category toggle state based on provider states - useEffect(() => { - const newCategoryState = filteredProviders.every((p) => p.settings.enabled); - setCategoryEnabled(newCategoryState); - }, [filteredProviders]); + }, [filteredProviders, startMonitoring, stopMonitoring]); // Fetch Ollama models when enabled useEffect(() => { @@ -149,28 +101,99 @@ export default function LocalProvidersTab() { } }, [filteredProviders]); + // Fetch LM Studio models when enabled + useEffect(() => { + const lmStudioProvider = filteredProviders.find((p) => p.name === 'LMStudio'); + + if (lmStudioProvider?.settings.enabled && lmStudioProvider.settings.baseUrl) { + fetchLMStudioModels(lmStudioProvider.settings.baseUrl); + } + }, [filteredProviders]); + const fetchOllamaModels = async () => { try { setIsLoadingModels(true); - const response = await fetch('http://127.0.0.1:11434/api/tags'); - const data = (await response.json()) as { models: OllamaModel[] }; + const response = await fetch(`${OLLAMA_API_URL}/api/tags`); + if (!response.ok) { + throw new Error('Failed to fetch models'); + } + + const data = (await response.json()) as { models: OllamaModel[] }; setOllamaModels( data.models.map((model) => ({ ...model, status: 'idle' as const, })), ); - } catch (error) { - console.error('Error fetching Ollama models:', error); + } catch { + console.error('Error fetching Ollama models'); } finally { setIsLoadingModels(false); } }; - const updateOllamaModel = async (modelName: string): Promise => { + const fetchLMStudioModels = async (baseUrl: string) => { + try { + setIsLoadingLMStudioModels(true); + + const response = await fetch(`${baseUrl}/v1/models`); + + if (!response.ok) { + throw new Error('Failed to fetch LM Studio models'); + } + + const data = (await response.json()) as { data: LMStudioModel[] }; + setLMStudioModels(data.data || []); + } catch { + console.error('Error fetching LM Studio models'); + setLMStudioModels([]); + } finally { + setIsLoadingLMStudioModels(false); + } + }; + + const handleToggleCategory = useCallback( + async (enabled: boolean) => { + filteredProviders.forEach((provider) => { + updateProviderSettings(provider.name, { ...provider.settings, enabled }); + }); + toast(enabled ? 'All local providers enabled' : 'All local providers disabled'); + }, + [filteredProviders, updateProviderSettings, toast], + ); + + const handleToggleProvider = useCallback( + (provider: IProviderConfig, enabled: boolean) => { + updateProviderSettings(provider.name, { + ...provider.settings, + enabled, + }); + + logStore.logProvider(`Provider ${provider.name} ${enabled ? 'enabled' : 'disabled'}`, { + provider: provider.name, + }); + toast(`${provider.name} ${enabled ? 'enabled' : 'disabled'}`); + }, + [updateProviderSettings, toast], + ); + + const handleUpdateBaseUrl = useCallback( + (provider: IProviderConfig, newBaseUrl: string) => { + updateProviderSettings(provider.name, { + ...provider.settings, + baseUrl: newBaseUrl, + }); + toast(`${provider.name} base URL updated`); + }, + [updateProviderSettings, toast], + ); + + const handleUpdateOllamaModel = async (modelName: string) => { try { + setOllamaModels((prev) => prev.map((m) => (m.name === modelName ? { ...m, status: 'updating' } : m))); + const response = await fetch(`${OLLAMA_API_URL}/api/pull`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -181,6 +204,7 @@ export default function LocalProvidersTab() { throw new Error(`Failed to update ${modelName}`); } + // Handle streaming response const reader = response.body?.getReader(); if (!reader) { @@ -198,93 +222,52 @@ export default function LocalProvidersTab() { const lines = text.split('\n').filter(Boolean); for (const line of lines) { - const rawData = JSON.parse(line); - - if (!isOllamaPullResponse(rawData)) { - console.error('Invalid response format:', rawData); - continue; + try { + const data = JSON.parse(line); + + if (data.status && data.completed && data.total) { + setOllamaModels((current) => + current.map((m) => + m.name === modelName + ? { + ...m, + progress: { + current: data.completed, + total: data.total, + status: data.status, + }, + } + : m, + ), + ); + } + } catch { + // Ignore parsing errors } - - setOllamaModels((current) => - current.map((m) => - m.name === modelName - ? { - ...m, - progress: { - current: rawData.completed || 0, - total: rawData.total || 0, - status: rawData.status, - }, - newDigest: rawData.digest, - } - : m, - ), - ); } } - const updatedResponse = await fetch('http://127.0.0.1:11434/api/tags'); - const updatedData = (await updatedResponse.json()) as { models: OllamaModel[] }; - const updatedModel = updatedData.models.find((m) => m.name === modelName); - - return updatedModel !== undefined; - } catch (error) { - console.error(`Error updating ${modelName}:`, error); - return false; - } - }; - - const handleToggleCategory = useCallback( - async (enabled: boolean) => { - filteredProviders.forEach((provider) => { - updateProviderSettings(provider.name, { ...provider.settings, enabled }); - }); - toast(enabled ? 'All local providers enabled' : 'All local providers disabled'); - }, - [filteredProviders, updateProviderSettings], - ); - - const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => { - updateProviderSettings(provider.name, { - ...provider.settings, - enabled, - }); - - if (enabled) { - logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name }); - toast(`${provider.name} enabled`); - } else { - logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name }); - toast(`${provider.name} disabled`); + setOllamaModels((prev) => + prev.map((m) => (m.name === modelName ? { ...m, status: 'updated', progress: undefined } : m)), + ); + toast(`Successfully updated ${modelName}`); + } catch { + setOllamaModels((prev) => + prev.map((m) => (m.name === modelName ? { ...m, status: 'error', progress: undefined } : m)), + ); + toast(`Failed to update ${modelName}`, { type: 'error' }); } }; - const handleUpdateBaseUrl = (provider: IProviderConfig, newBaseUrl: string) => { - updateProviderSettings(provider.name, { - ...provider.settings, - baseUrl: newBaseUrl, - }); - toast(`${provider.name} base URL updated`); - setEditingProvider(null); - }; - - const handleUpdateOllamaModel = async (modelName: string) => { - const updateSuccess = await updateOllamaModel(modelName); - - if (updateSuccess) { - toast(`Updated ${modelName}`); - } else { - toast(`Failed to update ${modelName}`); + const handleDeleteOllamaModel = async (modelName: string) => { + if (!window.confirm(`Are you sure you want to delete ${modelName}?`)) { + return; } - }; - const handleDeleteOllamaModel = async (modelName: string) => { try { const response = await fetch(`${OLLAMA_API_URL}/api/delete`, { method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: modelName }), }); @@ -294,484 +277,280 @@ export default function LocalProvidersTab() { setOllamaModels((current) => current.filter((m) => m.name !== modelName)); toast(`Deleted ${modelName}`); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; - console.error(`Error deleting ${modelName}:`, errorMessage); - toast(`Failed to delete ${modelName}`); + } catch { + toast(`Failed to delete ${modelName}`, { type: 'error' }); } }; - // Update model details display - const ModelDetails = ({ model }: { model: OllamaModel }) => ( -
-
-
- {model.digest.substring(0, 7)} -
- {model.details && ( - <> -
-
- {model.details.parameter_size} -
-
-
- {model.details.quantization_level} -
- - )} -
- ); + // Render different views based on viewMode + if (viewMode === 'guide') { + return ( + + setViewMode('dashboard')} /> + + ); + } - // Update model actions to not use Tooltip - const ModelActions = ({ - model, - onUpdate, - onDelete, - }: { - model: OllamaModel; - onUpdate: () => void; - onDelete: () => void; - }) => ( -
- - {model.status === 'updating' ? ( -
-
- Updating... -
- ) : ( -
- )} - - -
- -
- ); + if (viewMode === 'status') { + return ( + + setViewMode('dashboard')} /> + + ); + } return ( -
- - {/* Header section */} -
-
- - - + +
+ {/* Header */} +
+
+
+ +
-
-

Local AI Models

-
-

Configure and manage your local AI providers

+

Local AI Providers

+

Configure and manage your local AI models

- -
- Enable All - +
+
+ Enable All + +
+
+ + +
- {/* Ollama Section */} - {filteredProviders - .filter((provider) => provider.name === 'Ollama') - .map((provider) => ( - - {/* Provider Header */} -
-
- - {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, { - className: 'w-7 h-7', - 'aria-label': `${provider.name} icon`, - })} - -
-
-

{provider.name}

- Local -
-

- {PROVIDER_DESCRIPTIONS[provider.name as ProviderName]} -

-
-
- handleToggleProvider(provider, checked)} - aria-label={`Toggle ${provider.name} provider`} - /> -
- - {/* URL Configuration Section */} - - {provider.settings.enabled && ( - -
- - {editingProvider === provider.name ? ( - { - if (e.key === 'Enter') { - handleUpdateBaseUrl(provider, e.currentTarget.value); - } else if (e.key === 'Escape') { - setEditingProvider(null); - } - }} - onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)} - autoFocus - /> - ) : ( -
setEditingProvider(provider.name)} - className={classNames( - 'w-full px-3 py-2 rounded-lg text-sm cursor-pointer', - 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor', - 'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4', - 'transition-all duration-200', - )} - > -
-
- {provider.settings.baseUrl || OLLAMA_API_URL} -
-
- )} -
- - )} - + {/* Provider Cards */} +
+ {filteredProviders.map((provider) => ( +
+ handleToggleProvider(provider, enabled)} + onUpdateBaseUrl={(url) => handleUpdateBaseUrl(provider, url)} + isEditing={editingProvider === provider.name} + onStartEditing={() => setEditingProvider(provider.name)} + onStopEditing={() => setEditingProvider(null)} + /> {/* Ollama Models Section */} - {provider.settings.enabled && ( - -
-
-
-

Installed Models

-
- {isLoadingModels ? ( + {provider.name === 'Ollama' && provider.settings.enabled && ( + + +
-
- Loading models... + +

Installed Models

- ) : ( - - {ollamaModels.length} models available - - )} -
- -
+ +
+ + {isLoadingModels ? ( -
+
{Array.from({ length: 3 }).map((_, i) => ( -
+ ))}
) : ollamaModels.length === 0 ? ( -
-
-

No models installed yet

-

- Browse models at{' '} +

+ +

No Models Installed

+

+ Visit{' '} ollama.com/library -

+ {' '} - and copy model names to install + to browse available models

+
) : ( - ollamaModels.map((model) => ( - -
-
-
-
{model.name}
- -
- -
- handleUpdateOllamaModel(model.name)} - onDelete={() => { - if (window.confirm(`Are you sure you want to delete ${model.name}?`)) { - handleDeleteOllamaModel(model.name); - } - }} - /> -
- {model.progress && ( -
- -
- {model.progress.status} - {Math.round((model.progress.current / model.progress.total) * 100)}% -
-
- )} -
- )) +
+ {ollamaModels.map((model) => ( + handleUpdateOllamaModel(model.name)} + onDelete={() => handleDeleteOllamaModel(model.name)} + /> + ))} +
)} -
- - {/* Model Installation Section */} - - + + )} - - ))} - {/* Other Providers Section */} -
-

Other Local Providers

-
- {filteredProviders - .filter((provider) => provider.name !== 'Ollama') - .map((provider, index) => ( - - {/* Provider Header */} -
-
- + +
+
+ +

Available Models

+
+ +
+
+ + {isLoadingLMStudioModels ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : lmStudioModels.length === 0 ? ( +
+ +

No Models Available

+

+ Make sure LM Studio is running with the local server started and CORS enabled.

+
-
- handleToggleProvider(provider, checked)} - aria-label={`Toggle ${provider.name} provider`} - /> -
- - {/* URL Configuration Section */} - - {provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && ( - -
- - {editingProvider === provider.name ? ( - { - if (e.key === 'Enter') { - handleUpdateBaseUrl(provider, e.currentTarget.value); - } else if (e.key === 'Escape') { - setEditingProvider(null); - } - }} - onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)} - autoFocus - /> - ) : ( -
setEditingProvider(provider.name)} - className={classNames( - 'w-full px-3 py-2 rounded-lg text-sm cursor-pointer', - 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor', - 'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4', - 'transition-all duration-200', - )} - > -
-
- {provider.settings.baseUrl || 'Click to set base URL'} + ) : ( +
+ {lmStudioModels.map((model) => ( + + +
+
+

+ {model.id} +

+ + Available + +
+
+
+ + {model.object} +
+
+ + Owned by: {model.owned_by} +
+ {model.created && ( +
+ + Created: {new Date(model.created * 1000).toLocaleDateString()} +
+ )} +
-
- )} -
- + + + ))} +
)} - - - ))} -
+ + + )} +
+ ))}
- -
- ); -} -// Helper component for model status badge -function ModelStatusBadge({ status }: { status?: string }) { - if (!status || status === 'idle') { - return null; - } - - const statusConfig = { - updating: { bg: 'bg-yellow-500/10', text: 'text-yellow-500', label: 'Updating' }, - updated: { bg: 'bg-green-500/10', text: 'text-green-500', label: 'Updated' }, - error: { bg: 'bg-red-500/10', text: 'text-red-500', label: 'Error' }, - }; - - const config = statusConfig[status as keyof typeof statusConfig]; - - if (!config) { - return null; - } - - return ( - - {config.label} - + {filteredProviders.length === 0 && ( + + + +

No Local Providers Available

+

+ Local providers will appear here when they're configured in the system. +

+
+
+ )} +
+ ); } diff --git a/app/components/@settings/tabs/providers/local/ModelCard.tsx b/app/components/@settings/tabs/providers/local/ModelCard.tsx new file mode 100644 index 0000000000..87567c564d --- /dev/null +++ b/app/components/@settings/tabs/providers/local/ModelCard.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { Card, CardContent } from '~/components/ui/Card'; +import { Progress } from '~/components/ui/Progress'; +import { RotateCw, Trash2, Code, Database, Package, Loader2 } from 'lucide-react'; +import { classNames } from '~/utils/classNames'; +import type { OllamaModel } from './types'; + +// Model Card Component +interface ModelCardProps { + model: OllamaModel; + onUpdate: () => void; + onDelete: () => void; +} + +function ModelCard({ model, onUpdate, onDelete }: ModelCardProps) { + return ( + + +
+
+
+

{model.name}

+ {model.status && model.status !== 'idle' && ( + + {model.status === 'updating' && 'Updating'} + {model.status === 'updated' && 'Updated'} + {model.status === 'error' && 'Error'} + + )} +
+
+
+ + {model.digest.substring(0, 8)} +
+ {model.details && ( + <> +
+ + {model.details.parameter_size} +
+
+ + {model.details.quantization_level} +
+ + )} +
+
+
+ + +
+
+ {model.progress && ( +
+
+ {model.progress.status} + {Math.round((model.progress.current / model.progress.total) * 100)}% +
+ +
+ )} +
+
+ ); +} + +export default ModelCard; diff --git a/app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx b/app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx deleted file mode 100644 index 9568076f24..0000000000 --- a/app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx +++ /dev/null @@ -1,603 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { motion } from 'framer-motion'; -import { classNames } from '~/utils/classNames'; -import { Progress } from '~/components/ui/Progress'; -import { useToast } from '~/components/ui/use-toast'; -import { useSettings } from '~/lib/hooks/useSettings'; - -interface OllamaModelInstallerProps { - onModelInstalled: () => void; -} - -interface InstallProgress { - status: string; - progress: number; - downloadedSize?: string; - totalSize?: string; - speed?: string; -} - -interface ModelInfo { - name: string; - desc: string; - size: string; - tags: string[]; - installedVersion?: string; - latestVersion?: string; - needsUpdate?: boolean; - status?: 'idle' | 'installing' | 'updating' | 'updated' | 'error'; - details?: { - family: string; - parameter_size: string; - quantization_level: string; - }; -} - -const POPULAR_MODELS: ModelInfo[] = [ - { - name: 'deepseek-coder:6.7b', - desc: "DeepSeek's code generation model", - size: '4.1GB', - tags: ['coding', 'popular'], - }, - { - name: 'llama2:7b', - desc: "Meta's Llama 2 (7B parameters)", - size: '3.8GB', - tags: ['general', 'popular'], - }, - { - name: 'mistral:7b', - desc: "Mistral's 7B model", - size: '4.1GB', - tags: ['general', 'popular'], - }, - { - name: 'gemma:7b', - desc: "Google's Gemma model", - size: '4.0GB', - tags: ['general', 'new'], - }, - { - name: 'codellama:7b', - desc: "Meta's Code Llama model", - size: '4.1GB', - tags: ['coding', 'popular'], - }, - { - name: 'neural-chat:7b', - desc: "Intel's Neural Chat model", - size: '4.1GB', - tags: ['chat', 'popular'], - }, - { - name: 'phi:latest', - desc: "Microsoft's Phi-2 model", - size: '2.7GB', - tags: ['small', 'fast'], - }, - { - name: 'qwen:7b', - desc: "Alibaba's Qwen model", - size: '4.1GB', - tags: ['general'], - }, - { - name: 'solar:10.7b', - desc: "Upstage's Solar model", - size: '6.1GB', - tags: ['large', 'powerful'], - }, - { - name: 'openchat:7b', - desc: 'Open-source chat model', - size: '4.1GB', - tags: ['chat', 'popular'], - }, - { - name: 'dolphin-phi:2.7b', - desc: 'Lightweight chat model', - size: '1.6GB', - tags: ['small', 'fast'], - }, - { - name: 'stable-code:3b', - desc: 'Lightweight coding model', - size: '1.8GB', - tags: ['coding', 'small'], - }, -]; - -function formatBytes(bytes: number): string { - if (bytes === 0) { - return '0 B'; - } - - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; -} - -function formatSpeed(bytesPerSecond: number): string { - return `${formatBytes(bytesPerSecond)}/s`; -} - -// Add Ollama Icon SVG component -function OllamaIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -export default function OllamaModelInstaller({ onModelInstalled }: OllamaModelInstallerProps) { - const [modelString, setModelString] = useState(''); - const [searchQuery, setSearchQuery] = useState(''); - const [isInstalling, setIsInstalling] = useState(false); - const [isChecking, setIsChecking] = useState(false); - const [installProgress, setInstallProgress] = useState(null); - const [selectedTags, setSelectedTags] = useState([]); - const [models, setModels] = useState(POPULAR_MODELS); - const { toast } = useToast(); - const { providers } = useSettings(); - - // Get base URL from provider settings - const baseUrl = providers?.Ollama?.settings?.baseUrl || 'http://127.0.0.1:11434'; - - // Function to check installed models and their versions - const checkInstalledModels = async () => { - try { - const response = await fetch(`${baseUrl}/api/tags`, { - method: 'GET', - }); - - if (!response.ok) { - throw new Error('Failed to fetch installed models'); - } - - const data = (await response.json()) as { models: Array<{ name: string; digest: string; latest: string }> }; - const installedModels = data.models || []; - - // Update models with installed versions - setModels((prevModels) => - prevModels.map((model) => { - const installed = installedModels.find((m) => m.name.toLowerCase() === model.name.toLowerCase()); - - if (installed) { - return { - ...model, - installedVersion: installed.digest.substring(0, 8), - needsUpdate: installed.digest !== installed.latest, - latestVersion: installed.latest?.substring(0, 8), - }; - } - - return model; - }), - ); - } catch (error) { - console.error('Error checking installed models:', error); - } - }; - - // Check installed models on mount and after installation - useEffect(() => { - checkInstalledModels(); - }, [baseUrl]); - - const handleCheckUpdates = async () => { - setIsChecking(true); - - try { - await checkInstalledModels(); - toast('Model versions checked'); - } catch (err) { - console.error('Failed to check model versions:', err); - toast('Failed to check model versions'); - } finally { - setIsChecking(false); - } - }; - - const filteredModels = models.filter((model) => { - const matchesSearch = - searchQuery === '' || - model.name.toLowerCase().includes(searchQuery.toLowerCase()) || - model.desc.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesTags = selectedTags.length === 0 || selectedTags.some((tag) => model.tags.includes(tag)); - - return matchesSearch && matchesTags; - }); - - const handleInstallModel = async (modelToInstall: string) => { - if (!modelToInstall) { - return; - } - - try { - setIsInstalling(true); - setInstallProgress({ - status: 'Starting download...', - progress: 0, - downloadedSize: '0 B', - totalSize: 'Calculating...', - speed: '0 B/s', - }); - setModelString(''); - setSearchQuery(''); - - const response = await fetch(`${baseUrl}/api/pull`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ name: modelToInstall }), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const reader = response.body?.getReader(); - - if (!reader) { - throw new Error('Failed to get response reader'); - } - - let lastTime = Date.now(); - let lastBytes = 0; - - while (true) { - const { done, value } = await reader.read(); - - if (done) { - break; - } - - const text = new TextDecoder().decode(value); - const lines = text.split('\n').filter(Boolean); - - for (const line of lines) { - try { - const data = JSON.parse(line); - - if ('status' in data) { - const currentTime = Date.now(); - const timeDiff = (currentTime - lastTime) / 1000; // Convert to seconds - const bytesDiff = (data.completed || 0) - lastBytes; - const speed = bytesDiff / timeDiff; - - setInstallProgress({ - status: data.status, - progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0, - downloadedSize: formatBytes(data.completed || 0), - totalSize: data.total ? formatBytes(data.total) : 'Calculating...', - speed: formatSpeed(speed), - }); - - lastTime = currentTime; - lastBytes = data.completed || 0; - } - } catch (err) { - console.error('Error parsing progress:', err); - } - } - } - - toast('Successfully installed ' + modelToInstall + '. The model list will refresh automatically.'); - - // Ensure we call onModelInstalled after successful installation - setTimeout(() => { - onModelInstalled(); - }, 1000); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; - console.error(`Error installing ${modelToInstall}:`, errorMessage); - toast(`Failed to install ${modelToInstall}. ${errorMessage}`); - } finally { - setIsInstalling(false); - setInstallProgress(null); - } - }; - - const handleUpdateModel = async (modelToUpdate: string) => { - try { - setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'updating' } : m))); - - const response = await fetch(`${baseUrl}/api/pull`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ name: modelToUpdate }), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const reader = response.body?.getReader(); - - if (!reader) { - throw new Error('Failed to get response reader'); - } - - let lastTime = Date.now(); - let lastBytes = 0; - - while (true) { - const { done, value } = await reader.read(); - - if (done) { - break; - } - - const text = new TextDecoder().decode(value); - const lines = text.split('\n').filter(Boolean); - - for (const line of lines) { - try { - const data = JSON.parse(line); - - if ('status' in data) { - const currentTime = Date.now(); - const timeDiff = (currentTime - lastTime) / 1000; - const bytesDiff = (data.completed || 0) - lastBytes; - const speed = bytesDiff / timeDiff; - - setInstallProgress({ - status: data.status, - progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0, - downloadedSize: formatBytes(data.completed || 0), - totalSize: data.total ? formatBytes(data.total) : 'Calculating...', - speed: formatSpeed(speed), - }); - - lastTime = currentTime; - lastBytes = data.completed || 0; - } - } catch (err) { - console.error('Error parsing progress:', err); - } - } - } - - toast('Successfully updated ' + modelToUpdate); - - // Refresh model list after update - await checkInstalledModels(); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; - console.error(`Error updating ${modelToUpdate}:`, errorMessage); - toast(`Failed to update ${modelToUpdate}. ${errorMessage}`); - setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'error' } : m))); - } finally { - setInstallProgress(null); - } - }; - - const allTags = Array.from(new Set(POPULAR_MODELS.flatMap((model) => model.tags))); - - return ( -
-
-
- -
-

Ollama Models

-

Install and manage your Ollama models

-
-
- - {isChecking ? ( -
- ) : ( -
- )} - Check Updates - -
- -
-
-
- { - const value = e.target.value; - setSearchQuery(value); - setModelString(value); - }} - disabled={isInstalling} - /> -

- Browse models at{' '} - - ollama.com/library -

- {' '} - and copy model names to install -

-
-
- handleInstallModel(modelString)} - disabled={!modelString || isInstalling} - className={classNames( - 'rounded-lg px-4 py-2', - 'bg-purple-500 text-white text-sm', - 'hover:bg-purple-600', - 'transition-all duration-200', - 'flex items-center gap-2', - { 'opacity-50 cursor-not-allowed': !modelString || isInstalling }, - )} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > - {isInstalling ? ( -
-
- Installing... -
- ) : ( -
- - Install Model -
- )} - -
- -
- {allTags.map((tag) => ( - - ))} -
- -
- {filteredModels.map((model) => ( - - -
-
-
-

{model.name}

-

{model.desc}

-
-
- {model.size} - {model.installedVersion && ( -
- v{model.installedVersion} - {model.needsUpdate && model.latestVersion && ( - v{model.latestVersion} available - )} -
- )} -
-
-
-
- {model.tags.map((tag) => ( - - {tag} - - ))} -
-
- {model.installedVersion ? ( - model.needsUpdate ? ( - handleUpdateModel(model.name)} - className={classNames( - 'px-2 py-0.5 rounded-lg text-xs', - 'bg-purple-500 text-white', - 'hover:bg-purple-600', - 'transition-all duration-200', - 'flex items-center gap-1', - )} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > -
- Update - - ) : ( - Up to date - ) - ) : ( - handleInstallModel(model.name)} - className={classNames( - 'px-2 py-0.5 rounded-lg text-xs', - 'bg-purple-500 text-white', - 'hover:bg-purple-600', - 'transition-all duration-200', - 'flex items-center gap-1', - )} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > -
- Install - - )} -
-
-
- - ))} -
- - {installProgress && ( - -
- {installProgress.status} -
- - {installProgress.downloadedSize} / {installProgress.totalSize} - - {installProgress.speed} - {Math.round(installProgress.progress)}% -
-
- -
- )} -
- ); -} diff --git a/app/components/@settings/tabs/providers/local/ProviderCard.tsx b/app/components/@settings/tabs/providers/local/ProviderCard.tsx new file mode 100644 index 0000000000..39e280a916 --- /dev/null +++ b/app/components/@settings/tabs/providers/local/ProviderCard.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { Switch } from '~/components/ui/Switch'; +import { Card, CardContent } from '~/components/ui/Card'; +import { Link, Server, Monitor, Globe } from 'lucide-react'; +import { classNames } from '~/utils/classNames'; +import type { IProviderConfig } from '~/types/model'; +import { PROVIDER_DESCRIPTIONS } from './types'; + +// Provider Card Component +interface ProviderCardProps { + provider: IProviderConfig; + onToggle: (enabled: boolean) => void; + onUpdateBaseUrl: (url: string) => void; + isEditing: boolean; + onStartEditing: () => void; + onStopEditing: () => void; +} + +function ProviderCard({ + provider, + onToggle, + onUpdateBaseUrl, + isEditing, + onStartEditing, + onStopEditing, +}: ProviderCardProps) { + const getIcon = (providerName: string) => { + switch (providerName) { + case 'Ollama': + return Server; + case 'LMStudio': + return Monitor; + case 'OpenAILike': + return Globe; + default: + return Server; + } + }; + + const Icon = getIcon(provider.name); + + return ( + + +
+
+
+ +
+
+
+

{provider.name}

+ Local +
+

+ {PROVIDER_DESCRIPTIONS[provider.name as keyof typeof PROVIDER_DESCRIPTIONS]} +

+ + {provider.settings.enabled && ( +
+ + {isEditing ? ( + { + if (e.key === 'Enter') { + onUpdateBaseUrl(e.currentTarget.value); + onStopEditing(); + } else if (e.key === 'Escape') { + onStopEditing(); + } + }} + onBlur={(e) => { + onUpdateBaseUrl(e.target.value); + onStopEditing(); + }} + autoFocus + /> + ) : ( + + )} +
+ )} +
+
+ +
+
+
+ ); +} + +export default ProviderCard; diff --git a/app/components/@settings/tabs/providers/local/SetupGuide.tsx b/app/components/@settings/tabs/providers/local/SetupGuide.tsx new file mode 100644 index 0000000000..a3e4406b6c --- /dev/null +++ b/app/components/@settings/tabs/providers/local/SetupGuide.tsx @@ -0,0 +1,671 @@ +import React from 'react'; +import { Button } from '~/components/ui/Button'; +import { Card, CardContent, CardHeader } from '~/components/ui/Card'; +import { + Cpu, + Server, + Settings, + ExternalLink, + Package, + Code, + Database, + CheckCircle, + AlertCircle, + Activity, + Cable, + ArrowLeft, + Download, + Shield, + Globe, + Terminal, + Monitor, + Wifi, +} from 'lucide-react'; + +// Setup Guide Component +function SetupGuide({ onBack }: { onBack: () => void }) { + return ( +
+ {/* Header with Back Button */} +
+ +
+

Local Provider Setup Guide

+

+ Complete setup instructions for running AI models locally +

+
+
+ + {/* Hardware Requirements Overview */} + + +
+
+ +
+
+

System Requirements

+

Recommended hardware for optimal performance

+
+
+
+
+
+ + CPU +
+

8+ cores, modern architecture

+
+
+
+ + RAM +
+

16GB minimum, 32GB+ recommended

+
+
+
+ + GPU +
+

NVIDIA RTX 30xx+ or AMD RX 6000+

+
+
+
+
+ + {/* Ollama Setup Section */} + + +
+
+ +
+
+

Ollama Setup

+

+ Most popular choice for running open-source models locally with desktop app +

+
+ + Recommended + +
+
+ + {/* Installation Options */} +
+

+ + 1. Choose Installation Method +

+ + {/* Desktop App - New and Recommended */} +
+
+ +
🆕 Desktop App (Recommended)
+
+

+ New user-friendly desktop application with built-in model management and web interface. +

+
+
+
+ + macOS +
+ +
+
+
+ + Windows +
+ +
+
+
+
+ + Built-in Web Interface +
+

+ Desktop app includes a web interface at{' '} + http://localhost:11434 +

+
+
+ + {/* CLI Installation */} +
+
+ +
Command Line (Advanced)
+
+
+
+
+ + Windows +
+
+ winget install Ollama.Ollama +
+
+
+
+ + macOS +
+
+ brew install ollama +
+
+
+
+ + Linux +
+
+ curl -fsSL https://ollama.com/install.sh | sh +
+
+
+
+
+ + {/* Latest Model Recommendations */} +
+

+ + 2. Download Latest Models +

+
+
+
+ + Code & Development +
+
+
# Latest Llama 3.2 for coding
+
ollama pull llama3.2:3b
+
ollama pull codellama:13b
+
ollama pull deepseek-coder-v2
+
ollama pull qwen2.5-coder:7b
+
+
+
+
+ + General Purpose & Chat +
+
+
# Latest general models
+
ollama pull llama3.2:3b
+
ollama pull mistral:7b
+
ollama pull phi3.5:3.8b
+
ollama pull qwen2.5:7b
+
+
+
+
+
+
+ + Performance Optimized +
+
    +
  • • Llama 3.2: 3B - Fastest, 8GB RAM
  • +
  • • Phi-3.5: 3.8B - Great balance
  • +
  • • Qwen2.5: 7B - Excellent quality
  • +
  • • Mistral: 7B - Popular choice
  • +
+
+
+
+ + Pro Tips +
+
    +
  • • Start with 3B-7B models for best performance
  • +
  • • Use quantized versions for faster loading
  • +
  • • Desktop app auto-manages model storage
  • +
  • • Web UI available at localhost:11434
  • +
+
+
+
+ + {/* Desktop App Features */} +
+

+ + 3. Desktop App Features +

+
+
+
+
🖥️ User Interface
+
    +
  • • Model library browser
  • +
  • • One-click model downloads
  • +
  • • Built-in chat interface
  • +
  • • System resource monitoring
  • +
+
+
+
🔧 Management Tools
+
    +
  • • Automatic updates
  • +
  • • Model size optimization
  • +
  • • GPU acceleration detection
  • +
  • • Cross-platform compatibility
  • +
+
+
+
+
+ + {/* Troubleshooting */} +
+

+ + 4. Troubleshooting & Commands +

+
+
+
Common Issues
+
    +
  • • Desktop app not starting: Restart system
  • +
  • • GPU not detected: Update drivers
  • +
  • • Port 11434 blocked: Change port in settings
  • +
  • • Models not loading: Check available disk space
  • +
  • • Slow performance: Use smaller models or enable GPU
  • +
+
+
+
Useful Commands
+
+
# Check installed models
+
ollama list
+
+
# Remove unused models
+
ollama rm model_name
+
+
# Check GPU usage
+
ollama ps
+
+
# View logs
+
ollama logs
+
+
+
+
+
+
+ + {/* LM Studio Setup Section */} + + +
+
+ +
+
+

LM Studio Setup

+

+ User-friendly GUI for running local models with excellent model management +

+
+
+
+ + {/* Installation */} +
+

+ + 1. Download & Install +

+
+

+ Download LM Studio for Windows, macOS, or Linux from the official website. +

+ +
+
+ + {/* Configuration */} +
+

+ + 2. Configure Local Server +

+
+
+
Start Local Server
+
    +
  1. Download a model from the "My Models" tab
  2. +
  3. Go to "Local Server" tab
  4. +
  5. Select your downloaded model
  6. +
  7. Set port to 1234 (default)
  8. +
  9. Click "Start Server"
  10. +
+
+ +
+
+ + Critical: Enable CORS +
+
+

+ To work with Bolt DIY, you MUST enable CORS in LM Studio: +

+
    +
  1. In Server Settings, check "Enable CORS"
  2. +
  3. Set Network Interface to "0.0.0.0" for external access
  4. +
  5. + Alternatively, use CLI:{' '} + lms server start --cors +
  6. +
+
+
+
+
+ + {/* Advantages */} +
+
+ + LM Studio Advantages +
+
    +
  • Built-in model downloader with search
  • +
  • Easy model switching and management
  • +
  • Built-in chat interface for testing
  • +
  • GGUF format support (most compatible)
  • +
  • Regular updates with new features
  • +
+
+
+
+ + {/* LocalAI Setup Section */} + + +
+
+ +
+
+

LocalAI Setup

+

+ Self-hosted OpenAI-compatible API server with extensive model support +

+
+
+
+ + {/* Installation */} +
+

+ + Installation Options +

+
+
+
Quick Install
+
+
# One-line install
+
curl https://localai.io/install.sh | sh
+
+
+
+
Docker (Recommended)
+
+
docker run -p 8080:8080
+
quay.io/go-skynet/local-ai:latest
+
+
+
+
+ + {/* Configuration */} +
+

+ + Configuration +

+
+

+ LocalAI supports many model formats and provides a full OpenAI-compatible API. +

+
+
# Example configuration
+
models:
+
- name: llama3.1
+
backend: llama
+
parameters:
+
model: llama3.1.gguf
+
+
+
+ + {/* Advantages */} +
+
+ + LocalAI Advantages +
+
    +
  • Full OpenAI API compatibility
  • +
  • Supports multiple model formats
  • +
  • Docker deployment option
  • +
  • Built-in model gallery
  • +
  • REST API for model management
  • +
+
+
+
+ + {/* Performance Optimization */} + + +
+
+ +
+
+

Performance Optimization

+

Tips to improve local AI performance

+
+
+
+ +
+
+

Hardware Optimizations

+
    +
  • + + Use NVIDIA GPU with CUDA for 5-10x speedup +
  • +
  • + + Increase RAM for larger context windows +
  • +
  • + + Use SSD storage for faster model loading +
  • +
  • + + Close other applications to free up RAM +
  • +
+
+
+

Software Optimizations

+
    +
  • + + Use smaller models for faster responses +
  • +
  • + + Enable quantization (4-bit, 8-bit models) +
  • +
  • + + Reduce context length for chat applications +
  • +
  • + + Use streaming responses for better UX +
  • +
+
+
+
+
+ + {/* Alternative Options */} + + +
+
+ +
+
+

Alternative Options

+

+ Other local AI solutions and cloud alternatives +

+
+
+
+ +
+
+

Other Local Solutions

+
+
+
+ + Jan.ai +
+

+ Modern interface with built-in model marketplace +

+
+
+
+ + Oobabooga +
+

+ Advanced text generation web UI with extensions +

+
+
+
+ + KoboldAI +
+

Focus on creative writing and storytelling

+
+
+
+
+

Cloud Alternatives

+
+
+
+ + OpenRouter +
+

Access to 100+ models through unified API

+
+
+
+ + Together AI +
+

Fast inference with open-source models

+
+
+
+ + Groq +
+

Ultra-fast LPU inference for Llama models

+
+
+
+
+
+
+
+ ); +} + +export default SetupGuide; diff --git a/app/components/@settings/tabs/providers/local/StatusDashboard.tsx b/app/components/@settings/tabs/providers/local/StatusDashboard.tsx new file mode 100644 index 0000000000..77a07065f0 --- /dev/null +++ b/app/components/@settings/tabs/providers/local/StatusDashboard.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Button } from '~/components/ui/Button'; +import { Card, CardContent } from '~/components/ui/Card'; +import { Cable, Server, ArrowLeft } from 'lucide-react'; +import { useLocalModelHealth } from '~/lib/hooks/useLocalModelHealth'; +import HealthStatusBadge from './HealthStatusBadge'; +import { PROVIDER_ICONS } from './types'; + +// Status Dashboard Component +function StatusDashboard({ onBack }: { onBack: () => void }) { + const { healthStatuses } = useLocalModelHealth(); + + return ( +
+ {/* Header with Back Button */} +
+ +
+

Provider Status

+

Monitor the health of your local AI providers

+
+
+ + {healthStatuses.length === 0 ? ( + + + +

No Endpoints Configured

+

+ Configure and enable local providers to see their endpoint status here. +

+
+
+ ) : ( +
+ {healthStatuses.map((status) => ( + + +
+
+
+ {React.createElement(PROVIDER_ICONS[status.provider as keyof typeof PROVIDER_ICONS] || Server, { + className: 'w-5 h-5 text-bolt-elements-textPrimary', + })} +
+
+

{status.provider}

+

{status.baseUrl}

+
+
+ +
+ +
+
+
Models
+
+ {status.availableModels?.length || 0} +
+
+
+
Version
+
+ {status.version || 'Unknown'} +
+
+
+
Last Check
+
+ {status.lastChecked ? new Date(status.lastChecked).toLocaleTimeString() : 'Never'} +
+
+
+
+
+ ))} +
+ )} +
+ ); +} + +export default StatusDashboard; diff --git a/app/components/@settings/tabs/providers/local/types.ts b/app/components/@settings/tabs/providers/local/types.ts new file mode 100644 index 0000000000..cf1955428e --- /dev/null +++ b/app/components/@settings/tabs/providers/local/types.ts @@ -0,0 +1,44 @@ +// Type definitions +export type ProviderName = 'Ollama' | 'LMStudio' | 'OpenAILike'; + +export interface OllamaModel { + name: string; + digest: string; + size: number; + modified_at: string; + details?: { + family: string; + parameter_size: string; + quantization_level: string; + }; + status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking'; + error?: string; + newDigest?: string; + progress?: { + current: number; + total: number; + status: string; + }; +} + +export interface LMStudioModel { + id: string; + object: 'model'; + owned_by: string; + created?: number; +} + +// Constants +export const OLLAMA_API_URL = 'http://127.0.0.1:11434'; + +export const PROVIDER_ICONS = { + Ollama: 'Server', + LMStudio: 'Monitor', + OpenAILike: 'Globe', +} as const; + +export const PROVIDER_DESCRIPTIONS = { + Ollama: 'Run open-source models locally on your machine', + LMStudio: 'Local model inference with LM Studio', + OpenAILike: 'Connect to OpenAI-compatible API endpoints', +} as const; diff --git a/app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx b/app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx deleted file mode 100644 index 401bd42fe9..0000000000 --- a/app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useState, useEffect } from 'react'; -import type { ServiceStatus } from './types'; -import { ProviderStatusCheckerFactory } from './provider-factory'; - -export default function ServiceStatusTab() { - const [serviceStatuses, setServiceStatuses] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const checkAllProviders = async () => { - try { - setLoading(true); - setError(null); - - const providers = ProviderStatusCheckerFactory.getProviderNames(); - const statuses: ServiceStatus[] = []; - - for (const provider of providers) { - try { - const checker = ProviderStatusCheckerFactory.getChecker(provider); - const result = await checker.checkStatus(); - - statuses.push({ - provider, - ...result, - lastChecked: new Date().toISOString(), - }); - } catch (err) { - console.error(`Error checking ${provider} status:`, err); - statuses.push({ - provider, - status: 'degraded', - message: 'Unable to check service status', - incidents: ['Error checking service status'], - lastChecked: new Date().toISOString(), - }); - } - } - - setServiceStatuses(statuses); - } catch (err) { - console.error('Error checking provider statuses:', err); - setError('Failed to check service statuses'); - } finally { - setLoading(false); - } - }; - - checkAllProviders(); - - // Set up periodic checks every 5 minutes - const interval = setInterval(checkAllProviders, 5 * 60 * 1000); - - return () => clearInterval(interval); - }, []); - - const getStatusColor = (status: ServiceStatus['status']) => { - switch (status) { - case 'operational': - return 'text-green-500 dark:text-green-400'; - case 'degraded': - return 'text-yellow-500 dark:text-yellow-400'; - case 'down': - return 'text-red-500 dark:text-red-400'; - default: - return 'text-gray-500 dark:text-gray-400'; - } - }; - - const getStatusIcon = (status: ServiceStatus['status']) => { - switch (status) { - case 'operational': - return 'i-ph:check-circle'; - case 'degraded': - return 'i-ph:warning'; - case 'down': - return 'i-ph:x-circle'; - default: - return 'i-ph:question'; - } - }; - - if (loading) { - return ( -
-
-
- ); - } - - if (error) { - return ( -
-
-

{error}

-
- ); - } - - return ( -
-
- {serviceStatuses.map((service) => ( -
-
-

{service.provider}

-
-
- {service.status} -
-
-

{service.message}

- {service.incidents && service.incidents.length > 0 && ( -
-

Recent Incidents:

-
    - {service.incidents.map((incident, index) => ( -
  • {incident}
  • - ))} -
-
- )} -
- Last checked: {new Date(service.lastChecked).toLocaleString()} -
-
- ))} -
-
- ); -} diff --git a/app/components/@settings/tabs/providers/service-status/base-provider.ts b/app/components/@settings/tabs/providers/service-status/base-provider.ts deleted file mode 100644 index dde4bd318b..0000000000 --- a/app/components/@settings/tabs/providers/service-status/base-provider.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { ProviderConfig, StatusCheckResult, ApiResponse } from './types'; - -export abstract class BaseProviderChecker { - protected config: ProviderConfig; - - constructor(config: ProviderConfig) { - this.config = config; - } - - protected async checkApiEndpoint( - url: string, - headers?: Record, - testModel?: string, - ): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - const startTime = performance.now(); - - // Add common headers - const processedHeaders = { - 'Content-Type': 'application/json', - ...headers, - }; - - const response = await fetch(url, { - method: 'GET', - headers: processedHeaders, - signal: controller.signal, - }); - - const endTime = performance.now(); - const responseTime = endTime - startTime; - - clearTimeout(timeoutId); - - const data = (await response.json()) as ApiResponse; - - if (!response.ok) { - let errorMessage = `API returned status: ${response.status}`; - - if (data.error?.message) { - errorMessage = data.error.message; - } else if (data.message) { - errorMessage = data.message; - } - - return { - ok: false, - status: response.status, - message: errorMessage, - responseTime, - }; - } - - // Different providers have different model list formats - let models: string[] = []; - - if (Array.isArray(data)) { - models = data.map((model: { id?: string; name?: string }) => model.id || model.name || ''); - } else if (data.data && Array.isArray(data.data)) { - models = data.data.map((model) => model.id || model.name || ''); - } else if (data.models && Array.isArray(data.models)) { - models = data.models.map((model) => model.id || model.name || ''); - } else if (data.model) { - models = [data.model]; - } - - if (!testModel || models.length > 0) { - return { - ok: true, - status: response.status, - responseTime, - message: 'API key is valid', - }; - } - - if (testModel && !models.includes(testModel)) { - return { - ok: true, - status: 'model_not_found', - message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`, - responseTime, - }; - } - - return { - ok: true, - status: response.status, - message: 'API key is valid', - responseTime, - }; - } catch (error) { - console.error(`Error checking API endpoint ${url}:`, error); - return { - ok: false, - status: error instanceof Error ? error.message : 'Unknown error', - message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed', - responseTime: 0, - }; - } - } - - protected async checkEndpoint(url: string): Promise<'reachable' | 'unreachable'> { - try { - const response = await fetch(url, { - mode: 'no-cors', - headers: { - Accept: 'text/html', - }, - }); - return response.type === 'opaque' ? 'reachable' : 'unreachable'; - } catch (error) { - console.error(`Error checking ${url}:`, error); - return 'unreachable'; - } - } - - abstract checkStatus(): Promise; -} diff --git a/app/components/@settings/tabs/providers/service-status/provider-factory.ts b/app/components/@settings/tabs/providers/service-status/provider-factory.ts deleted file mode 100644 index d9f627d595..0000000000 --- a/app/components/@settings/tabs/providers/service-status/provider-factory.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { ProviderName, ProviderConfig, StatusCheckResult } from './types'; -import { BaseProviderChecker } from './base-provider'; - -import { AmazonBedrockStatusChecker } from './providers/amazon-bedrock'; -import { CohereStatusChecker } from './providers/cohere'; -import { DeepseekStatusChecker } from './providers/deepseek'; -import { GoogleStatusChecker } from './providers/google'; -import { GroqStatusChecker } from './providers/groq'; -import { HuggingFaceStatusChecker } from './providers/huggingface'; -import { HyperbolicStatusChecker } from './providers/hyperbolic'; -import { MistralStatusChecker } from './providers/mistral'; -import { OpenRouterStatusChecker } from './providers/openrouter'; -import { PerplexityStatusChecker } from './providers/perplexity'; -import { TogetherStatusChecker } from './providers/together'; -import { XAIStatusChecker } from './providers/xai'; -import { MoonshotStatusChecker } from './providers/moonshot'; - -export class ProviderStatusCheckerFactory { - private static _providerConfigs: Record = { - AmazonBedrock: { - statusUrl: 'https://health.aws.amazon.com/health/status', - apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models', - headers: {}, - testModel: 'anthropic.claude-3-sonnet-20240229-v1:0', - }, - Cohere: { - statusUrl: 'https://status.cohere.com/', - apiUrl: 'https://api.cohere.ai/v1/models', - headers: {}, - testModel: 'command', - }, - Deepseek: { - statusUrl: 'https://status.deepseek.com/', - apiUrl: 'https://api.deepseek.com/v1/models', - headers: {}, - testModel: 'deepseek-chat', - }, - Google: { - statusUrl: 'https://status.cloud.google.com/', - apiUrl: 'https://generativelanguage.googleapis.com/v1/models', - headers: {}, - testModel: 'gemini-pro', - }, - Groq: { - statusUrl: 'https://groqstatus.com/', - apiUrl: 'https://api.groq.com/v1/models', - headers: {}, - testModel: 'mixtral-8x7b-32768', - }, - HuggingFace: { - statusUrl: 'https://status.huggingface.co/', - apiUrl: 'https://api-inference.huggingface.co/models', - headers: {}, - testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1', - }, - Hyperbolic: { - statusUrl: 'https://status.hyperbolic.ai/', - apiUrl: 'https://api.hyperbolic.ai/v1/models', - headers: {}, - testModel: 'hyperbolic-1', - }, - Mistral: { - statusUrl: 'https://status.mistral.ai/', - apiUrl: 'https://api.mistral.ai/v1/models', - headers: {}, - testModel: 'mistral-tiny', - }, - OpenRouter: { - statusUrl: 'https://status.openrouter.ai/', - apiUrl: 'https://openrouter.ai/api/v1/models', - headers: {}, - testModel: 'anthropic/claude-3-sonnet', - }, - Perplexity: { - statusUrl: 'https://status.perplexity.com/', - apiUrl: 'https://api.perplexity.ai/v1/models', - headers: {}, - testModel: 'pplx-7b-chat', - }, - Together: { - statusUrl: 'https://status.together.ai/', - apiUrl: 'https://api.together.xyz/v1/models', - headers: {}, - testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1', - }, - Moonshot: { - statusUrl: 'https://status.moonshot.ai/', - apiUrl: 'https://api.moonshot.ai/v1/models', - headers: {}, - testModel: 'moonshot-v1-8k', - }, - XAI: { - statusUrl: 'https://status.x.ai/', - apiUrl: 'https://api.x.ai/v1/models', - headers: {}, - testModel: 'grok-1', - }, - }; - - static getChecker(provider: ProviderName): BaseProviderChecker { - const config = this._providerConfigs[provider]; - - if (!config) { - throw new Error(`No configuration found for provider: ${provider}`); - } - - switch (provider) { - case 'AmazonBedrock': - return new AmazonBedrockStatusChecker(config); - case 'Cohere': - return new CohereStatusChecker(config); - case 'Deepseek': - return new DeepseekStatusChecker(config); - case 'Google': - return new GoogleStatusChecker(config); - case 'Groq': - return new GroqStatusChecker(config); - case 'HuggingFace': - return new HuggingFaceStatusChecker(config); - case 'Hyperbolic': - return new HyperbolicStatusChecker(config); - case 'Mistral': - return new MistralStatusChecker(config); - case 'OpenRouter': - return new OpenRouterStatusChecker(config); - case 'Perplexity': - return new PerplexityStatusChecker(config); - case 'Together': - return new TogetherStatusChecker(config); - case 'Moonshot': - return new MoonshotStatusChecker(config); - case 'XAI': - return new XAIStatusChecker(config); - default: - return new (class extends BaseProviderChecker { - async checkStatus(): Promise { - const endpointStatus = await this.checkEndpoint(this.config.statusUrl); - const apiStatus = await this.checkEndpoint(this.config.apiUrl); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - })(config); - } - } - - static getProviderNames(): ProviderName[] { - return Object.keys(this._providerConfigs) as ProviderName[]; - } - - static getProviderConfig(provider: ProviderName): ProviderConfig { - const config = this._providerConfigs[provider]; - - if (!config) { - throw new Error(`Unknown provider: ${provider}`); - } - - return config; - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts b/app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts deleted file mode 100644 index dff9d9a1fb..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class AmazonBedrockStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - // Check AWS health status page - const statusPageResponse = await fetch('https://health.aws.amazon.com/health/status'); - const text = await statusPageResponse.text(); - - // Check for Bedrock and general AWS status - const hasBedrockIssues = - text.includes('Amazon Bedrock') && - (text.includes('Service is experiencing elevated error rates') || - text.includes('Service disruption') || - text.includes('Degraded Service')); - - const hasGeneralIssues = text.includes('Service disruption') || text.includes('Multiple services affected'); - - // Extract incidents - const incidents: string[] = []; - const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g); - - for (const match of incidentMatches) { - const [, date, title, impact] = match; - - if (title.includes('Bedrock') || title.includes('AWS')) { - incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`); - } - } - - let status: StatusCheckResult['status'] = 'operational'; - let message = 'All services operational'; - - if (hasBedrockIssues) { - status = 'degraded'; - message = 'Amazon Bedrock service issues reported'; - } else if (hasGeneralIssues) { - status = 'degraded'; - message = 'AWS experiencing general issues'; - } - - // If status page check fails, fallback to endpoint check - if (!statusPageResponse.ok) { - const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status'); - const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - - return { - status, - message, - incidents: incidents.slice(0, 5), - }; - } catch (error) { - console.error('Error checking Amazon Bedrock status:', error); - - // Fallback to basic endpoint check - const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status'); - const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/anthropic.ts b/app/components/@settings/tabs/providers/service-status/providers/anthropic.ts deleted file mode 100644 index dccbf66b39..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/anthropic.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class AnthropicStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - // Check status page - const statusPageResponse = await fetch('https://status.anthropic.com/'); - const text = await statusPageResponse.text(); - - // Check for specific Anthropic status indicators - const isOperational = text.includes('All Systems Operational'); - const hasDegradedPerformance = text.includes('Degraded Performance'); - const hasPartialOutage = text.includes('Partial Outage'); - const hasMajorOutage = text.includes('Major Outage'); - - // Extract incidents - const incidents: string[] = []; - const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s); - - if (incidentSection) { - const incidentLines = incidentSection[1] - .split('\n') - .map((line) => line.trim()) - .filter((line) => line && line.includes('202')); // Only get dated incidents - - incidents.push(...incidentLines.slice(0, 5)); - } - - let status: StatusCheckResult['status'] = 'operational'; - let message = 'All systems operational'; - - if (hasMajorOutage) { - status = 'down'; - message = 'Major service outage'; - } else if (hasPartialOutage) { - status = 'down'; - message = 'Partial service outage'; - } else if (hasDegradedPerformance) { - status = 'degraded'; - message = 'Service experiencing degraded performance'; - } else if (!isOperational) { - status = 'degraded'; - message = 'Service status unknown'; - } - - // If status page check fails, fallback to endpoint check - if (!statusPageResponse.ok) { - const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/'); - const apiEndpoint = 'https://api.anthropic.com/v1/messages'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - - return { - status, - message, - incidents, - }; - } catch (error) { - console.error('Error checking Anthropic status:', error); - - // Fallback to basic endpoint check - const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/'); - const apiEndpoint = 'https://api.anthropic.com/v1/messages'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/cohere.ts b/app/components/@settings/tabs/providers/service-status/providers/cohere.ts deleted file mode 100644 index 7707f7377d..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/cohere.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class CohereStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - // Check status page - const statusPageResponse = await fetch('https://status.cohere.com/'); - const text = await statusPageResponse.text(); - - // Check for specific Cohere status indicators - const isOperational = text.includes('All Systems Operational'); - const hasIncidents = text.includes('Active Incidents'); - const hasDegradation = text.includes('Degraded Performance'); - const hasOutage = text.includes('Service Outage'); - - // Extract incidents - const incidents: string[] = []; - const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s); - - if (incidentSection) { - const incidentLines = incidentSection[1] - .split('\n') - .map((line) => line.trim()) - .filter((line) => line && line.includes('202')); // Only get dated incidents - - incidents.push(...incidentLines.slice(0, 5)); - } - - // Check specific services - const services = { - api: { - operational: text.includes('API Service') && text.includes('Operational'), - degraded: text.includes('API Service') && text.includes('Degraded Performance'), - outage: text.includes('API Service') && text.includes('Service Outage'), - }, - generation: { - operational: text.includes('Generation Service') && text.includes('Operational'), - degraded: text.includes('Generation Service') && text.includes('Degraded Performance'), - outage: text.includes('Generation Service') && text.includes('Service Outage'), - }, - }; - - let status: StatusCheckResult['status'] = 'operational'; - let message = 'All systems operational'; - - if (services.api.outage || services.generation.outage || hasOutage) { - status = 'down'; - message = 'Service outage detected'; - } else if (services.api.degraded || services.generation.degraded || hasDegradation || hasIncidents) { - status = 'degraded'; - message = 'Service experiencing issues'; - } else if (!isOperational) { - status = 'degraded'; - message = 'Service status unknown'; - } - - // If status page check fails, fallback to endpoint check - if (!statusPageResponse.ok) { - const endpointStatus = await this.checkEndpoint('https://status.cohere.com/'); - const apiEndpoint = 'https://api.cohere.ai/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - - return { - status, - message, - incidents, - }; - } catch (error) { - console.error('Error checking Cohere status:', error); - - // Fallback to basic endpoint check - const endpointStatus = await this.checkEndpoint('https://status.cohere.com/'); - const apiEndpoint = 'https://api.cohere.ai/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/deepseek.ts b/app/components/@settings/tabs/providers/service-status/providers/deepseek.ts deleted file mode 100644 index 7aa88bac42..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/deepseek.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class DeepseekStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - /* - * Check status page - Note: Deepseek doesn't have a public status page yet - * so we'll check their API endpoint directly - */ - const apiEndpoint = 'https://api.deepseek.com/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - // Check their website as a secondary indicator - const websiteStatus = await this.checkEndpoint('https://deepseek.com'); - - let status: StatusCheckResult['status'] = 'operational'; - let message = 'All systems operational'; - - if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') { - status = apiStatus !== 'reachable' ? 'down' : 'degraded'; - message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues'; - } - - return { - status, - message, - incidents: [], // No public incident tracking available yet - }; - } catch (error) { - console.error('Error checking Deepseek status:', error); - - return { - status: 'degraded', - message: 'Unable to determine service status', - incidents: ['Note: Limited status information available'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/google.ts b/app/components/@settings/tabs/providers/service-status/providers/google.ts deleted file mode 100644 index 80b5ecf81c..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/google.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class GoogleStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - // Check status page - const statusPageResponse = await fetch('https://status.cloud.google.com/'); - const text = await statusPageResponse.text(); - - // Check for Vertex AI and general cloud status - const hasVertexAIIssues = - text.includes('Vertex AI') && - (text.includes('Incident') || - text.includes('Disruption') || - text.includes('Outage') || - text.includes('degraded')); - - const hasGeneralIssues = text.includes('Major Incidents') || text.includes('Service Disruption'); - - // Extract incidents - const incidents: string[] = []; - const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g); - - for (const match of incidentMatches) { - const [, date, title, impact] = match; - - if (title.includes('Vertex AI') || title.includes('Cloud')) { - incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`); - } - } - - let status: StatusCheckResult['status'] = 'operational'; - let message = 'All services operational'; - - if (hasVertexAIIssues) { - status = 'degraded'; - message = 'Vertex AI service issues reported'; - } else if (hasGeneralIssues) { - status = 'degraded'; - message = 'Google Cloud experiencing issues'; - } - - // If status page check fails, fallback to endpoint check - if (!statusPageResponse.ok) { - const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/'); - const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - - return { - status, - message, - incidents: incidents.slice(0, 5), - }; - } catch (error) { - console.error('Error checking Google status:', error); - - // Fallback to basic endpoint check - const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/'); - const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/groq.ts b/app/components/@settings/tabs/providers/service-status/providers/groq.ts deleted file mode 100644 index c465cedd81..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/groq.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class GroqStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - // Check status page - const statusPageResponse = await fetch('https://groqstatus.com/'); - const text = await statusPageResponse.text(); - - const isOperational = text.includes('All Systems Operational'); - const hasIncidents = text.includes('Active Incidents'); - const hasDegradation = text.includes('Degraded Performance'); - const hasOutage = text.includes('Service Outage'); - - // Extract incidents - const incidents: string[] = []; - const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Status:(.*?)(?=\n|$)/g); - - for (const match of incidentMatches) { - const [, date, title, status] = match; - incidents.push(`${date}: ${title.trim()} - ${status.trim()}`); - } - - let status: StatusCheckResult['status'] = 'operational'; - let message = 'All systems operational'; - - if (hasOutage) { - status = 'down'; - message = 'Service outage detected'; - } else if (hasDegradation || hasIncidents) { - status = 'degraded'; - message = 'Service experiencing issues'; - } else if (!isOperational) { - status = 'degraded'; - message = 'Service status unknown'; - } - - // If status page check fails, fallback to endpoint check - if (!statusPageResponse.ok) { - const endpointStatus = await this.checkEndpoint('https://groqstatus.com/'); - const apiEndpoint = 'https://api.groq.com/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - - return { - status, - message, - incidents: incidents.slice(0, 5), - }; - } catch (error) { - console.error('Error checking Groq status:', error); - - // Fallback to basic endpoint check - const endpointStatus = await this.checkEndpoint('https://groqstatus.com/'); - const apiEndpoint = 'https://api.groq.com/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/huggingface.ts b/app/components/@settings/tabs/providers/service-status/providers/huggingface.ts deleted file mode 100644 index 80dcfe848d..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/huggingface.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class HuggingFaceStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - // Check status page - const statusPageResponse = await fetch('https://status.huggingface.co/'); - const text = await statusPageResponse.text(); - - // Check for "All services are online" message - const allServicesOnline = text.includes('All services are online'); - - // Get last update time - const lastUpdateMatch = text.match(/Last updated on (.*?)(EST|PST|GMT)/); - const lastUpdate = lastUpdateMatch ? `${lastUpdateMatch[1]}${lastUpdateMatch[2]}` : ''; - - // Check individual services and their uptime percentages - const services = { - 'Huggingface Hub': { - operational: text.includes('Huggingface Hub') && text.includes('Operational'), - uptime: text.match(/Huggingface Hub[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1], - }, - 'Git Hosting and Serving': { - operational: text.includes('Git Hosting and Serving') && text.includes('Operational'), - uptime: text.match(/Git Hosting and Serving[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1], - }, - 'Inference API': { - operational: text.includes('Inference API') && text.includes('Operational'), - uptime: text.match(/Inference API[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1], - }, - 'HF Endpoints': { - operational: text.includes('HF Endpoints') && text.includes('Operational'), - uptime: text.match(/HF Endpoints[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1], - }, - Spaces: { - operational: text.includes('Spaces') && text.includes('Operational'), - uptime: text.match(/Spaces[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1], - }, - }; - - // Create service status messages with uptime - const serviceMessages = Object.entries(services).map(([name, info]) => { - if (info.uptime) { - return `${name}: ${info.uptime}% uptime`; - } - - return `${name}: ${info.operational ? 'Operational' : 'Issues detected'}`; - }); - - // Determine overall status - let status: StatusCheckResult['status'] = 'operational'; - let message = allServicesOnline - ? `All services are online (Last updated on ${lastUpdate})` - : 'Checking individual services'; - - // Only mark as degraded if we explicitly detect issues - const hasIssues = Object.values(services).some((service) => !service.operational); - - if (hasIssues) { - status = 'degraded'; - message = `Service issues detected (Last updated on ${lastUpdate})`; - } - - // If status page check fails, fallback to endpoint check - if (!statusPageResponse.ok) { - const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/'); - const apiEndpoint = 'https://api-inference.huggingface.co/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - - return { - status, - message, - incidents: serviceMessages, - }; - } catch (error) { - console.error('Error checking HuggingFace status:', error); - - // Fallback to basic endpoint check - const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/'); - const apiEndpoint = 'https://api-inference.huggingface.co/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts b/app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts deleted file mode 100644 index 6dca268fb7..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class HyperbolicStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - /* - * Check API endpoint directly since Hyperbolic is a newer provider - * and may not have a public status page yet - */ - const apiEndpoint = 'https://api.hyperbolic.ai/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - // Check their website as a secondary indicator - const websiteStatus = await this.checkEndpoint('https://hyperbolic.ai'); - - let status: StatusCheckResult['status'] = 'operational'; - let message = 'All systems operational'; - - if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') { - status = apiStatus !== 'reachable' ? 'down' : 'degraded'; - message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues'; - } - - return { - status, - message, - incidents: [], // No public incident tracking available yet - }; - } catch (error) { - console.error('Error checking Hyperbolic status:', error); - - return { - status: 'degraded', - message: 'Unable to determine service status', - incidents: ['Note: Limited status information available'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/mistral.ts b/app/components/@settings/tabs/providers/service-status/providers/mistral.ts deleted file mode 100644 index 5966682cff..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/mistral.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class MistralStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - // Check status page - const statusPageResponse = await fetch('https://status.mistral.ai/'); - const text = await statusPageResponse.text(); - - const isOperational = text.includes('All Systems Operational'); - const hasIncidents = text.includes('Active Incidents'); - const hasDegradation = text.includes('Degraded Performance'); - const hasOutage = text.includes('Service Outage'); - - // Extract incidents - const incidents: string[] = []; - const incidentSection = text.match(/Recent Events(.*?)(?=\n\n)/s); - - if (incidentSection) { - const incidentLines = incidentSection[1] - .split('\n') - .map((line) => line.trim()) - .filter((line) => line && !line.includes('No incidents')); - - incidents.push(...incidentLines.slice(0, 5)); - } - - let status: StatusCheckResult['status'] = 'operational'; - let message = 'All systems operational'; - - if (hasOutage) { - status = 'down'; - message = 'Service outage detected'; - } else if (hasDegradation || hasIncidents) { - status = 'degraded'; - message = 'Service experiencing issues'; - } else if (!isOperational) { - status = 'degraded'; - message = 'Service status unknown'; - } - - // If status page check fails, fallback to endpoint check - if (!statusPageResponse.ok) { - const endpointStatus = await this.checkEndpoint('https://status.mistral.ai/'); - const apiEndpoint = 'https://api.mistral.ai/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - - return { - status, - message, - incidents, - }; - } catch (error) { - console.error('Error checking Mistral status:', error); - - // Fallback to basic endpoint check - const endpointStatus = await this.checkEndpoint('https://status.mistral.ai/'); - const apiEndpoint = 'https://api.mistral.ai/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/moonshot.ts b/app/components/@settings/tabs/providers/service-status/providers/moonshot.ts deleted file mode 100644 index 718d755308..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/moonshot.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class MoonshotStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - // Check Moonshot API endpoint - const apiEndpoint = 'https://api.moonshot.ai/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - // Check their main website - const websiteStatus = await this.checkEndpoint('https://www.moonshot.ai'); - - let status: StatusCheckResult['status'] = 'operational'; - let message = 'All systems operational'; - - if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') { - status = apiStatus !== 'reachable' ? 'down' : 'degraded'; - message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues'; - } - - return { - status, - message, - incidents: [], // No public incident tracking available yet - }; - } catch (error) { - console.error('Error checking Moonshot status:', error); - - return { - status: 'degraded', - message: 'Unable to determine service status', - incidents: ['Note: Limited status information available'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/openai.ts b/app/components/@settings/tabs/providers/service-status/providers/openai.ts deleted file mode 100644 index 252c16ea1b..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/openai.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class OpenAIStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - // Check status page - const statusPageResponse = await fetch('https://status.openai.com/'); - const text = await statusPageResponse.text(); - - // Check individual services - const services = { - api: { - operational: text.includes('API ? Operational'), - degraded: text.includes('API ? Degraded Performance'), - outage: text.includes('API ? Major Outage') || text.includes('API ? Partial Outage'), - }, - chat: { - operational: text.includes('ChatGPT ? Operational'), - degraded: text.includes('ChatGPT ? Degraded Performance'), - outage: text.includes('ChatGPT ? Major Outage') || text.includes('ChatGPT ? Partial Outage'), - }, - }; - - // Extract recent incidents - const incidents: string[] = []; - const incidentMatches = text.match(/Past Incidents(.*?)(?=\w+ \d+, \d{4})/s); - - if (incidentMatches) { - const recentIncidents = incidentMatches[1] - .split('\n') - .map((line) => line.trim()) - .filter((line) => line && line.includes('202')); // Get only dated incidents - - incidents.push(...recentIncidents.slice(0, 5)); - } - - // Determine overall status - let status: StatusCheckResult['status'] = 'operational'; - const messages: string[] = []; - - if (services.api.outage || services.chat.outage) { - status = 'down'; - - if (services.api.outage) { - messages.push('API: Major Outage'); - } - - if (services.chat.outage) { - messages.push('ChatGPT: Major Outage'); - } - } else if (services.api.degraded || services.chat.degraded) { - status = 'degraded'; - - if (services.api.degraded) { - messages.push('API: Degraded Performance'); - } - - if (services.chat.degraded) { - messages.push('ChatGPT: Degraded Performance'); - } - } else if (services.api.operational) { - messages.push('API: Operational'); - } - - // If status page check fails, fallback to endpoint check - if (!statusPageResponse.ok) { - const endpointStatus = await this.checkEndpoint('https://status.openai.com/'); - const apiEndpoint = 'https://api.openai.com/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - - return { - status, - message: messages.join(', ') || 'Status unknown', - incidents, - }; - } catch (error) { - console.error('Error checking OpenAI status:', error); - - // Fallback to basic endpoint check - const endpointStatus = await this.checkEndpoint('https://status.openai.com/'); - const apiEndpoint = 'https://api.openai.com/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/openrouter.ts b/app/components/@settings/tabs/providers/service-status/providers/openrouter.ts deleted file mode 100644 index f05edb98a6..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/openrouter.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class OpenRouterStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - // Check status page - const statusPageResponse = await fetch('https://status.openrouter.ai/'); - const text = await statusPageResponse.text(); - - // Check for specific OpenRouter status indicators - const isOperational = text.includes('All Systems Operational'); - const hasIncidents = text.includes('Active Incidents'); - const hasDegradation = text.includes('Degraded Performance'); - const hasOutage = text.includes('Service Outage'); - - // Extract incidents - const incidents: string[] = []; - const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s); - - if (incidentSection) { - const incidentLines = incidentSection[1] - .split('\n') - .map((line) => line.trim()) - .filter((line) => line && line.includes('202')); // Only get dated incidents - - incidents.push(...incidentLines.slice(0, 5)); - } - - // Check specific services - const services = { - api: { - operational: text.includes('API Service') && text.includes('Operational'), - degraded: text.includes('API Service') && text.includes('Degraded Performance'), - outage: text.includes('API Service') && text.includes('Service Outage'), - }, - routing: { - operational: text.includes('Routing Service') && text.includes('Operational'), - degraded: text.includes('Routing Service') && text.includes('Degraded Performance'), - outage: text.includes('Routing Service') && text.includes('Service Outage'), - }, - }; - - let status: StatusCheckResult['status'] = 'operational'; - let message = 'All systems operational'; - - if (services.api.outage || services.routing.outage || hasOutage) { - status = 'down'; - message = 'Service outage detected'; - } else if (services.api.degraded || services.routing.degraded || hasDegradation || hasIncidents) { - status = 'degraded'; - message = 'Service experiencing issues'; - } else if (!isOperational) { - status = 'degraded'; - message = 'Service status unknown'; - } - - // If status page check fails, fallback to endpoint check - if (!statusPageResponse.ok) { - const endpointStatus = await this.checkEndpoint('https://status.openrouter.ai/'); - const apiEndpoint = 'https://openrouter.ai/api/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - - return { - status, - message, - incidents, - }; - } catch (error) { - console.error('Error checking OpenRouter status:', error); - - // Fallback to basic endpoint check - const endpointStatus = await this.checkEndpoint('https://status.openrouter.ai/'); - const apiEndpoint = 'https://openrouter.ai/api/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/perplexity.ts b/app/components/@settings/tabs/providers/service-status/providers/perplexity.ts deleted file mode 100644 index 31a8088e3c..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/perplexity.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class PerplexityStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - // Check status page - const statusPageResponse = await fetch('https://status.perplexity.ai/'); - const text = await statusPageResponse.text(); - - // Check for specific Perplexity status indicators - const isOperational = text.includes('All Systems Operational'); - const hasIncidents = text.includes('Active Incidents'); - const hasDegradation = text.includes('Degraded Performance'); - const hasOutage = text.includes('Service Outage'); - - // Extract incidents - const incidents: string[] = []; - const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s); - - if (incidentSection) { - const incidentLines = incidentSection[1] - .split('\n') - .map((line) => line.trim()) - .filter((line) => line && line.includes('202')); // Only get dated incidents - - incidents.push(...incidentLines.slice(0, 5)); - } - - // Check specific services - const services = { - api: { - operational: text.includes('API Service') && text.includes('Operational'), - degraded: text.includes('API Service') && text.includes('Degraded Performance'), - outage: text.includes('API Service') && text.includes('Service Outage'), - }, - inference: { - operational: text.includes('Inference Service') && text.includes('Operational'), - degraded: text.includes('Inference Service') && text.includes('Degraded Performance'), - outage: text.includes('Inference Service') && text.includes('Service Outage'), - }, - }; - - let status: StatusCheckResult['status'] = 'operational'; - let message = 'All systems operational'; - - if (services.api.outage || services.inference.outage || hasOutage) { - status = 'down'; - message = 'Service outage detected'; - } else if (services.api.degraded || services.inference.degraded || hasDegradation || hasIncidents) { - status = 'degraded'; - message = 'Service experiencing issues'; - } else if (!isOperational) { - status = 'degraded'; - message = 'Service status unknown'; - } - - // If status page check fails, fallback to endpoint check - if (!statusPageResponse.ok) { - const endpointStatus = await this.checkEndpoint('https://status.perplexity.ai/'); - const apiEndpoint = 'https://api.perplexity.ai/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - - return { - status, - message, - incidents, - }; - } catch (error) { - console.error('Error checking Perplexity status:', error); - - // Fallback to basic endpoint check - const endpointStatus = await this.checkEndpoint('https://status.perplexity.ai/'); - const apiEndpoint = 'https://api.perplexity.ai/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/together.ts b/app/components/@settings/tabs/providers/service-status/providers/together.ts deleted file mode 100644 index 77abce9810..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/together.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class TogetherStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - // Check status page - const statusPageResponse = await fetch('https://status.together.ai/'); - const text = await statusPageResponse.text(); - - // Check for specific Together status indicators - const isOperational = text.includes('All Systems Operational'); - const hasIncidents = text.includes('Active Incidents'); - const hasDegradation = text.includes('Degraded Performance'); - const hasOutage = text.includes('Service Outage'); - - // Extract incidents - const incidents: string[] = []; - const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s); - - if (incidentSection) { - const incidentLines = incidentSection[1] - .split('\n') - .map((line) => line.trim()) - .filter((line) => line && line.includes('202')); // Only get dated incidents - - incidents.push(...incidentLines.slice(0, 5)); - } - - // Check specific services - const services = { - api: { - operational: text.includes('API Service') && text.includes('Operational'), - degraded: text.includes('API Service') && text.includes('Degraded Performance'), - outage: text.includes('API Service') && text.includes('Service Outage'), - }, - inference: { - operational: text.includes('Inference Service') && text.includes('Operational'), - degraded: text.includes('Inference Service') && text.includes('Degraded Performance'), - outage: text.includes('Inference Service') && text.includes('Service Outage'), - }, - }; - - let status: StatusCheckResult['status'] = 'operational'; - let message = 'All systems operational'; - - if (services.api.outage || services.inference.outage || hasOutage) { - status = 'down'; - message = 'Service outage detected'; - } else if (services.api.degraded || services.inference.degraded || hasDegradation || hasIncidents) { - status = 'degraded'; - message = 'Service experiencing issues'; - } else if (!isOperational) { - status = 'degraded'; - message = 'Service status unknown'; - } - - // If status page check fails, fallback to endpoint check - if (!statusPageResponse.ok) { - const endpointStatus = await this.checkEndpoint('https://status.together.ai/'); - const apiEndpoint = 'https://api.together.ai/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - - return { - status, - message, - incidents, - }; - } catch (error) { - console.error('Error checking Together status:', error); - - // Fallback to basic endpoint check - const endpointStatus = await this.checkEndpoint('https://status.together.ai/'); - const apiEndpoint = 'https://api.together.ai/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/providers/xai.ts b/app/components/@settings/tabs/providers/service-status/providers/xai.ts deleted file mode 100644 index 7b98c6a382..0000000000 --- a/app/components/@settings/tabs/providers/service-status/providers/xai.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider'; -import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types'; - -export class XAIStatusChecker extends BaseProviderChecker { - async checkStatus(): Promise { - try { - /* - * Check API endpoint directly since XAI is a newer provider - * and may not have a public status page yet - */ - const apiEndpoint = 'https://api.xai.com/v1/models'; - const apiStatus = await this.checkEndpoint(apiEndpoint); - - // Check their website as a secondary indicator - const websiteStatus = await this.checkEndpoint('https://x.ai'); - - let status: StatusCheckResult['status'] = 'operational'; - let message = 'All systems operational'; - - if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') { - status = apiStatus !== 'reachable' ? 'down' : 'degraded'; - message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues'; - } - - return { - status, - message, - incidents: [], // No public incident tracking available yet - }; - } catch (error) { - console.error('Error checking XAI status:', error); - - return { - status: 'degraded', - message: 'Unable to determine service status', - incidents: ['Note: Limited status information available'], - }; - } - } -} diff --git a/app/components/@settings/tabs/providers/service-status/types.ts b/app/components/@settings/tabs/providers/service-status/types.ts deleted file mode 100644 index d09a865c46..0000000000 --- a/app/components/@settings/tabs/providers/service-status/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { IconType } from 'react-icons'; - -export type ProviderName = - | 'AmazonBedrock' - | 'Cohere' - | 'Deepseek' - | 'Google' - | 'Groq' - | 'HuggingFace' - | 'Hyperbolic' - | 'Mistral' - | 'Moonshot' - | 'OpenRouter' - | 'Perplexity' - | 'Together' - | 'XAI'; - -export type ServiceStatus = { - provider: ProviderName; - status: 'operational' | 'degraded' | 'down'; - lastChecked: string; - statusUrl?: string; - icon?: IconType; - message?: string; - responseTime?: number; - incidents?: string[]; -}; - -export interface ProviderConfig { - statusUrl: string; - apiUrl: string; - headers: Record; - testModel: string; -} - -export type ApiResponse = { - error?: { - message: string; - }; - message?: string; - model?: string; - models?: Array<{ - id?: string; - name?: string; - }>; - data?: Array<{ - id?: string; - name?: string; - }>; -}; - -export type StatusCheckResult = { - status: 'operational' | 'degraded' | 'down'; - message: string; - incidents: string[]; -}; diff --git a/app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx b/app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx deleted file mode 100644 index b61ed04359..0000000000 --- a/app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx +++ /dev/null @@ -1,886 +0,0 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { motion } from 'framer-motion'; -import { classNames } from '~/utils/classNames'; -import { TbActivityHeartbeat } from 'react-icons/tb'; -import { BsCheckCircleFill, BsXCircleFill, BsExclamationCircleFill } from 'react-icons/bs'; -import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si'; -import { BsRobot, BsCloud } from 'react-icons/bs'; -import { TbBrain } from 'react-icons/tb'; -import { BiChip, BiCodeBlock } from 'react-icons/bi'; -import { FaCloud, FaBrain } from 'react-icons/fa'; -import type { IconType } from 'react-icons'; -import { useSettings } from '~/lib/hooks/useSettings'; -import { useToast } from '~/components/ui/use-toast'; - -// Types -type ProviderName = - | 'AmazonBedrock' - | 'Anthropic' - | 'Cohere' - | 'Deepseek' - | 'Google' - | 'Groq' - | 'HuggingFace' - | 'Mistral' - | 'OpenAI' - | 'OpenRouter' - | 'Perplexity' - | 'Together' - | 'XAI'; - -type ServiceStatus = { - provider: ProviderName; - status: 'operational' | 'degraded' | 'down'; - lastChecked: string; - statusUrl?: string; - icon?: IconType; - message?: string; - responseTime?: number; - incidents?: string[]; -}; - -type ProviderConfig = { - statusUrl: string; - apiUrl: string; - headers: Record; - testModel: string; -}; - -// Types for API responses -type ApiResponse = { - error?: { - message: string; - }; - message?: string; - model?: string; - models?: Array<{ - id?: string; - name?: string; - }>; - data?: Array<{ - id?: string; - name?: string; - }>; -}; - -// Constants -const PROVIDER_STATUS_URLS: Record = { - OpenAI: { - statusUrl: 'https://status.openai.com/', - apiUrl: 'https://api.openai.com/v1/models', - headers: { - Authorization: 'Bearer $OPENAI_API_KEY', - }, - testModel: 'gpt-3.5-turbo', - }, - Anthropic: { - statusUrl: 'https://status.anthropic.com/', - apiUrl: 'https://api.anthropic.com/v1/messages', - headers: { - 'x-api-key': '$ANTHROPIC_API_KEY', - 'anthropic-version': '2024-02-29', - }, - testModel: 'claude-3-sonnet-20240229', - }, - Cohere: { - statusUrl: 'https://status.cohere.com/', - apiUrl: 'https://api.cohere.ai/v1/models', - headers: { - Authorization: 'Bearer $COHERE_API_KEY', - }, - testModel: 'command', - }, - Google: { - statusUrl: 'https://status.cloud.google.com/', - apiUrl: 'https://generativelanguage.googleapis.com/v1/models', - headers: { - 'x-goog-api-key': '$GOOGLE_API_KEY', - }, - testModel: 'gemini-pro', - }, - HuggingFace: { - statusUrl: 'https://status.huggingface.co/', - apiUrl: 'https://api-inference.huggingface.co/models', - headers: { - Authorization: 'Bearer $HUGGINGFACE_API_KEY', - }, - testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1', - }, - Mistral: { - statusUrl: 'https://status.mistral.ai/', - apiUrl: 'https://api.mistral.ai/v1/models', - headers: { - Authorization: 'Bearer $MISTRAL_API_KEY', - }, - testModel: 'mistral-tiny', - }, - Perplexity: { - statusUrl: 'https://status.perplexity.com/', - apiUrl: 'https://api.perplexity.ai/v1/models', - headers: { - Authorization: 'Bearer $PERPLEXITY_API_KEY', - }, - testModel: 'pplx-7b-chat', - }, - Together: { - statusUrl: 'https://status.together.ai/', - apiUrl: 'https://api.together.xyz/v1/models', - headers: { - Authorization: 'Bearer $TOGETHER_API_KEY', - }, - testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1', - }, - AmazonBedrock: { - statusUrl: 'https://health.aws.amazon.com/health/status', - apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models', - headers: { - Authorization: 'Bearer $AWS_BEDROCK_CONFIG', - }, - testModel: 'anthropic.claude-3-sonnet-20240229-v1:0', - }, - Groq: { - statusUrl: 'https://groqstatus.com/', - apiUrl: 'https://api.groq.com/v1/models', - headers: { - Authorization: 'Bearer $GROQ_API_KEY', - }, - testModel: 'mixtral-8x7b-32768', - }, - OpenRouter: { - statusUrl: 'https://status.openrouter.ai/', - apiUrl: 'https://openrouter.ai/api/v1/models', - headers: { - Authorization: 'Bearer $OPEN_ROUTER_API_KEY', - }, - testModel: 'anthropic/claude-3-sonnet', - }, - XAI: { - statusUrl: 'https://status.x.ai/', - apiUrl: 'https://api.x.ai/v1/models', - headers: { - Authorization: 'Bearer $XAI_API_KEY', - }, - testModel: 'grok-1', - }, - Deepseek: { - statusUrl: 'https://status.deepseek.com/', - apiUrl: 'https://api.deepseek.com/v1/models', - headers: { - Authorization: 'Bearer $DEEPSEEK_API_KEY', - }, - testModel: 'deepseek-chat', - }, -}; - -const PROVIDER_ICONS: Record = { - AmazonBedrock: SiAmazon, - Anthropic: FaBrain, - Cohere: BiChip, - Google: SiGoogle, - Groq: BsCloud, - HuggingFace: SiHuggingface, - Mistral: TbBrain, - OpenAI: SiOpenai, - OpenRouter: FaCloud, - Perplexity: SiPerplexity, - Together: BsCloud, - XAI: BsRobot, - Deepseek: BiCodeBlock, -}; - -const ServiceStatusTab = () => { - const [serviceStatuses, setServiceStatuses] = useState([]); - const [loading, setLoading] = useState(true); - const [lastRefresh, setLastRefresh] = useState(new Date()); - const [testApiKey, setTestApiKey] = useState(''); - const [testProvider, setTestProvider] = useState(''); - const [testingStatus, setTestingStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); - const settings = useSettings(); - const { success, error } = useToast(); - - // Function to get the API key for a provider from environment variables - const getApiKey = useCallback( - (provider: ProviderName): string | null => { - if (!settings.providers) { - return null; - } - - // Map provider names to environment variable names - const envKeyMap: Record = { - OpenAI: 'OPENAI_API_KEY', - Anthropic: 'ANTHROPIC_API_KEY', - Cohere: 'COHERE_API_KEY', - Google: 'GOOGLE_GENERATIVE_AI_API_KEY', - HuggingFace: 'HuggingFace_API_KEY', - Mistral: 'MISTRAL_API_KEY', - Perplexity: 'PERPLEXITY_API_KEY', - Together: 'TOGETHER_API_KEY', - AmazonBedrock: 'AWS_BEDROCK_CONFIG', - Groq: 'GROQ_API_KEY', - OpenRouter: 'OPEN_ROUTER_API_KEY', - XAI: 'XAI_API_KEY', - Deepseek: 'DEEPSEEK_API_KEY', - }; - - const envKey = envKeyMap[provider]; - - if (!envKey) { - return null; - } - - // Get the API key from environment variables - const apiKey = (import.meta.env[envKey] as string) || null; - - // Special handling for providers with base URLs - if (provider === 'Together' && apiKey) { - const baseUrl = import.meta.env.TOGETHER_API_BASE_URL; - - if (!baseUrl) { - return null; - } - } - - return apiKey; - }, - [settings.providers], - ); - - // Update provider configurations based on available API keys - const getProviderConfig = useCallback((provider: ProviderName): ProviderConfig | null => { - const config = PROVIDER_STATUS_URLS[provider]; - - if (!config) { - return null; - } - - // Handle special cases for providers with base URLs - let updatedConfig = { ...config }; - const togetherBaseUrl = import.meta.env.TOGETHER_API_BASE_URL; - - if (provider === 'Together' && togetherBaseUrl) { - updatedConfig = { - ...config, - apiUrl: `${togetherBaseUrl}/models`, - }; - } - - return updatedConfig; - }, []); - - // Function to check if an API endpoint is accessible with model verification - const checkApiEndpoint = useCallback( - async ( - url: string, - headers?: Record, - testModel?: string, - ): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> => { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - const startTime = performance.now(); - - // Add common headers - const processedHeaders = { - 'Content-Type': 'application/json', - ...headers, - }; - - // First check if the API is accessible - const response = await fetch(url, { - method: 'GET', - headers: processedHeaders, - signal: controller.signal, - }); - - const endTime = performance.now(); - const responseTime = endTime - startTime; - - clearTimeout(timeoutId); - - // Get response data - const data = (await response.json()) as ApiResponse; - - // Special handling for different provider responses - if (!response.ok) { - let errorMessage = `API returned status: ${response.status}`; - - // Handle provider-specific error messages - if (data.error?.message) { - errorMessage = data.error.message; - } else if (data.message) { - errorMessage = data.message; - } - - return { - ok: false, - status: response.status, - message: errorMessage, - responseTime, - }; - } - - // Different providers have different model list formats - let models: string[] = []; - - if (Array.isArray(data)) { - models = data.map((model: { id?: string; name?: string }) => model.id || model.name || ''); - } else if (data.data && Array.isArray(data.data)) { - models = data.data.map((model) => model.id || model.name || ''); - } else if (data.models && Array.isArray(data.models)) { - models = data.models.map((model) => model.id || model.name || ''); - } else if (data.model) { - // Some providers return single model info - models = [data.model]; - } - - // For some providers, just having a successful response is enough - if (!testModel || models.length > 0) { - return { - ok: true, - status: response.status, - responseTime, - message: 'API key is valid', - }; - } - - // If a specific model was requested, verify it exists - if (testModel && !models.includes(testModel)) { - return { - ok: true, // Still mark as ok since API works - status: 'model_not_found', - message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`, - responseTime, - }; - } - - return { - ok: true, - status: response.status, - message: 'API key is valid', - responseTime, - }; - } catch (error) { - console.error(`Error checking API endpoint ${url}:`, error); - return { - ok: false, - status: error instanceof Error ? error.message : 'Unknown error', - message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed', - responseTime: 0, - }; - } - }, - [getApiKey], - ); - - // Function to fetch real status from provider status pages - const fetchPublicStatus = useCallback( - async ( - provider: ProviderName, - ): Promise<{ - status: ServiceStatus['status']; - message?: string; - incidents?: string[]; - }> => { - try { - // Due to CORS restrictions, we can only check if the endpoints are reachable - const checkEndpoint = async (url: string) => { - try { - const response = await fetch(url, { - mode: 'no-cors', - headers: { - Accept: 'text/html', - }, - }); - - // With no-cors, we can only know if the request succeeded - return response.type === 'opaque' ? 'reachable' : 'unreachable'; - } catch (error) { - console.error(`Error checking ${url}:`, error); - return 'unreachable'; - } - }; - - switch (provider) { - case 'HuggingFace': { - const endpointStatus = await checkEndpoint('https://status.huggingface.co/'); - - // Check API endpoint as fallback - const apiEndpoint = 'https://api-inference.huggingface.co/models'; - const apiStatus = await checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - - case 'OpenAI': { - const endpointStatus = await checkEndpoint('https://status.openai.com/'); - const apiEndpoint = 'https://api.openai.com/v1/models'; - const apiStatus = await checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - - case 'Google': { - const endpointStatus = await checkEndpoint('https://status.cloud.google.com/'); - const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models'; - const apiStatus = await checkEndpoint(apiEndpoint); - - return { - status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', - message: `Status page: ${endpointStatus}, API: ${apiStatus}`, - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - - // Similar pattern for other providers... - default: - return { - status: 'operational', - message: 'Basic reachability check only', - incidents: ['Note: Limited status information due to CORS restrictions'], - }; - } - } catch (error) { - console.error(`Error fetching status for ${provider}:`, error); - return { - status: 'degraded', - message: 'Unable to fetch status due to CORS restrictions', - incidents: ['Error: Unable to check service status'], - }; - } - }, - [], - ); - - // Function to fetch status for a provider with retries - const fetchProviderStatus = useCallback( - async (provider: ProviderName, config: ProviderConfig): Promise => { - const MAX_RETRIES = 2; - const RETRY_DELAY = 2000; // 2 seconds - - const attemptCheck = async (attempt: number): Promise => { - try { - // First check the public status page if available - const hasPublicStatus = [ - 'Anthropic', - 'OpenAI', - 'Google', - 'HuggingFace', - 'Mistral', - 'Groq', - 'Perplexity', - 'Together', - ].includes(provider); - - if (hasPublicStatus) { - const publicStatus = await fetchPublicStatus(provider); - - return { - provider, - status: publicStatus.status, - lastChecked: new Date().toISOString(), - statusUrl: config.statusUrl, - icon: PROVIDER_ICONS[provider], - message: publicStatus.message, - incidents: publicStatus.incidents, - }; - } - - // For other providers, we'll show status but mark API check as separate - const apiKey = getApiKey(provider); - const providerConfig = getProviderConfig(provider); - - if (!apiKey || !providerConfig) { - return { - provider, - status: 'operational', - lastChecked: new Date().toISOString(), - statusUrl: config.statusUrl, - icon: PROVIDER_ICONS[provider], - message: !apiKey - ? 'Status operational (API key needed for usage)' - : 'Status operational (configuration needed for usage)', - incidents: [], - }; - } - - // If we have API access, let's verify that too - const { ok, status, message, responseTime } = await checkApiEndpoint( - providerConfig.apiUrl, - providerConfig.headers, - providerConfig.testModel, - ); - - if (!ok && attempt < MAX_RETRIES) { - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); - return attemptCheck(attempt + 1); - } - - return { - provider, - status: ok ? 'operational' : 'degraded', - lastChecked: new Date().toISOString(), - statusUrl: providerConfig.statusUrl, - icon: PROVIDER_ICONS[provider], - message: ok ? 'Service and API operational' : `Service operational (API: ${message || status})`, - responseTime, - incidents: [], - }; - } catch (error) { - console.error(`Error fetching status for ${provider} (attempt ${attempt}):`, error); - - if (attempt < MAX_RETRIES) { - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); - return attemptCheck(attempt + 1); - } - - return { - provider, - status: 'degraded', - lastChecked: new Date().toISOString(), - statusUrl: config.statusUrl, - icon: PROVIDER_ICONS[provider], - message: 'Service operational (Status check error)', - responseTime: 0, - incidents: [], - }; - } - }; - - return attemptCheck(1); - }, - [checkApiEndpoint, getApiKey, getProviderConfig, fetchPublicStatus], - ); - - // Memoize the fetchAllStatuses function - const fetchAllStatuses = useCallback(async () => { - try { - setLoading(true); - - const statuses = await Promise.all( - Object.entries(PROVIDER_STATUS_URLS).map(([provider, config]) => - fetchProviderStatus(provider as ProviderName, config), - ), - ); - - setServiceStatuses(statuses.sort((a, b) => a.provider.localeCompare(b.provider))); - setLastRefresh(new Date()); - success('Service statuses updated successfully'); - } catch (err) { - console.error('Error fetching all statuses:', err); - error('Failed to update service statuses'); - } finally { - setLoading(false); - } - }, [fetchProviderStatus, success, error]); - - useEffect(() => { - fetchAllStatuses(); - - // Refresh status every 2 minutes - const interval = setInterval(fetchAllStatuses, 2 * 60 * 1000); - - return () => clearInterval(interval); - }, [fetchAllStatuses]); - - // Function to test an API key - const testApiKeyForProvider = useCallback( - async (provider: ProviderName, apiKey: string) => { - try { - setTestingStatus('testing'); - - const config = PROVIDER_STATUS_URLS[provider]; - - if (!config) { - throw new Error('Provider configuration not found'); - } - - const headers = { ...config.headers }; - - // Replace the placeholder API key with the test key - Object.keys(headers).forEach((key) => { - if (headers[key].startsWith('$')) { - headers[key] = headers[key].replace(/\$.*/, apiKey); - } - }); - - // Special handling for certain providers - switch (provider) { - case 'Anthropic': - headers['anthropic-version'] = '2024-02-29'; - break; - case 'OpenAI': - if (!headers.Authorization?.startsWith('Bearer ')) { - headers.Authorization = `Bearer ${apiKey}`; - } - - break; - case 'Google': { - // Google uses the API key directly in the URL - const googleUrl = `${config.apiUrl}?key=${apiKey}`; - const result = await checkApiEndpoint(googleUrl, {}, config.testModel); - - if (result.ok) { - setTestingStatus('success'); - success('API key is valid!'); - } else { - setTestingStatus('error'); - error(`API key test failed: ${result.message}`); - } - - return; - } - } - - const { ok, message } = await checkApiEndpoint(config.apiUrl, headers, config.testModel); - - if (ok) { - setTestingStatus('success'); - success('API key is valid!'); - } else { - setTestingStatus('error'); - error(`API key test failed: ${message}`); - } - } catch (err: unknown) { - setTestingStatus('error'); - error('Failed to test API key: ' + (err instanceof Error ? err.message : 'Unknown error')); - } finally { - // Reset testing status after a delay - setTimeout(() => setTestingStatus('idle'), 3000); - } - }, - [checkApiEndpoint, success, error], - ); - - const getStatusColor = (status: ServiceStatus['status']) => { - switch (status) { - case 'operational': - return 'text-green-500'; - case 'degraded': - return 'text-yellow-500'; - case 'down': - return 'text-red-500'; - default: - return 'text-gray-500'; - } - }; - - const getStatusIcon = (status: ServiceStatus['status']) => { - switch (status) { - case 'operational': - return ; - case 'degraded': - return ; - case 'down': - return ; - default: - return ; - } - }; - - return ( -
- -
-
-
- -
-
-

Service Status

-

- Monitor and test the operational status of cloud LLM providers -

-
-
-
- - Last updated: {lastRefresh.toLocaleTimeString()} - - -
-
- - {/* API Key Test Section */} -
-
Test API Key
-
- - setTestApiKey(e.target.value)} - placeholder="Enter API key to test" - className={classNames( - 'flex-1 px-3 py-1.5 rounded-lg text-sm', - 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor', - 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', - 'focus:outline-none focus:ring-2 focus:ring-purple-500/30', - )} - /> - -
-
- - {/* Status Grid */} - {loading && serviceStatuses.length === 0 ? ( -
Loading service statuses...
- ) : ( -
- {serviceStatuses.map((service, index) => ( - -
service.statusUrl && window.open(service.statusUrl, '_blank')} - > -
-
- {service.icon && ( -
- {React.createElement(service.icon, { - className: 'w-5 h-5', - })} -
- )} -
-

{service.provider}

-
-

- Last checked: {new Date(service.lastChecked).toLocaleTimeString()} -

- {service.responseTime && ( -

- Response time: {Math.round(service.responseTime)}ms -

- )} - {service.message && ( -

{service.message}

- )} -
-
-
-
- {service.status} - {getStatusIcon(service.status)} -
-
- {service.incidents && service.incidents.length > 0 && ( -
-

Recent Incidents:

-
    - {service.incidents.map((incident, i) => ( -
  • {incident}
  • - ))} -
-
- )} -
-
- ))} -
- )} -
-
- ); -}; - -// Add tab metadata -ServiceStatusTab.tabMetadata = { - icon: 'i-ph:activity-bold', - description: 'Monitor and test LLM provider service status', - category: 'services', -}; - -export default ServiceStatusTab; diff --git a/app/components/chat/ChatBox.tsx b/app/components/chat/ChatBox.tsx index 8a9fefa67c..4cd9a149a2 100644 --- a/app/components/chat/ChatBox.tsx +++ b/app/components/chat/ChatBox.tsx @@ -119,7 +119,7 @@ export const ChatBox: React.FC = (props) => { /> {(props.providerList || []).length > 0 && props.provider && - (!LOCAL_PROVIDERS.includes(props.provider.name) || 'OpenAILike') && ( + !LOCAL_PROVIDERS.includes(props.provider.name) && ( 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; + 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([]); + + // 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(); + + // 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), + }; +} diff --git a/app/lib/services/localModelHealthMonitor.ts b/app/lib/services/localModelHealthMonitor.ts new file mode 100644 index 0000000000..0ce28f898d --- /dev/null +++ b/app/lib/services/localModelHealthMonitor.ts @@ -0,0 +1,389 @@ +// Simple EventEmitter implementation for browser compatibility +class SimpleEventEmitter { + private _events: Record 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(); + private _checkIntervals = new Map(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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();