feat: local providers refactor & enhancement (#1968)
* feat: improve local providers health monitoring and model management - Add automatic health monitoring initialization for enabled providers - Add LM Studio model management and display functionality - Fix endpoint status detection by setting default base URLs - Replace mixed icon libraries with consistent Lucide icons only - Fix button styling with transparent backgrounds - Add comprehensive setup guides with web-researched content - Add proper navigation with back buttons between views - Fix all TypeScript and linting issues in LocalProvidersTab 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Remove Service Status tab and related code The Service Status tab and all associated files, components, and provider checkers have been deleted. References to 'service-status' have been removed from tab constants, types, and the control panel. This simplifies the settings UI and codebase by eliminating the service status monitoring feature. * Update LocalProvidersTab.tsx * Fix all linter and TypeScript errors in local providers components - Remove unused imports and fix import formatting - Fix type-only imports for OllamaModel and LMStudioModel - Fix Icon component usage in ProviderCard.tsx - Clean up unused imports across all local provider files - Ensure all TypeScript and ESLint checks pass --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,6 @@ import { DataTab } from '~/components/@settings/tabs/data/DataTab';
|
|||||||
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
|
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
|
||||||
import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab';
|
import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab';
|
||||||
import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab';
|
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 LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab';
|
||||||
import McpTab from '~/components/@settings/tabs/mcp/McpTab';
|
import McpTab from '~/components/@settings/tabs/mcp/McpTab';
|
||||||
|
|
||||||
@@ -33,7 +32,7 @@ interface ControlPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Beta status for experimental features
|
// Beta status for experimental features
|
||||||
const BETA_TABS = new Set<TabType>(['service-status', 'local-providers', 'mcp']);
|
const BETA_TABS = new Set<TabType>(['local-providers', 'mcp']);
|
||||||
|
|
||||||
const BetaLabel = () => (
|
const BetaLabel = () => (
|
||||||
<div className="absolute top-2 right-2 px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20">
|
<div className="absolute top-2 right-2 px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20">
|
||||||
@@ -138,8 +137,6 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|||||||
return <ConnectionsTab />;
|
return <ConnectionsTab />;
|
||||||
case 'event-logs':
|
case 'event-logs':
|
||||||
return <EventLogsTab />;
|
return <EventLogsTab />;
|
||||||
case 'service-status':
|
|
||||||
return <ServiceStatusTab />;
|
|
||||||
case 'mcp':
|
case 'mcp':
|
||||||
return <McpTab />;
|
return <McpTab />;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export const TAB_ICONS: Record<TabType, string> = {
|
|||||||
data: 'i-ph:database',
|
data: 'i-ph:database',
|
||||||
'cloud-providers': 'i-ph:cloud',
|
'cloud-providers': 'i-ph:cloud',
|
||||||
'local-providers': 'i-ph:laptop',
|
'local-providers': 'i-ph:laptop',
|
||||||
'service-status': 'i-ph:activity-bold',
|
|
||||||
connection: 'i-ph:wifi-high',
|
connection: 'i-ph:wifi-high',
|
||||||
'event-logs': 'i-ph:list-bullets',
|
'event-logs': 'i-ph:list-bullets',
|
||||||
mcp: 'i-ph:wrench',
|
mcp: 'i-ph:wrench',
|
||||||
@@ -22,7 +21,6 @@ export const TAB_LABELS: Record<TabType, string> = {
|
|||||||
data: 'Data Management',
|
data: 'Data Management',
|
||||||
'cloud-providers': 'Cloud Providers',
|
'cloud-providers': 'Cloud Providers',
|
||||||
'local-providers': 'Local Providers',
|
'local-providers': 'Local Providers',
|
||||||
'service-status': 'Service Status',
|
|
||||||
connection: 'Connection',
|
connection: 'Connection',
|
||||||
'event-logs': 'Event Logs',
|
'event-logs': 'Event Logs',
|
||||||
mcp: 'MCP Servers',
|
mcp: 'MCP Servers',
|
||||||
@@ -36,7 +34,6 @@ export const TAB_DESCRIPTIONS: Record<TabType, string> = {
|
|||||||
data: 'Manage your data and storage',
|
data: 'Manage your data and storage',
|
||||||
'cloud-providers': 'Configure cloud AI providers and models',
|
'cloud-providers': 'Configure cloud AI providers and models',
|
||||||
'local-providers': 'Configure local 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',
|
connection: 'Check connection status and settings',
|
||||||
'event-logs': 'View system events and logs',
|
'event-logs': 'View system events and logs',
|
||||||
mcp: 'Configure MCP (Model Context Protocol) servers',
|
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: 'mcp', visible: true, window: 'user' as const, order: 7 },
|
||||||
|
|
||||||
{ id: 'profile', visible: true, window: 'user' as const, order: 9 },
|
{ 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: 10 },
|
||||||
{ id: 'settings', visible: true, window: 'user' as const, order: 11 },
|
|
||||||
|
|
||||||
// User Window Tabs (In dropdown, initially hidden)
|
// User Window Tabs (In dropdown, initially hidden)
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export type TabType =
|
|||||||
| 'data'
|
| 'data'
|
||||||
| 'cloud-providers'
|
| 'cloud-providers'
|
||||||
| 'local-providers'
|
| 'local-providers'
|
||||||
| 'service-status'
|
|
||||||
| 'connection'
|
| 'connection'
|
||||||
| 'event-logs'
|
| 'event-logs'
|
||||||
| 'mcp';
|
| 'mcp';
|
||||||
@@ -70,7 +69,6 @@ export const TAB_LABELS: Record<TabType, string> = {
|
|||||||
data: 'Data Management',
|
data: 'Data Management',
|
||||||
'cloud-providers': 'Cloud Providers',
|
'cloud-providers': 'Cloud Providers',
|
||||||
'local-providers': 'Local Providers',
|
'local-providers': 'Local Providers',
|
||||||
'service-status': 'Service Status',
|
|
||||||
connection: 'Connections',
|
connection: 'Connections',
|
||||||
'event-logs': 'Event Logs',
|
'event-logs': 'Event Logs',
|
||||||
mcp: 'MCP Servers',
|
mcp: 'MCP Servers',
|
||||||
|
|||||||
@@ -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<Props, State> {
|
||||||
|
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 (
|
||||||
|
<div className={classNames('p-6 rounded-lg border border-red-500/20', 'bg-red-500/5 text-center')}>
|
||||||
|
<AlertCircle className="w-12 h-12 mx-auto text-red-500 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-red-500 mb-2">Something went wrong</h3>
|
||||||
|
<p className="text-sm text-red-400 mb-4">There was an error loading the local providers section.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => this.setState({ hasError: false, error: undefined })}
|
||||||
|
className={classNames(
|
||||||
|
'px-4 py-2 rounded-lg text-sm font-medium',
|
||||||
|
'bg-red-500/10 text-red-500',
|
||||||
|
'hover:bg-red-500/20',
|
||||||
|
'transition-colors duration-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||||
|
<details className="mt-4 text-left">
|
||||||
|
<summary className="cursor-pointer text-sm text-red-400 hover:text-red-300">Error Details</summary>
|
||||||
|
<pre className="mt-2 p-2 bg-red-500/10 rounded text-xs text-red-300 overflow-auto">
|
||||||
|
{this.state.error.stack}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||||||
|
config.bgColor,
|
||||||
|
config.color,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={classNames('w-3 h-3', { 'animate-spin': status === 'checking' })} />
|
||||||
|
<span>{config.label}</span>
|
||||||
|
{responseTime !== undefined && status === 'healthy' && <span className="opacity-75">({responseTime}ms)</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HealthStatusBadge;
|
||||||
@@ -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 (
|
||||||
|
<div className={classNames('space-y-2', className)}>
|
||||||
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={classNames('bg-bolt-elements-background-depth-3 rounded', height, 'animate-pulse')}
|
||||||
|
style={{ animationDelay: `${i * 0.1}s` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelCardSkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelCardSkeleton({ className }: ModelCardSkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'border rounded-lg p-4',
|
||||||
|
'bg-bolt-elements-background-depth-2',
|
||||||
|
'border-bolt-elements-borderColor',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-bolt-elements-textTertiary animate-pulse" />
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<LoadingSkeleton height="h-5" lines={1} className="w-3/4" />
|
||||||
|
<LoadingSkeleton height="h-3" lines={1} className="w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-4 h-4 bg-bolt-elements-textTertiary rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderCardSkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderCardSkeleton({ className }: ProviderCardSkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div className={classNames('bg-bolt-elements-background-depth-2 rounded-xl p-5', className)}>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-4 flex-1">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-bolt-elements-background-depth-3 animate-pulse" />
|
||||||
|
<div className="space-y-3 flex-1">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<LoadingSkeleton height="h-5" lines={1} className="w-1/3" />
|
||||||
|
<LoadingSkeleton height="h-4" lines={1} className="w-2/3" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<LoadingSkeleton height="h-3" lines={1} className="w-1/4" />
|
||||||
|
<LoadingSkeleton height="h-8" lines={1} className="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-6 bg-bolt-elements-background-depth-3 rounded-full animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelManagerSkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
cardCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelManagerSkeleton({ className, cardCount = 3 }: ModelManagerSkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div className={classNames('space-y-6', className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<LoadingSkeleton height="h-6" lines={1} className="w-48" />
|
||||||
|
<LoadingSkeleton height="h-4" lines={1} className="w-64" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-24 h-8 bg-bolt-elements-background-depth-3 rounded-lg animate-pulse" />
|
||||||
|
<div className="w-16 h-8 bg-bolt-elements-background-depth-3 rounded-lg animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Cards */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: cardCount }).map((_, i) => (
|
||||||
|
<ModelCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ViewMode>('dashboard');
|
||||||
|
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
||||||
|
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
|
||||||
|
const [lmStudioModels, setLMStudioModels] = useState<LMStudioModel[]>([]);
|
||||||
|
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 (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<SetupGuide onBack={() => setViewMode('dashboard')} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewMode === 'status') {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<StatusDashboard onBack={() => setViewMode('dashboard')} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-500/20 to-blue-500/20 flex items-center justify-center ring-1 ring-purple-500/30">
|
||||||
|
<Cpu className="w-6 h-6 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-bolt-elements-textPrimary">Local AI Providers</h2>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary">Configure and manage your local AI models</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-bolt-elements-textSecondary">Enable All</span>
|
||||||
|
<Switch
|
||||||
|
checked={categoryEnabled}
|
||||||
|
onCheckedChange={handleToggleCategory}
|
||||||
|
aria-label="Toggle all local providers"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode('guide')}
|
||||||
|
className="bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 border-bolt-elements-borderColor hover:border-purple-500/30 transition-all duration-200 gap-2"
|
||||||
|
>
|
||||||
|
<BookOpen className="w-4 h-4" />
|
||||||
|
Setup Guide
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode('status')}
|
||||||
|
className="bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 border-bolt-elements-borderColor hover:border-purple-500/30 transition-all duration-200 gap-2"
|
||||||
|
>
|
||||||
|
<Activity className="w-4 h-4" />
|
||||||
|
Status
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider Cards */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{filteredProviders.map((provider) => (
|
||||||
|
<div key={provider.name} className="space-y-4">
|
||||||
|
<ProviderCard
|
||||||
|
provider={provider}
|
||||||
|
onToggle={(enabled) => 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 && (
|
||||||
|
<Card className="mt-4 bg-bolt-elements-background-depth-2">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PackageOpen className="w-5 h-5 text-purple-500" />
|
||||||
|
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Installed Models</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={fetchOllamaModels}
|
||||||
|
disabled={isLoadingModels}
|
||||||
|
className="bg-transparent hover:bg-bolt-elements-background-depth-2"
|
||||||
|
>
|
||||||
|
{isLoadingModels ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
) : (
|
||||||
|
<RotateCw className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isLoadingModels ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<ModelCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : ollamaModels.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<PackageOpen className="w-16 h-16 mx-auto text-bolt-elements-textTertiary mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">No Models Installed</h3>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
||||||
|
Visit{' '}
|
||||||
|
<a
|
||||||
|
href="https://ollama.com/library"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-purple-500 hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
ollama.com/library
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>{' '}
|
||||||
|
to browse available models
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="bg-gradient-to-r from-purple-500/8 to-purple-600/8 hover:from-purple-500/15 hover:to-purple-600/15 border-purple-500/25 hover:border-purple-500/40 transition-all duration-300 gap-2 group shadow-sm hover:shadow-md font-medium"
|
||||||
|
_asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://ollama.com/library"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300 flex-shrink-0" />
|
||||||
|
<span className="flex-1 text-center font-medium">Browse Models</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{ollamaModels.map((model) => (
|
||||||
|
<ModelCard
|
||||||
|
key={model.name}
|
||||||
|
model={model}
|
||||||
|
onUpdate={() => handleUpdateOllamaModel(model.name)}
|
||||||
|
onDelete={() => handleDeleteOllamaModel(model.name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LM Studio Models Section */}
|
||||||
|
{provider.name === 'LMStudio' && provider.settings.enabled && (
|
||||||
|
<Card className="mt-4 bg-bolt-elements-background-depth-2">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Monitor className="w-5 h-5 text-blue-500" />
|
||||||
|
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Available Models</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fetchLMStudioModels(provider.settings.baseUrl!)}
|
||||||
|
disabled={isLoadingLMStudioModels}
|
||||||
|
className="bg-transparent hover:bg-bolt-elements-background-depth-2"
|
||||||
|
>
|
||||||
|
{isLoadingLMStudioModels ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
) : (
|
||||||
|
<RotateCw className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isLoadingLMStudioModels ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<ModelCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : lmStudioModels.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Monitor className="w-16 h-16 mx-auto text-bolt-elements-textTertiary mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">No Models Available</h3>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
||||||
|
Make sure LM Studio is running with the local server started and CORS enabled.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="bg-gradient-to-r from-blue-500/8 to-blue-600/8 hover:from-blue-500/15 hover:to-blue-600/15 border-blue-500/25 hover:border-blue-500/40 transition-all duration-300 gap-2 group shadow-sm hover:shadow-md font-medium"
|
||||||
|
_asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://lmstudio.ai/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300 flex-shrink-0" />
|
||||||
|
<span className="flex-1 text-center font-medium">Get LM Studio</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{lmStudioModels.map((model) => (
|
||||||
|
<Card key={model.id} className="bg-bolt-elements-background-depth-3">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="text-sm font-medium text-bolt-elements-textPrimary font-mono">
|
||||||
|
{model.id}
|
||||||
|
</h4>
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-500">
|
||||||
|
Available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-bolt-elements-textSecondary">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Server className="w-3 h-3" />
|
||||||
|
<span>{model.object}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Activity className="w-3 h-3" />
|
||||||
|
<span>Owned by: {model.owned_by}</span>
|
||||||
|
</div>
|
||||||
|
{model.created && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Activity className="w-3 h-3" />
|
||||||
|
<span>Created: {new Date(model.created * 1000).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredProviders.length === 0 && (
|
||||||
|
<Card className="bg-bolt-elements-background-depth-2">
|
||||||
|
<CardContent className="p-8 text-center">
|
||||||
|
<Server className="w-16 h-16 mx-auto text-bolt-elements-textTertiary mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">No Local Providers Available</h3>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary">
|
||||||
|
Local providers will appear here when they're configured in the system.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
106
app/components/@settings/tabs/providers/local/ModelCard.tsx
Normal file
106
app/components/@settings/tabs/providers/local/ModelCard.tsx
Normal file
@@ -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 (
|
||||||
|
<Card className="bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4 transition-all duration-200 shadow-sm hover:shadow-md border border-bolt-elements-borderColor hover:border-purple-500/20">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="text-sm font-medium text-bolt-elements-textPrimary font-mono">{model.name}</h4>
|
||||||
|
{model.status && model.status !== 'idle' && (
|
||||||
|
<span
|
||||||
|
className={classNames('px-2 py-0.5 rounded-full text-xs font-medium', {
|
||||||
|
'bg-yellow-500/10 text-yellow-500': model.status === 'updating',
|
||||||
|
'bg-green-500/10 text-green-500': model.status === 'updated',
|
||||||
|
'bg-red-500/10 text-red-500': model.status === 'error',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{model.status === 'updating' && 'Updating'}
|
||||||
|
{model.status === 'updated' && 'Updated'}
|
||||||
|
{model.status === 'error' && 'Error'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-bolt-elements-textSecondary">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Code className="w-3 h-3" />
|
||||||
|
<span>{model.digest.substring(0, 8)}</span>
|
||||||
|
</div>
|
||||||
|
{model.details && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Database className="w-3 h-3" />
|
||||||
|
<span>{model.details.parameter_size}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Package className="w-3 h-3" />
|
||||||
|
<span>{model.details.quantization_level}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onUpdate}
|
||||||
|
disabled={model.status === 'updating'}
|
||||||
|
className={classNames(
|
||||||
|
'flex items-center gap-2 px-3 py-2 text-xs rounded-lg transition-all duration-200',
|
||||||
|
'bg-purple-500/10 text-purple-500 hover:bg-purple-500/20 hover:shadow-sm',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-purple-500/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{model.status === 'updating' ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
Updating
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RotateCw className="w-3 h-3" />
|
||||||
|
Update
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={model.status === 'updating'}
|
||||||
|
className={classNames(
|
||||||
|
'flex items-center gap-2 px-3 py-2 text-xs rounded-lg transition-all duration-200',
|
||||||
|
'bg-red-500/10 text-red-500 hover:bg-red-500/20 hover:shadow-sm',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-red-500/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{model.progress && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="flex justify-between text-xs text-bolt-elements-textSecondary">
|
||||||
|
<span>{model.progress.status}</span>
|
||||||
|
<span>{Math.round((model.progress.current / model.progress.total) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={Math.round((model.progress.current / model.progress.total) * 100)} className="h-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModelCard;
|
||||||
@@ -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 (
|
|
||||||
<svg viewBox="0 0 1024 1024" className={className} fill="currentColor">
|
|
||||||
<path d="M684.3 322.2H339.8c-9.5.1-17.7 6.8-19.6 16.1-8.2 41.4-12.4 83.5-12.4 125.7 0 42.2 4.2 84.3 12.4 125.7 1.9 9.3 10.1 16 19.6 16.1h344.5c9.5-.1 17.7-6.8 19.6-16.1 8.2-41.4 12.4-83.5 12.4-125.7 0-42.2-4.2-84.3-12.4-125.7-1.9-9.3-10.1-16-19.6-16.1zM512 640c-176.7 0-320-143.3-320-320S335.3 0 512 0s320 143.3 320 320-143.3 320-320 320z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<InstallProgress | null>(null);
|
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
|
||||||
const [models, setModels] = useState<ModelInfo[]>(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 (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between pt-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<OllamaIcon className="w-8 h-8 text-purple-500" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Ollama Models</h3>
|
|
||||||
<p className="text-sm text-bolt-elements-textSecondary mt-1">Install and manage your Ollama models</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<motion.button
|
|
||||||
onClick={handleCheckUpdates}
|
|
||||||
disabled={isChecking}
|
|
||||||
className={classNames(
|
|
||||||
'px-4 py-2 rounded-lg',
|
|
||||||
'bg-purple-500/10 text-purple-500',
|
|
||||||
'hover:bg-purple-500/20',
|
|
||||||
'transition-all duration-200',
|
|
||||||
'flex items-center gap-2',
|
|
||||||
)}
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
>
|
|
||||||
{isChecking ? (
|
|
||||||
<div className="i-ph:spinner-gap-bold animate-spin" />
|
|
||||||
) : (
|
|
||||||
<div className="i-ph:arrows-clockwise" />
|
|
||||||
)}
|
|
||||||
Check Updates
|
|
||||||
</motion.button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={classNames(
|
|
||||||
'w-full px-4 py-3 rounded-xl',
|
|
||||||
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
|
|
||||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
|
||||||
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
|
||||||
'transition-all duration-200',
|
|
||||||
)}
|
|
||||||
placeholder="Search models or enter custom model name..."
|
|
||||||
value={searchQuery || modelString}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
setSearchQuery(value);
|
|
||||||
setModelString(value);
|
|
||||||
}}
|
|
||||||
disabled={isInstalling}
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-bolt-elements-textSecondary px-1">
|
|
||||||
Browse models at{' '}
|
|
||||||
<a
|
|
||||||
href="https://ollama.com/library"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-purple-500 hover:underline inline-flex items-center gap-1 text-base font-medium"
|
|
||||||
>
|
|
||||||
ollama.com/library
|
|
||||||
<div className="i-ph:arrow-square-out text-sm" />
|
|
||||||
</a>{' '}
|
|
||||||
and copy model names to install
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<motion.button
|
|
||||||
onClick={() => 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 ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
|
||||||
<span>Installing...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<OllamaIcon className="w-4 h-4" />
|
|
||||||
<span>Install Model</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{allTags.map((tag) => (
|
|
||||||
<button
|
|
||||||
key={tag}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
|
|
||||||
}}
|
|
||||||
className={classNames(
|
|
||||||
'px-3 py-1 rounded-full text-xs font-medium transition-all duration-200',
|
|
||||||
selectedTags.includes(tag)
|
|
||||||
? 'bg-purple-500 text-white'
|
|
||||||
: 'bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:bg-bolt-elements-background-depth-4',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2">
|
|
||||||
{filteredModels.map((model) => (
|
|
||||||
<motion.div
|
|
||||||
key={model.name}
|
|
||||||
className={classNames(
|
|
||||||
'flex items-start gap-2 p-3 rounded-lg',
|
|
||||||
'bg-bolt-elements-background-depth-3',
|
|
||||||
'hover:bg-bolt-elements-background-depth-4',
|
|
||||||
'transition-all duration-200',
|
|
||||||
'relative group',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<OllamaIcon className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="flex-1 space-y-1.5">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-bolt-elements-textPrimary font-mono text-sm">{model.name}</p>
|
|
||||||
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">{model.desc}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<span className="text-xs text-bolt-elements-textTertiary">{model.size}</span>
|
|
||||||
{model.installedVersion && (
|
|
||||||
<div className="mt-0.5 flex flex-col items-end gap-0.5">
|
|
||||||
<span className="text-xs text-bolt-elements-textTertiary">v{model.installedVersion}</span>
|
|
||||||
{model.needsUpdate && model.latestVersion && (
|
|
||||||
<span className="text-xs text-purple-500">v{model.latestVersion} available</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{model.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="px-1.5 py-0.5 rounded-full text-[10px] bg-bolt-elements-background-depth-4 text-bolt-elements-textTertiary"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{model.installedVersion ? (
|
|
||||||
model.needsUpdate ? (
|
|
||||||
<motion.button
|
|
||||||
onClick={() => 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 }}
|
|
||||||
>
|
|
||||||
<div className="i-ph:arrows-clockwise text-xs" />
|
|
||||||
Update
|
|
||||||
</motion.button>
|
|
||||||
) : (
|
|
||||||
<span className="px-2 py-0.5 rounded-lg text-xs text-green-500 bg-green-500/10">Up to date</span>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<motion.button
|
|
||||||
onClick={() => 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 }}
|
|
||||||
>
|
|
||||||
<div className="i-ph:download text-xs" />
|
|
||||||
Install
|
|
||||||
</motion.button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{installProgress && (
|
|
||||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-2">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-bolt-elements-textSecondary">{installProgress.status}</span>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-bolt-elements-textTertiary">
|
|
||||||
{installProgress.downloadedSize} / {installProgress.totalSize}
|
|
||||||
</span>
|
|
||||||
<span className="text-bolt-elements-textTertiary">{installProgress.speed}</span>
|
|
||||||
<span className="text-bolt-elements-textSecondary">{Math.round(installProgress.progress)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Progress value={installProgress.progress} className="h-1" />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
120
app/components/@settings/tabs/providers/local/ProviderCard.tsx
Normal file
120
app/components/@settings/tabs/providers/local/ProviderCard.tsx
Normal file
@@ -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 (
|
||||||
|
<Card className="bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 transition-all duration-300 shadow-sm hover:shadow-md border border-bolt-elements-borderColor hover:border-purple-500/30">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-4 flex-1">
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300',
|
||||||
|
provider.settings.enabled
|
||||||
|
? 'bg-gradient-to-br from-purple-500/20 to-purple-600/20 ring-1 ring-purple-500/30'
|
||||||
|
: 'bg-bolt-elements-background-depth-3',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={classNames(
|
||||||
|
'w-6 h-6 transition-all duration-300',
|
||||||
|
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textTertiary',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
|
||||||
|
<span className="px-2 py-1 text-xs rounded-full bg-green-500/10 text-green-500 font-medium">Local</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
||||||
|
{PROVIDER_DESCRIPTIONS[provider.name as keyof typeof PROVIDER_DESCRIPTIONS]}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{provider.settings.enabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-bolt-elements-textPrimary">API Endpoint</label>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={provider.settings.baseUrl}
|
||||||
|
placeholder={`Enter ${provider.name} base URL`}
|
||||||
|
className="w-full px-4 py-3 rounded-lg text-sm bg-bolt-elements-background-depth-4 border border-purple-500/30 text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 transition-all duration-200 shadow-sm"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
onUpdateBaseUrl(e.currentTarget.value);
|
||||||
|
onStopEditing();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
onStopEditing();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
onUpdateBaseUrl(e.target.value);
|
||||||
|
onStopEditing();
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onStartEditing}
|
||||||
|
className="w-full px-4 py-3 rounded-lg text-sm bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4 hover:shadow-sm transition-all duration-200 text-left group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 text-bolt-elements-textSecondary group-hover:text-bolt-elements-textPrimary">
|
||||||
|
<Link className="w-4 h-4 group-hover:text-purple-500 transition-colors" />
|
||||||
|
<span className="font-mono">{provider.settings.baseUrl || 'Click to set base URL'}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={provider.settings.enabled}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
aria-label={`Toggle ${provider.name} provider`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProviderCard;
|
||||||
671
app/components/@settings/tabs/providers/local/SetupGuide.tsx
Normal file
671
app/components/@settings/tabs/providers/local/SetupGuide.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header with Back Button */}
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onBack}
|
||||||
|
className="bg-transparent hover:bg-transparent text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-all duration-200 p-2"
|
||||||
|
aria-label="Back to Dashboard"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-bolt-elements-textPrimary">Local Provider Setup Guide</h2>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary">
|
||||||
|
Complete setup instructions for running AI models locally
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hardware Requirements Overview */}
|
||||||
|
<Card className="bg-gradient-to-r from-blue-500/10 to-purple-500/10 border border-blue-500/20 shadow-sm">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||||
|
<Shield className="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">System Requirements</h3>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary">Recommended hardware for optimal performance</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-3 gap-4 text-sm">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Cpu className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="font-medium text-bolt-elements-textPrimary">CPU</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-bolt-elements-textSecondary">8+ cores, modern architecture</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="w-4 h-4 text-blue-500" />
|
||||||
|
<span className="font-medium text-bolt-elements-textPrimary">RAM</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-bolt-elements-textSecondary">16GB minimum, 32GB+ recommended</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Monitor className="w-4 h-4 text-purple-500" />
|
||||||
|
<span className="font-medium text-bolt-elements-textPrimary">GPU</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-bolt-elements-textSecondary">NVIDIA RTX 30xx+ or AMD RX 6000+</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Ollama Setup Section */}
|
||||||
|
<Card className="bg-bolt-elements-background-depth-2 shadow-sm">
|
||||||
|
<CardHeader className="pb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-600/20 flex items-center justify-center ring-1 ring-purple-500/30">
|
||||||
|
<Server className="w-6 h-6 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-xl font-semibold text-bolt-elements-textPrimary">Ollama Setup</h3>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary">
|
||||||
|
Most popular choice for running open-source models locally with desktop app
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="px-3 py-1 bg-purple-500/10 text-purple-500 text-xs font-medium rounded-full">
|
||||||
|
Recommended
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Installation Options */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
1. Choose Installation Method
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Desktop App - New and Recommended */}
|
||||||
|
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Monitor className="w-5 h-5 text-green-500" />
|
||||||
|
<h5 className="font-medium text-green-500">🆕 Desktop App (Recommended)</h5>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary mb-3">
|
||||||
|
New user-friendly desktop application with built-in model management and web interface.
|
||||||
|
</p>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Monitor className="w-4 h-4 text-bolt-elements-textPrimary" />
|
||||||
|
<strong className="text-bolt-elements-textPrimary">macOS</strong>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full bg-gradient-to-r from-purple-500/10 to-purple-600/10 hover:from-purple-500/20 hover:to-purple-600/20 border-purple-500/30 hover:border-purple-500/50 transition-all duration-300 gap-2 group shadow-sm hover:shadow-lg hover:shadow-purple-500/20 font-medium"
|
||||||
|
_asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://ollama.com/download/mac"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 group-hover:scale-110 group-hover:rotate-12 transition-all duration-300 flex-shrink-0" />
|
||||||
|
<span className="flex-1 text-center font-medium">Download Desktop App</span>
|
||||||
|
<ExternalLink className="w-3 h-3 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300 flex-shrink-0" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Monitor className="w-4 h-4 text-bolt-elements-textPrimary" />
|
||||||
|
<strong className="text-bolt-elements-textPrimary">Windows</strong>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full bg-gradient-to-r from-purple-500/10 to-purple-600/10 hover:from-purple-500/20 hover:to-purple-600/20 border-purple-500/30 hover:border-purple-500/50 transition-all duration-300 gap-2 group shadow-sm hover:shadow-lg hover:shadow-purple-500/20 font-medium"
|
||||||
|
_asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://ollama.com/download/windows"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 group-hover:scale-110 group-hover:rotate-12 transition-all duration-300 flex-shrink-0" />
|
||||||
|
<span className="flex-1 text-center font-medium">Download Desktop App</span>
|
||||||
|
<ExternalLink className="w-3 h-3 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300 flex-shrink-0" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Globe className="w-4 h-4 text-blue-500" />
|
||||||
|
<span className="font-medium text-blue-500 text-sm">Built-in Web Interface</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-bolt-elements-textSecondary">
|
||||||
|
Desktop app includes a web interface at{' '}
|
||||||
|
<code className="bg-bolt-elements-background-depth-4 px-1 rounded">http://localhost:11434</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CLI Installation */}
|
||||||
|
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Terminal className="w-5 h-5 text-bolt-elements-textPrimary" />
|
||||||
|
<h5 className="font-medium text-bolt-elements-textPrimary">Command Line (Advanced)</h5>
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-3 gap-4">
|
||||||
|
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Monitor className="w-4 h-4 text-bolt-elements-textPrimary" />
|
||||||
|
<strong className="text-bolt-elements-textPrimary">Windows</strong>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs bg-bolt-elements-background-depth-4 p-2 rounded font-mono text-bolt-elements-textPrimary">
|
||||||
|
winget install Ollama.Ollama
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Monitor className="w-4 h-4 text-bolt-elements-textPrimary" />
|
||||||
|
<strong className="text-bolt-elements-textPrimary">macOS</strong>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs bg-bolt-elements-background-depth-4 p-2 rounded font-mono text-bolt-elements-textPrimary">
|
||||||
|
brew install ollama
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Terminal className="w-4 h-4 text-bolt-elements-textPrimary" />
|
||||||
|
<strong className="text-bolt-elements-textPrimary">Linux</strong>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs bg-bolt-elements-background-depth-4 p-2 rounded font-mono text-bolt-elements-textPrimary">
|
||||||
|
curl -fsSL https://ollama.com/install.sh | sh
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Latest Model Recommendations */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4" />
|
||||||
|
2. Download Latest Models
|
||||||
|
</h4>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<h5 className="font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2">
|
||||||
|
<Code className="w-4 h-4 text-green-500" />
|
||||||
|
Code & Development
|
||||||
|
</h5>
|
||||||
|
<div className="space-y-2 text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary">
|
||||||
|
<div># Latest Llama 3.2 for coding</div>
|
||||||
|
<div>ollama pull llama3.2:3b</div>
|
||||||
|
<div>ollama pull codellama:13b</div>
|
||||||
|
<div>ollama pull deepseek-coder-v2</div>
|
||||||
|
<div>ollama pull qwen2.5-coder:7b</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<h5 className="font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2">
|
||||||
|
<Terminal className="w-4 h-4 text-blue-500" />
|
||||||
|
General Purpose & Chat
|
||||||
|
</h5>
|
||||||
|
<div className="space-y-2 text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary">
|
||||||
|
<div># Latest general models</div>
|
||||||
|
<div>ollama pull llama3.2:3b</div>
|
||||||
|
<div>ollama pull mistral:7b</div>
|
||||||
|
<div>ollama pull phi3.5:3.8b</div>
|
||||||
|
<div>ollama pull qwen2.5:7b</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 rounded-lg bg-purple-500/5 border border-purple-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Activity className="w-4 h-4 text-purple-500" />
|
||||||
|
<span className="font-medium text-purple-500">Performance Optimized</span>
|
||||||
|
</div>
|
||||||
|
<ul className="text-xs text-bolt-elements-textSecondary space-y-1">
|
||||||
|
<li>• Llama 3.2: 3B - Fastest, 8GB RAM</li>
|
||||||
|
<li>• Phi-3.5: 3.8B - Great balance</li>
|
||||||
|
<li>• Qwen2.5: 7B - Excellent quality</li>
|
||||||
|
<li>• Mistral: 7B - Popular choice</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
||||||
|
<span className="font-medium text-yellow-500">Pro Tips</span>
|
||||||
|
</div>
|
||||||
|
<ul className="text-xs text-bolt-elements-textSecondary space-y-1">
|
||||||
|
<li>• Start with 3B-7B models for best performance</li>
|
||||||
|
<li>• Use quantized versions for faster loading</li>
|
||||||
|
<li>• Desktop app auto-manages model storage</li>
|
||||||
|
<li>• Web UI available at localhost:11434</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop App Features */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||||
|
<Monitor className="w-4 h-4" />
|
||||||
|
3. Desktop App Features
|
||||||
|
</h4>
|
||||||
|
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h5 className="font-medium text-blue-500 mb-3">🖥️ User Interface</h5>
|
||||||
|
<ul className="text-sm text-bolt-elements-textSecondary space-y-1">
|
||||||
|
<li>• Model library browser</li>
|
||||||
|
<li>• One-click model downloads</li>
|
||||||
|
<li>• Built-in chat interface</li>
|
||||||
|
<li>• System resource monitoring</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-medium text-blue-500 mb-3">🔧 Management Tools</h5>
|
||||||
|
<ul className="text-sm text-bolt-elements-textSecondary space-y-1">
|
||||||
|
<li>• Automatic updates</li>
|
||||||
|
<li>• Model size optimization</li>
|
||||||
|
<li>• GPU acceleration detection</li>
|
||||||
|
<li>• Cross-platform compatibility</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Troubleshooting */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
4. Troubleshooting & Commands
|
||||||
|
</h4>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 rounded-lg bg-red-500/5 border border-red-500/20">
|
||||||
|
<h5 className="font-medium text-red-500 mb-2">Common Issues</h5>
|
||||||
|
<ul className="text-xs text-bolt-elements-textSecondary space-y-1">
|
||||||
|
<li>• Desktop app not starting: Restart system</li>
|
||||||
|
<li>• GPU not detected: Update drivers</li>
|
||||||
|
<li>• Port 11434 blocked: Change port in settings</li>
|
||||||
|
<li>• Models not loading: Check available disk space</li>
|
||||||
|
<li>• Slow performance: Use smaller models or enable GPU</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/20">
|
||||||
|
<h5 className="font-medium text-green-500 mb-2">Useful Commands</h5>
|
||||||
|
<div className="text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary space-y-1">
|
||||||
|
<div># Check installed models</div>
|
||||||
|
<div>ollama list</div>
|
||||||
|
<div></div>
|
||||||
|
<div># Remove unused models</div>
|
||||||
|
<div>ollama rm model_name</div>
|
||||||
|
<div></div>
|
||||||
|
<div># Check GPU usage</div>
|
||||||
|
<div>ollama ps</div>
|
||||||
|
<div></div>
|
||||||
|
<div># View logs</div>
|
||||||
|
<div>ollama logs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* LM Studio Setup Section */}
|
||||||
|
<Card className="bg-bolt-elements-background-depth-2 shadow-sm">
|
||||||
|
<CardHeader className="pb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/20 flex items-center justify-center ring-1 ring-blue-500/30">
|
||||||
|
<Monitor className="w-6 h-6 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-xl font-semibold text-bolt-elements-textPrimary">LM Studio Setup</h3>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary">
|
||||||
|
User-friendly GUI for running local models with excellent model management
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Installation */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
1. Download & Install
|
||||||
|
</h4>
|
||||||
|
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary mb-3">
|
||||||
|
Download LM Studio for Windows, macOS, or Linux from the official website.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="bg-gradient-to-r from-blue-500/10 to-blue-600/10 hover:from-blue-500/20 hover:to-blue-600/20 border-blue-500/30 hover:border-blue-500/50 transition-all duration-300 gap-2 group shadow-sm hover:shadow-lg hover:shadow-blue-500/20 font-medium"
|
||||||
|
_asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://lmstudio.ai/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 group-hover:scale-110 group-hover:rotate-12 transition-all duration-300 flex-shrink-0" />
|
||||||
|
<span className="flex-1 text-center font-medium">Download LM Studio</span>
|
||||||
|
<ExternalLink className="w-3 h-3 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300 flex-shrink-0" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
2. Configure Local Server
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<h5 className="font-medium text-bolt-elements-textPrimary mb-2">Start Local Server</h5>
|
||||||
|
<ol className="text-xs text-bolt-elements-textSecondary space-y-1 list-decimal list-inside">
|
||||||
|
<li>Download a model from the "My Models" tab</li>
|
||||||
|
<li>Go to "Local Server" tab</li>
|
||||||
|
<li>Select your downloaded model</li>
|
||||||
|
<li>Set port to 1234 (default)</li>
|
||||||
|
<li>Click "Start Server"</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||||
|
<span className="font-medium text-red-500">Critical: Enable CORS</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-bolt-elements-textSecondary">
|
||||||
|
To work with Bolt DIY, you MUST enable CORS in LM Studio:
|
||||||
|
</p>
|
||||||
|
<ol className="text-xs text-bolt-elements-textSecondary space-y-1 list-decimal list-inside ml-2">
|
||||||
|
<li>In Server Settings, check "Enable CORS"</li>
|
||||||
|
<li>Set Network Interface to "0.0.0.0" for external access</li>
|
||||||
|
<li>
|
||||||
|
Alternatively, use CLI:{' '}
|
||||||
|
<code className="bg-bolt-elements-background-depth-4 px-1 rounded">lms server start --cors</code>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advantages */}
|
||||||
|
<div className="p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-blue-500" />
|
||||||
|
<span className="font-medium text-blue-500">LM Studio Advantages</span>
|
||||||
|
</div>
|
||||||
|
<ul className="text-xs text-bolt-elements-textSecondary space-y-1 list-disc list-inside">
|
||||||
|
<li>Built-in model downloader with search</li>
|
||||||
|
<li>Easy model switching and management</li>
|
||||||
|
<li>Built-in chat interface for testing</li>
|
||||||
|
<li>GGUF format support (most compatible)</li>
|
||||||
|
<li>Regular updates with new features</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* LocalAI Setup Section */}
|
||||||
|
<Card className="bg-bolt-elements-background-depth-2 shadow-sm">
|
||||||
|
<CardHeader className="pb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/20 flex items-center justify-center ring-1 ring-green-500/30">
|
||||||
|
<Globe className="w-6 h-6 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-xl font-semibold text-bolt-elements-textPrimary">LocalAI Setup</h3>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary">
|
||||||
|
Self-hosted OpenAI-compatible API server with extensive model support
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Installation */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Installation Options
|
||||||
|
</h4>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<h5 className="font-medium text-bolt-elements-textPrimary mb-2">Quick Install</h5>
|
||||||
|
<div className="text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary space-y-1">
|
||||||
|
<div># One-line install</div>
|
||||||
|
<div>curl https://localai.io/install.sh | sh</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<h5 className="font-medium text-bolt-elements-textPrimary mb-2">Docker (Recommended)</h5>
|
||||||
|
<div className="text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary space-y-1">
|
||||||
|
<div>docker run -p 8080:8080</div>
|
||||||
|
<div>quay.io/go-skynet/local-ai:latest</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
Configuration
|
||||||
|
</h4>
|
||||||
|
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary mb-3">
|
||||||
|
LocalAI supports many model formats and provides a full OpenAI-compatible API.
|
||||||
|
</p>
|
||||||
|
<div className="text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary space-y-1">
|
||||||
|
<div># Example configuration</div>
|
||||||
|
<div>models:</div>
|
||||||
|
<div>- name: llama3.1</div>
|
||||||
|
<div>backend: llama</div>
|
||||||
|
<div>parameters:</div>
|
||||||
|
<div>model: llama3.1.gguf</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advantages */}
|
||||||
|
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="font-medium text-green-500">LocalAI Advantages</span>
|
||||||
|
</div>
|
||||||
|
<ul className="text-xs text-bolt-elements-textSecondary space-y-1 list-disc list-inside">
|
||||||
|
<li>Full OpenAI API compatibility</li>
|
||||||
|
<li>Supports multiple model formats</li>
|
||||||
|
<li>Docker deployment option</li>
|
||||||
|
<li>Built-in model gallery</li>
|
||||||
|
<li>REST API for model management</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Performance Optimization */}
|
||||||
|
<Card className="bg-gradient-to-r from-purple-500/10 to-pink-500/10 border border-purple-500/20 shadow-sm">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
||||||
|
<Activity className="w-5 h-5 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Performance Optimization</h3>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary">Tips to improve local AI performance</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium text-bolt-elements-textPrimary">Hardware Optimizations</h4>
|
||||||
|
<ul className="text-sm text-bolt-elements-textSecondary space-y-2">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Use NVIDIA GPU with CUDA for 5-10x speedup</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Increase RAM for larger context windows</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Use SSD storage for faster model loading</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Close other applications to free up RAM</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium text-bolt-elements-textPrimary">Software Optimizations</h4>
|
||||||
|
<ul className="text-sm text-bolt-elements-textSecondary space-y-2">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Use smaller models for faster responses</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Enable quantization (4-bit, 8-bit models)</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Reduce context length for chat applications</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Use streaming responses for better UX</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Alternative Options */}
|
||||||
|
<Card className="bg-bolt-elements-background-depth-2 shadow-sm">
|
||||||
|
<CardHeader className="pb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-orange-500/20 to-red-500/20 flex items-center justify-center ring-1 ring-orange-500/30">
|
||||||
|
<Wifi className="w-6 h-6 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-bolt-elements-textPrimary">Alternative Options</h3>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary">
|
||||||
|
Other local AI solutions and cloud alternatives
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium text-bolt-elements-textPrimary">Other Local Solutions</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Package className="w-4 h-4 text-blue-500" />
|
||||||
|
<span className="font-medium text-bolt-elements-textPrimary">Jan.ai</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-bolt-elements-textSecondary">
|
||||||
|
Modern interface with built-in model marketplace
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Terminal className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="font-medium text-bolt-elements-textPrimary">Oobabooga</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-bolt-elements-textSecondary">
|
||||||
|
Advanced text generation web UI with extensions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Cable className="w-4 h-4 text-purple-500" />
|
||||||
|
<span className="font-medium text-bolt-elements-textPrimary">KoboldAI</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-bolt-elements-textSecondary">Focus on creative writing and storytelling</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium text-bolt-elements-textPrimary">Cloud Alternatives</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Globe className="w-4 h-4 text-orange-500" />
|
||||||
|
<span className="font-medium text-bolt-elements-textPrimary">OpenRouter</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-bolt-elements-textSecondary">Access to 100+ models through unified API</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Server className="w-4 h-4 text-red-500" />
|
||||||
|
<span className="font-medium text-bolt-elements-textPrimary">Together AI</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-bolt-elements-textSecondary">Fast inference with open-source models</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Activity className="w-4 h-4 text-pink-500" />
|
||||||
|
<span className="font-medium text-bolt-elements-textPrimary">Groq</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-bolt-elements-textSecondary">Ultra-fast LPU inference for Llama models</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SetupGuide;
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header with Back Button */}
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onBack}
|
||||||
|
className="bg-transparent hover:bg-transparent text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-all duration-200 p-2"
|
||||||
|
aria-label="Back to Dashboard"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-bolt-elements-textPrimary">Provider Status</h2>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary">Monitor the health of your local AI providers</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{healthStatuses.length === 0 ? (
|
||||||
|
<Card className="bg-bolt-elements-background-depth-2">
|
||||||
|
<CardContent className="p-8 text-center">
|
||||||
|
<Cable className="w-16 h-16 mx-auto text-bolt-elements-textTertiary mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">No Endpoints Configured</h3>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary">
|
||||||
|
Configure and enable local providers to see their endpoint status here.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{healthStatuses.map((status) => (
|
||||||
|
<Card key={`${status.provider}-${status.baseUrl}`} className="bg-bolt-elements-background-depth-2">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-bolt-elements-background-depth-3 flex items-center justify-center">
|
||||||
|
{React.createElement(PROVIDER_ICONS[status.provider as keyof typeof PROVIDER_ICONS] || Server, {
|
||||||
|
className: 'w-5 h-5 text-bolt-elements-textPrimary',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-bolt-elements-textPrimary">{status.provider}</h3>
|
||||||
|
<p className="text-xs text-bolt-elements-textSecondary font-mono">{status.baseUrl}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HealthStatusBadge status={status.status} responseTime={status.responseTime} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-bolt-elements-textSecondary">Models</div>
|
||||||
|
<div className="text-lg font-semibold text-bolt-elements-textPrimary">
|
||||||
|
{status.availableModels?.length || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-bolt-elements-textSecondary">Version</div>
|
||||||
|
<div className="text-lg font-semibold text-bolt-elements-textPrimary">
|
||||||
|
{status.version || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-bolt-elements-textSecondary">Last Check</div>
|
||||||
|
<div className="text-lg font-semibold text-bolt-elements-textPrimary">
|
||||||
|
{status.lastChecked ? new Date(status.lastChecked).toLocaleTimeString() : 'Never'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusDashboard;
|
||||||
44
app/components/@settings/tabs/providers/local/types.ts
Normal file
44
app/components/@settings/tabs/providers/local/types.ts
Normal file
@@ -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;
|
||||||
@@ -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<ServiceStatus[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(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 (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="animate-spin i-ph:circle-notch w-8 h-8 text-purple-500" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-red-500 dark:text-red-400">
|
|
||||||
<div className="i-ph:warning w-8 h-8 mb-2" />
|
|
||||||
<p>{error}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
|
||||||
{serviceStatuses.map((service) => (
|
|
||||||
<div
|
|
||||||
key={service.provider}
|
|
||||||
className="p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{service.provider}</h3>
|
|
||||||
<div className={`flex items-center ${getStatusColor(service.status)}`}>
|
|
||||||
<div className={`${getStatusIcon(service.status)} w-5 h-5 mr-2`} />
|
|
||||||
<span className="capitalize">{service.status}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-2">{service.message}</p>
|
|
||||||
{service.incidents && service.incidents.length > 0 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">Recent Incidents:</h4>
|
|
||||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
|
||||||
{service.incidents.map((incident, index) => (
|
|
||||||
<li key={index}>{incident}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Last checked: {new Date(service.lastChecked).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<string, string>,
|
|
||||||
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<StatusCheckResult>;
|
|
||||||
}
|
|
||||||
@@ -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<ProviderName, ProviderConfig> = {
|
|
||||||
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<StatusCheckResult> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatusCheckResult> {
|
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<string, string>;
|
|
||||||
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[];
|
|
||||||
};
|
|
||||||
@@ -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<string, string>;
|
|
||||||
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<ProviderName, ProviderConfig> = {
|
|
||||||
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<ProviderName, IconType> = {
|
|
||||||
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<ServiceStatus[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
|
||||||
const [testApiKey, setTestApiKey] = useState<string>('');
|
|
||||||
const [testProvider, setTestProvider] = useState<ProviderName | ''>('');
|
|
||||||
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<ProviderName, string> = {
|
|
||||||
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<string, string>,
|
|
||||||
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<ServiceStatus> => {
|
|
||||||
const MAX_RETRIES = 2;
|
|
||||||
const RETRY_DELAY = 2000; // 2 seconds
|
|
||||||
|
|
||||||
const attemptCheck = async (attempt: number): Promise<ServiceStatus> => {
|
|
||||||
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 <BsCheckCircleFill className="w-4 h-4" />;
|
|
||||||
case 'degraded':
|
|
||||||
return <BsExclamationCircleFill className="w-4 h-4" />;
|
|
||||||
case 'down':
|
|
||||||
return <BsXCircleFill className="w-4 h-4" />;
|
|
||||||
default:
|
|
||||||
return <BsXCircleFill className="w-4 h-4" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<motion.div
|
|
||||||
className="space-y-4"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2 mt-8 mb-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
'w-8 h-8 flex items-center justify-center rounded-lg',
|
|
||||||
'bg-bolt-elements-background-depth-3',
|
|
||||||
'text-purple-500',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<TbActivityHeartbeat className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Service Status</h4>
|
|
||||||
<p className="text-sm text-bolt-elements-textSecondary">
|
|
||||||
Monitor and test the operational status of cloud LLM providers
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-bolt-elements-textSecondary">
|
|
||||||
Last updated: {lastRefresh.toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => fetchAllStatuses()}
|
|
||||||
className={classNames(
|
|
||||||
'px-3 py-1.5 rounded-lg text-sm',
|
|
||||||
'bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4',
|
|
||||||
'text-bolt-elements-textPrimary',
|
|
||||||
'transition-all duration-200',
|
|
||||||
'flex items-center gap-2',
|
|
||||||
loading ? 'opacity-50 cursor-not-allowed' : '',
|
|
||||||
)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<div className={`i-ph:arrows-clockwise w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
|
||||||
<span>{loading ? 'Refreshing...' : 'Refresh'}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Key Test Section */}
|
|
||||||
<div className="p-4 bg-bolt-elements-background-depth-2 rounded-lg">
|
|
||||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Test API Key</h5>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<select
|
|
||||||
value={testProvider}
|
|
||||||
onChange={(e) => setTestProvider(e.target.value as ProviderName)}
|
|
||||||
className={classNames(
|
|
||||||
'flex-1 px-3 py-1.5 rounded-lg text-sm max-w-[200px]',
|
|
||||||
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
|
||||||
'text-bolt-elements-textPrimary',
|
|
||||||
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<option value="">Select Provider</option>
|
|
||||||
{Object.keys(PROVIDER_STATUS_URLS).map((provider) => (
|
|
||||||
<option key={provider} value={provider}>
|
|
||||||
{provider}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={testApiKey}
|
|
||||||
onChange={(e) => 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',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
testProvider && testApiKey && testApiKeyForProvider(testProvider as ProviderName, testApiKey)
|
|
||||||
}
|
|
||||||
disabled={!testProvider || !testApiKey || testingStatus === 'testing'}
|
|
||||||
className={classNames(
|
|
||||||
'px-4 py-1.5 rounded-lg text-sm',
|
|
||||||
'bg-purple-500 hover:bg-purple-600',
|
|
||||||
'text-white',
|
|
||||||
'transition-all duration-200',
|
|
||||||
'flex items-center gap-2',
|
|
||||||
!testProvider || !testApiKey || testingStatus === 'testing' ? 'opacity-50 cursor-not-allowed' : '',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{testingStatus === 'testing' ? (
|
|
||||||
<>
|
|
||||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
|
||||||
<span>Testing...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="i-ph:key w-4 h-4" />
|
|
||||||
<span>Test Key</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Grid */}
|
|
||||||
{loading && serviceStatuses.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-bolt-elements-textSecondary">Loading service statuses...</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{serviceStatuses.map((service, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={service.provider}
|
|
||||||
className={classNames(
|
|
||||||
'bg-bolt-elements-background-depth-2',
|
|
||||||
'hover:bg-bolt-elements-background-depth-3',
|
|
||||||
'transition-all duration-200',
|
|
||||||
'relative overflow-hidden rounded-lg',
|
|
||||||
)}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: index * 0.1 }}
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames('block p-4', service.statusUrl ? 'cursor-pointer' : '')}
|
|
||||||
onClick={() => service.statusUrl && window.open(service.statusUrl, '_blank')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{service.icon && (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
'w-8 h-8 flex items-center justify-center rounded-lg',
|
|
||||||
'bg-bolt-elements-background-depth-3',
|
|
||||||
getStatusColor(service.status),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{React.createElement(service.icon, {
|
|
||||||
className: 'w-5 h-5',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{service.provider}</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs text-bolt-elements-textSecondary">
|
|
||||||
Last checked: {new Date(service.lastChecked).toLocaleTimeString()}
|
|
||||||
</p>
|
|
||||||
{service.responseTime && (
|
|
||||||
<p className="text-xs text-bolt-elements-textTertiary">
|
|
||||||
Response time: {Math.round(service.responseTime)}ms
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{service.message && (
|
|
||||||
<p className="text-xs text-bolt-elements-textTertiary">{service.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={classNames('flex items-center gap-2', getStatusColor(service.status))}>
|
|
||||||
<span className="text-sm capitalize">{service.status}</span>
|
|
||||||
{getStatusIcon(service.status)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{service.incidents && service.incidents.length > 0 && (
|
|
||||||
<div className="mt-2 border-t border-bolt-elements-borderColor pt-2">
|
|
||||||
<p className="text-xs font-medium text-bolt-elements-textSecondary mb-1">Recent Incidents:</p>
|
|
||||||
<ul className="text-xs text-bolt-elements-textTertiary space-y-1">
|
|
||||||
{service.incidents.map((incident, i) => (
|
|
||||||
<li key={i}>{incident}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add tab metadata
|
|
||||||
ServiceStatusTab.tabMetadata = {
|
|
||||||
icon: 'i-ph:activity-bold',
|
|
||||||
description: 'Monitor and test LLM provider service status',
|
|
||||||
category: 'services',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ServiceStatusTab;
|
|
||||||
@@ -119,7 +119,7 @@ export const ChatBox: React.FC<ChatBoxProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
{(props.providerList || []).length > 0 &&
|
{(props.providerList || []).length > 0 &&
|
||||||
props.provider &&
|
props.provider &&
|
||||||
(!LOCAL_PROVIDERS.includes(props.provider.name) || 'OpenAILike') && (
|
!LOCAL_PROVIDERS.includes(props.provider.name) && (
|
||||||
<APIKeyManager
|
<APIKeyManager
|
||||||
provider={props.provider}
|
provider={props.provider}
|
||||||
apiKey={props.apiKeys[props.provider.name] || ''}
|
apiKey={props.apiKeys[props.provider.name] || ''}
|
||||||
|
|||||||
165
app/lib/hooks/useLocalModelHealth.ts
Normal file
165
app/lib/hooks/useLocalModelHealth.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { localModelHealthMonitor, type ModelHealthStatus } from '~/lib/services/localModelHealthMonitor';
|
||||||
|
|
||||||
|
export interface UseLocalModelHealthOptions {
|
||||||
|
autoStart?: boolean;
|
||||||
|
checkInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseLocalModelHealthReturn {
|
||||||
|
healthStatuses: ModelHealthStatus[];
|
||||||
|
getHealthStatus: (provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => ModelHealthStatus | undefined;
|
||||||
|
startMonitoring: (provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string, checkInterval?: number) => void;
|
||||||
|
stopMonitoring: (provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => void;
|
||||||
|
performHealthCheck: (provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => Promise<void>;
|
||||||
|
isHealthy: (provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => boolean;
|
||||||
|
getOverallHealth: () => { healthy: number; unhealthy: number; checking: number; unknown: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook for monitoring local model health
|
||||||
|
*/
|
||||||
|
export function useLocalModelHealth(options: UseLocalModelHealthOptions = {}): UseLocalModelHealthReturn {
|
||||||
|
const { checkInterval } = options;
|
||||||
|
const [healthStatuses, setHealthStatuses] = useState<ModelHealthStatus[]>([]);
|
||||||
|
|
||||||
|
// Update health statuses when they change
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStatusChanged = (status: ModelHealthStatus) => {
|
||||||
|
setHealthStatuses((current) => {
|
||||||
|
const index = current.findIndex((s) => s.provider === status.provider && s.baseUrl === status.baseUrl);
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
const updated = [...current];
|
||||||
|
updated[index] = status;
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
} else {
|
||||||
|
return [...current, status];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
localModelHealthMonitor.on('statusChanged', handleStatusChanged);
|
||||||
|
|
||||||
|
// Initialize with current statuses
|
||||||
|
setHealthStatuses(localModelHealthMonitor.getAllHealthStatuses());
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
localModelHealthMonitor.off('statusChanged', handleStatusChanged);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get health status for a specific provider
|
||||||
|
const getHealthStatus = useCallback((provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => {
|
||||||
|
return localModelHealthMonitor.getHealthStatus(provider, baseUrl);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Start monitoring a provider
|
||||||
|
const startMonitoring = useCallback(
|
||||||
|
(provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string, interval?: number) => {
|
||||||
|
console.log(`[Health Monitor] Starting monitoring for ${provider} at ${baseUrl}`);
|
||||||
|
localModelHealthMonitor.startMonitoring(provider, baseUrl, interval || checkInterval);
|
||||||
|
},
|
||||||
|
[checkInterval],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop monitoring a provider
|
||||||
|
const stopMonitoring = useCallback((provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => {
|
||||||
|
console.log(`[Health Monitor] Stopping monitoring for ${provider} at ${baseUrl}`);
|
||||||
|
localModelHealthMonitor.stopMonitoring(provider, baseUrl);
|
||||||
|
|
||||||
|
// Remove from local state
|
||||||
|
setHealthStatuses((current) => current.filter((s) => !(s.provider === provider && s.baseUrl === baseUrl)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Perform manual health check
|
||||||
|
const performHealthCheck = useCallback(async (provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => {
|
||||||
|
await localModelHealthMonitor.performHealthCheck(provider, baseUrl);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check if a provider is healthy
|
||||||
|
const isHealthy = useCallback(
|
||||||
|
(provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string) => {
|
||||||
|
const status = getHealthStatus(provider, baseUrl);
|
||||||
|
return status?.status === 'healthy';
|
||||||
|
},
|
||||||
|
[getHealthStatus],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get overall health statistics
|
||||||
|
const getOverallHealth = useCallback(() => {
|
||||||
|
const stats = { healthy: 0, unhealthy: 0, checking: 0, unknown: 0 };
|
||||||
|
|
||||||
|
healthStatuses.forEach((status) => {
|
||||||
|
stats[status.status]++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}, [healthStatuses]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
healthStatuses,
|
||||||
|
getHealthStatus,
|
||||||
|
startMonitoring,
|
||||||
|
stopMonitoring,
|
||||||
|
performHealthCheck,
|
||||||
|
isHealthy,
|
||||||
|
getOverallHealth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for monitoring a specific provider
|
||||||
|
*/
|
||||||
|
export function useProviderHealth(
|
||||||
|
provider: 'Ollama' | 'LMStudio' | 'OpenAILike',
|
||||||
|
baseUrl: string,
|
||||||
|
options: UseLocalModelHealthOptions = {},
|
||||||
|
) {
|
||||||
|
const { autoStart = true, checkInterval } = options;
|
||||||
|
const { getHealthStatus, startMonitoring, stopMonitoring, performHealthCheck, isHealthy } = useLocalModelHealth();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<ModelHealthStatus | undefined>();
|
||||||
|
|
||||||
|
// Update status when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const updateStatus = () => {
|
||||||
|
setStatus(getHealthStatus(provider, baseUrl));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChanged = (changedStatus: ModelHealthStatus) => {
|
||||||
|
if (changedStatus.provider === provider && changedStatus.baseUrl === baseUrl) {
|
||||||
|
setStatus(changedStatus);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
localModelHealthMonitor.on('statusChanged', handleStatusChanged);
|
||||||
|
updateStatus();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
localModelHealthMonitor.off('statusChanged', handleStatusChanged);
|
||||||
|
};
|
||||||
|
}, [provider, baseUrl, getHealthStatus]);
|
||||||
|
|
||||||
|
// Auto-start monitoring if enabled
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoStart && baseUrl) {
|
||||||
|
startMonitoring(provider, baseUrl, checkInterval);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopMonitoring(provider, baseUrl);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}, [autoStart, provider, baseUrl, checkInterval, startMonitoring, stopMonitoring]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
isHealthy: isHealthy(provider, baseUrl),
|
||||||
|
performHealthCheck: () => performHealthCheck(provider, baseUrl),
|
||||||
|
startMonitoring: (interval?: number) => startMonitoring(provider, baseUrl, interval),
|
||||||
|
stopMonitoring: () => stopMonitoring(provider, baseUrl),
|
||||||
|
};
|
||||||
|
}
|
||||||
389
app/lib/services/localModelHealthMonitor.ts
Normal file
389
app/lib/services/localModelHealthMonitor.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
// Simple EventEmitter implementation for browser compatibility
|
||||||
|
class SimpleEventEmitter {
|
||||||
|
private _events: Record<string, ((...args: any[]) => void)[]> = {};
|
||||||
|
|
||||||
|
on(event: string, listener: (...args: any[]) => void): void {
|
||||||
|
if (!this._events[event]) {
|
||||||
|
this._events[event] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this._events[event].push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: string, listener: (...args: any[]) => void): void {
|
||||||
|
if (!this._events[event]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._events[event] = this._events[event].filter((l) => l !== listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, ...args: any[]): void {
|
||||||
|
if (!this._events[event]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._events[event].forEach((listener) => listener(...args));
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllListeners(): void {
|
||||||
|
this._events = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelHealthStatus {
|
||||||
|
provider: 'Ollama' | 'LMStudio' | 'OpenAILike';
|
||||||
|
baseUrl: string;
|
||||||
|
status: 'healthy' | 'unhealthy' | 'checking' | 'unknown';
|
||||||
|
lastChecked: Date;
|
||||||
|
responseTime?: number;
|
||||||
|
error?: string;
|
||||||
|
availableModels?: string[];
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthCheckResult {
|
||||||
|
isHealthy: boolean;
|
||||||
|
responseTime: number;
|
||||||
|
error?: string;
|
||||||
|
availableModels?: string[];
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LocalModelHealthMonitor extends SimpleEventEmitter {
|
||||||
|
private _healthStatuses = new Map<string, ModelHealthStatus>();
|
||||||
|
private _checkIntervals = new Map<string, NodeJS.Timeout>();
|
||||||
|
private readonly _defaultCheckInterval = 30000; // 30 seconds
|
||||||
|
private readonly _healthCheckTimeout = 10000; // 10 seconds
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start monitoring a local provider
|
||||||
|
*/
|
||||||
|
startMonitoring(provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string, checkInterval?: number): void {
|
||||||
|
const key = this._getProviderKey(provider, baseUrl);
|
||||||
|
|
||||||
|
// Stop existing monitoring if any
|
||||||
|
this.stopMonitoring(provider, baseUrl);
|
||||||
|
|
||||||
|
// Initialize status
|
||||||
|
this._healthStatuses.set(key, {
|
||||||
|
provider,
|
||||||
|
baseUrl,
|
||||||
|
status: 'unknown',
|
||||||
|
lastChecked: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start periodic health checks
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
await this.performHealthCheck(provider, baseUrl);
|
||||||
|
}, checkInterval || this._defaultCheckInterval);
|
||||||
|
|
||||||
|
this._checkIntervals.set(key, interval);
|
||||||
|
|
||||||
|
// Perform initial health check
|
||||||
|
this.performHealthCheck(provider, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop monitoring a local provider
|
||||||
|
*/
|
||||||
|
stopMonitoring(provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string): void {
|
||||||
|
const key = this._getProviderKey(provider, baseUrl);
|
||||||
|
|
||||||
|
const interval = this._checkIntervals.get(key);
|
||||||
|
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
this._checkIntervals.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._healthStatuses.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current health status for a provider
|
||||||
|
*/
|
||||||
|
getHealthStatus(provider: 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl: string): ModelHealthStatus | undefined {
|
||||||
|
const key = this._getProviderKey(provider, baseUrl);
|
||||||
|
return this._healthStatuses.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all health statuses
|
||||||
|
*/
|
||||||
|
getAllHealthStatuses(): ModelHealthStatus[] {
|
||||||
|
return Array.from(this._healthStatuses.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a manual health check
|
||||||
|
*/
|
||||||
|
async performHealthCheck(
|
||||||
|
provider: 'Ollama' | 'LMStudio' | 'OpenAILike',
|
||||||
|
baseUrl: string,
|
||||||
|
): Promise<HealthCheckResult> {
|
||||||
|
const key = this._getProviderKey(provider, baseUrl);
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Update status to checking
|
||||||
|
const currentStatus = this._healthStatuses.get(key);
|
||||||
|
|
||||||
|
if (currentStatus) {
|
||||||
|
currentStatus.status = 'checking';
|
||||||
|
currentStatus.lastChecked = new Date();
|
||||||
|
this.emit('statusChanged', currentStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this._checkProviderHealth(provider, baseUrl);
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Update health status
|
||||||
|
const healthStatus: ModelHealthStatus = {
|
||||||
|
provider,
|
||||||
|
baseUrl,
|
||||||
|
status: result.isHealthy ? 'healthy' : 'unhealthy',
|
||||||
|
lastChecked: new Date(),
|
||||||
|
responseTime,
|
||||||
|
error: result.error,
|
||||||
|
availableModels: result.availableModels,
|
||||||
|
version: result.version,
|
||||||
|
};
|
||||||
|
|
||||||
|
this._healthStatuses.set(key, healthStatus);
|
||||||
|
this.emit('statusChanged', healthStatus);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isHealthy: result.isHealthy,
|
||||||
|
responseTime,
|
||||||
|
error: result.error,
|
||||||
|
availableModels: result.availableModels,
|
||||||
|
version: result.version,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
|
const healthStatus: ModelHealthStatus = {
|
||||||
|
provider,
|
||||||
|
baseUrl,
|
||||||
|
status: 'unhealthy',
|
||||||
|
lastChecked: new Date(),
|
||||||
|
responseTime,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
this._healthStatuses.set(key, healthStatus);
|
||||||
|
this.emit('statusChanged', healthStatus);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isHealthy: false,
|
||||||
|
responseTime,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check health of a specific provider
|
||||||
|
*/
|
||||||
|
private async _checkProviderHealth(
|
||||||
|
provider: 'Ollama' | 'LMStudio' | 'OpenAILike',
|
||||||
|
baseUrl: string,
|
||||||
|
): Promise<HealthCheckResult> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this._healthCheckTimeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (provider) {
|
||||||
|
case 'Ollama':
|
||||||
|
return await this._checkOllamaHealth(baseUrl, controller.signal);
|
||||||
|
case 'LMStudio':
|
||||||
|
return await this._checkLMStudioHealth(baseUrl, controller.signal);
|
||||||
|
case 'OpenAILike':
|
||||||
|
return await this._checkOpenAILikeHealth(baseUrl, controller.signal);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported provider: ${provider}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Ollama health
|
||||||
|
*/
|
||||||
|
private async _checkOllamaHealth(baseUrl: string, signal: AbortSignal): Promise<HealthCheckResult> {
|
||||||
|
try {
|
||||||
|
console.log(`[Health Check] Checking Ollama at ${baseUrl}`);
|
||||||
|
|
||||||
|
// Check if Ollama is running
|
||||||
|
const response = await fetch(`${baseUrl}/api/tags`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { models?: Array<{ name: string }> };
|
||||||
|
const models = data.models?.map((model) => model.name) || [];
|
||||||
|
|
||||||
|
console.log(`[Health Check] Ollama healthy with ${models.length} models`);
|
||||||
|
|
||||||
|
// Try to get version info
|
||||||
|
let version: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const versionResponse = await fetch(`${baseUrl}/api/version`, { signal });
|
||||||
|
|
||||||
|
if (versionResponse.ok) {
|
||||||
|
const versionData = (await versionResponse.json()) as { version?: string };
|
||||||
|
version = versionData.version;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Version endpoint might not be available in older versions
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isHealthy: true,
|
||||||
|
responseTime: 0, // Will be calculated by caller
|
||||||
|
availableModels: models,
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Health Check] Ollama health check failed:`, error);
|
||||||
|
return {
|
||||||
|
isHealthy: false,
|
||||||
|
responseTime: 0,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check LM Studio health
|
||||||
|
*/
|
||||||
|
private async _checkLMStudioHealth(baseUrl: string, signal: AbortSignal): Promise<HealthCheckResult> {
|
||||||
|
try {
|
||||||
|
// Normalize URL to ensure /v1 prefix
|
||||||
|
const normalizedUrl = baseUrl.includes('/v1') ? baseUrl : `${baseUrl}/v1`;
|
||||||
|
|
||||||
|
const response = await fetch(`${normalizedUrl}/models`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Check if this is a CORS error
|
||||||
|
if (response.type === 'opaque' || response.status === 0) {
|
||||||
|
throw new Error(
|
||||||
|
'CORS_ERROR: LM Studio server is not configured to allow requests from this origin. Please configure CORS in LM Studio settings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { data?: Array<{ id: string }> };
|
||||||
|
const models = data.data?.map((model) => model.id) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
isHealthy: true,
|
||||||
|
responseTime: 0,
|
||||||
|
availableModels: models,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
|
// Check if this is a CORS error
|
||||||
|
if (
|
||||||
|
errorMessage.includes('CORS') ||
|
||||||
|
errorMessage.includes('NetworkError') ||
|
||||||
|
errorMessage.includes('Failed to fetch')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
isHealthy: false,
|
||||||
|
responseTime: 0,
|
||||||
|
error:
|
||||||
|
'CORS_ERROR: LM Studio server is blocking cross-origin requests. Try enabling CORS in LM Studio settings or use Bolt desktop app.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isHealthy: false,
|
||||||
|
responseTime: 0,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check OpenAI-like provider health
|
||||||
|
*/
|
||||||
|
private async _checkOpenAILikeHealth(baseUrl: string, signal: AbortSignal): Promise<HealthCheckResult> {
|
||||||
|
try {
|
||||||
|
// Normalize URL to include /v1 if needed
|
||||||
|
const normalizedUrl = baseUrl.includes('/v1') ? baseUrl : `${baseUrl}/v1`;
|
||||||
|
|
||||||
|
const response = await fetch(`${normalizedUrl}/models`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { data?: Array<{ id: string }> };
|
||||||
|
const models = data.data?.map((model) => model.id) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
isHealthy: true,
|
||||||
|
responseTime: 0,
|
||||||
|
availableModels: models,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
isHealthy: false,
|
||||||
|
responseTime: 0,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique key for a provider
|
||||||
|
*/
|
||||||
|
private _getProviderKey(provider: string, baseUrl: string): string {
|
||||||
|
return `${provider}:${baseUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all monitoring
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
// Clear all intervals
|
||||||
|
for (const interval of this._checkIntervals.values()) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._checkIntervals.clear();
|
||||||
|
this._healthStatuses.clear();
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const localModelHealthMonitor = new LocalModelHealthMonitor();
|
||||||
Reference in New Issue
Block a user