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:
Stijnus
2025-09-06 19:03:25 +02:00
committed by GitHub
parent 3ea96506ea
commit a44de8addc
37 changed files with 2794 additions and 3706 deletions

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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;

View File

@@ -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>
);
}

View 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;

View 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;

View File

@@ -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;

View 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;