bolt.diy/app/components/settings/providers/OllamaModelUpdater.tsx
2025-01-22 14:34:30 +01:00

306 lines
9.4 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import { settingsStyles } from '~/components/settings/settings.styles';
import { DialogTitle, DialogDescription } from '~/components/ui/Dialog';
interface OllamaModel {
name: string;
digest: string;
size: number;
modified_at: string;
details?: {
family: string;
parameter_size: string;
quantization_level: string;
};
status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking';
error?: string;
newDigest?: string;
progress?: {
current: number;
total: number;
status: string;
};
}
interface OllamaTagResponse {
models: Array<{
name: string;
digest: string;
size: number;
modified_at: string;
details?: {
family: string;
parameter_size: string;
quantization_level: string;
};
}>;
}
interface OllamaPullResponse {
status: string;
digest?: string;
total?: number;
completed?: number;
}
export default function OllamaModelUpdater() {
const [models, setModels] = useState<OllamaModel[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isBulkUpdating, setIsBulkUpdating] = useState(false);
useEffect(() => {
fetchModels();
}, []);
const fetchModels = async () => {
try {
setIsLoading(true);
const response = await fetch('http://localhost:11434/api/tags');
const data = (await response.json()) as OllamaTagResponse;
setModels(
data.models.map((model) => ({
name: model.name,
digest: model.digest,
size: model.size,
modified_at: model.modified_at,
details: model.details,
status: 'idle' as const,
})),
);
} catch (error) {
toast.error('Failed to fetch Ollama models');
console.error('Error fetching models:', error);
} finally {
setIsLoading(false);
}
};
const updateModel = async (modelName: string): Promise<{ success: boolean; newDigest?: string }> => {
try {
const response = await fetch('http://localhost:11434/api/pull', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: modelName }),
});
if (!response.ok) {
throw new Error(`Failed to update ${modelName}`);
}
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) {
const data = JSON.parse(line) as OllamaPullResponse;
setModels((current) =>
current.map((m) =>
m.name === modelName
? {
...m,
progress: {
current: data.completed || 0,
total: data.total || 0,
status: data.status,
},
newDigest: data.digest,
}
: m,
),
);
}
}
setModels((current) => current.map((m) => (m.name === modelName ? { ...m, status: 'checking' } : m)));
const updatedResponse = await fetch('http://localhost:11434/api/tags');
const data = (await updatedResponse.json()) as OllamaTagResponse;
const updatedModel = data.models.find((m) => m.name === modelName);
return { success: true, newDigest: updatedModel?.digest };
} catch (error) {
console.error(`Error updating ${modelName}:`, error);
return { success: false };
}
};
const handleBulkUpdate = async () => {
setIsBulkUpdating(true);
for (const model of models) {
setModels((current) => current.map((m) => (m.name === model.name ? { ...m, status: 'updating' } : m)));
const { success, newDigest } = await updateModel(model.name);
setModels((current) =>
current.map((m) =>
m.name === model.name
? {
...m,
status: success ? 'updated' : 'error',
error: success ? undefined : 'Update failed',
newDigest,
}
: m,
),
);
}
setIsBulkUpdating(false);
toast.success('Bulk update completed');
};
const handleSingleUpdate = async (modelName: string) => {
setModels((current) => current.map((m) => (m.name === modelName ? { ...m, status: 'updating' } : m)));
const { success, newDigest } = await updateModel(modelName);
setModels((current) =>
current.map((m) =>
m.name === modelName
? {
...m,
status: success ? 'updated' : 'error',
error: success ? undefined : 'Update failed',
newDigest,
}
: m,
),
);
if (success) {
toast.success(`Updated ${modelName}`);
} else {
toast.error(`Failed to update ${modelName}`);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-4">
<div className={settingsStyles['loading-spinner']} />
<span className="ml-2 text-bolt-elements-textSecondary">Loading models...</span>
</div>
);
}
return (
<div className="space-y-4">
<div className="space-y-2">
<DialogTitle>Ollama Model Manager</DialogTitle>
<DialogDescription>Update your local Ollama models to their latest versions</DialogDescription>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="i-ph:arrows-clockwise text-purple-500" />
<span className="text-sm text-bolt-elements-textPrimary">{models.length} models available</span>
</div>
<motion.button
onClick={handleBulkUpdate}
disabled={isBulkUpdating}
className={classNames(
settingsStyles.button.base,
settingsStyles.button.primary,
'hover:bg-purple-500/10 hover:text-purple-500',
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isBulkUpdating ? (
<>
<div className={settingsStyles['loading-spinner']} />
Updating All...
</>
) : (
<>
<div className="i-ph:arrows-clockwise" />
Update All Models
</>
)}
</motion.button>
</div>
<div className="space-y-2">
{models.map((model) => (
<div
key={model.name}
className={classNames(
'flex items-center justify-between p-3 rounded-lg',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
)}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className="i-ph:cube text-purple-500" />
<span className="text-sm text-bolt-elements-textPrimary">{model.name}</span>
{model.status === 'updating' && <div className={settingsStyles['loading-spinner']} />}
{model.status === 'updated' && <div className="i-ph:check-circle text-green-500" />}
{model.status === 'error' && <div className="i-ph:x-circle text-red-500" />}
</div>
<div className="flex items-center gap-2 text-xs text-bolt-elements-textSecondary">
<span>Version: {model.digest.substring(0, 7)}</span>
{model.status === 'updated' && model.newDigest && (
<>
<div className="i-ph:arrow-right w-3 h-3" />
<span className="text-green-500">{model.newDigest.substring(0, 7)}</span>
</>
)}
{model.progress && (
<span className="ml-2">
{model.progress.status}{' '}
{model.progress.total > 0 && (
<>({Math.round((model.progress.current / model.progress.total) * 100)}%)</>
)}
</span>
)}
{model.details && (
<span className="ml-2">
({model.details.parameter_size}, {model.details.quantization_level})
</span>
)}
</div>
</div>
<motion.button
onClick={() => handleSingleUpdate(model.name)}
disabled={model.status === 'updating'}
className={classNames(
settingsStyles.button.base,
settingsStyles.button.secondary,
'hover:bg-purple-500/10 hover:text-purple-500',
'dark:bg-[#1A1A1A] dark:hover:bg-purple-500/20 dark:text-bolt-elements-textPrimary dark:hover:text-purple-500',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:arrows-clockwise" />
Update
</motion.button>
</div>
))}
</div>
</div>
);
}