feat(ollama): enhance model pull with improved error handling, persistence, and UI

- Add catalog metadata lookup for better error messages with hints (e.g., for missing manifests or tags)
- Persist downloaded model references in saved remote models for future reuse
- Replace searchRemoteModels import with getSavedRemoteModels in search route
- Enhance pull progress UI to display download bytes, digest, and fallback message when data is unavailable
- Improve error payload with model details, URL, and tags count to guide users toward valid tags or models
This commit is contained in:
Evandro Fonseca Junior committed 2025-10-29 19:42:24 -03:00
1 parent 1545020afe
commit a8bb7cc22d
33 files changed
+1298 -235

No files matched your search

+198 -45
View File
@@ -27,22 +27,113 @@ export function ModelPullDialog({
const [isPulling, setIsPulling] = useState(false);
const [progress, setProgress] = useState<PullProgress | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSuccess, setIsSuccess] = useState<boolean>(false);
const [editableName, setEditableName] = useState<string>(modelName);
const [tags, setTags] = useState<string[]>([]);
const [isLoadingTags, setIsLoadingTags] = useState<boolean>(false);
useEffect(() => {
if (!isOpen) {
setIsPulling(false);
setProgress(null);
setError(null);
setIsSuccess(false);
setEditableName(modelName);
setTags([]);
setIsLoadingTags(false);
return;
}
}, [isOpen]);
// Ao abrir, tentar buscar tags conhecidas
const base = String(modelName).split(":")[0];
setEditableName(modelName);
if (!base) {
setTags([]);
setIsLoadingTags(false);
return;
}
setIsLoadingTags(true);
fetch(`/api/ollama/tags?name=${encodeURIComponent(base)}`)
.then((res) => res.json())
.then((data) => {
if (data?.success && Array.isArray(data.tags)) {
setTags(data.tags);
} else {
setTags([]);
}
})
.catch(() => setTags([]))
.finally(() => setIsLoadingTags(false));
}, [isOpen, modelName]);
const handleConfirm = async () => {
setIsPulling(true);
setError(null);
setProgress(null);
try {
await onConfirmPull(modelName);
onClose();
const response = await fetch("/api/ollama/pull", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: editableName.trim() }),
});
if (!response.ok) {
throw new Error("Falha ao iniciar download do modelo");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Resposta inválida do servidor");
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
// Processar diferentes estados de progresso
if (data.status === "downloading" || data.status === "pulling") {
setProgress({
status: data.status,
total: data.total,
completed: data.completed,
digest: data.digest,
});
} else if (data.status === "success") {
setProgress((prev) => ({
status: "success",
total: prev?.total,
completed: prev?.total
}));
setIsSuccess(true);
} else if (data.error) {
setError(data.detail || data.error);
} else if (data.total || data.completed) {
// Caso o status não seja "downloading" mas tenha dados de progresso
setProgress({
status: data.status || "downloading",
total: data.total,
completed: data.completed,
digest: data.digest,
});
}
} catch (e) {
// ignorar linhas inválidas
}
}
}
// Não fechar automaticamente; permitir usuário fechar ao concluir
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to pull model");
} finally {
@@ -50,6 +141,18 @@ export function ModelPullDialog({
}
};
const formatBytes = (n?: number) => {
if (!n || n <= 0) return "0 MB";
const mb = n / 1024 / 1024;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
const gb = mb / 1024;
return `${gb.toFixed(2)} GB`;
};
const percent = progress?.total && progress?.completed
? Math.max(0, Math.min(100, Math.round((progress.completed / progress.total) * 100)))
: 0;
if (!isOpen) return null;
return (
@@ -67,42 +170,90 @@ export function ModelPullDialog({
</button>
</div>
<div className="mb-4">
<p className="text-sm text-[var(--foreground)]/80 mb-2">
Install <span className="font-medium">{modelName}</span>?
</p>
<div className="mb-4 space-y-2">
<div>
<p className="text-sm text-[var(--foreground)]/80 mb-2">
Confirme o identificador do modelo (nome[:tag]):
</p>
<input
type="text"
value={editableName}
onChange={(e) => setEditableName(e.target.value)}
disabled={isPulling}
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
placeholder="ex.: llama3:8b-instruct"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<p className="text-xs text-[var(--foreground)]/60">Tags disponíveis</p>
{isLoadingTags && (
<span className="text-xs text-[var(--foreground)]/50">Carregando…</span>
)}
</div>
{tags.length > 0 ? (
<select
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm"
onChange={(e) => {
const base = String(modelName).split(":")[0];
const tag = e.target.value;
setEditableName(`${base}:${tag}`);
}}
defaultValue=""
disabled={isPulling}
>
<option value="" disabled>
Selecione uma tag (opcional)
</option>
{tags.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
) : (
<p className="text-xs text-[var(--foreground)]/50">
Nenhuma tag detectada automaticamente. Você pode digitar manualmente.
</p>
)}
</div>
<p className="text-xs text-[var(--foreground)]/60">
This will download the model to your local Ollama installation.
Isso fará o download do modelo na sua instalação local do Ollama.
</p>
</div>
{progress && (
<div className="mb-4 rounded-lg bg-[var(--surface)] p-3">
<div className="flex items-center justify-between text-sm mb-2">
<span>Downloading...</span>
<span>
{progress.total && progress.completed
? `${Math.round(
(progress.completed / progress.total) * 100,
)}%`
: "0%"}
<span className="flex items-center gap-2">
{!isSuccess && <Loader2 className="h-4 w-4 animate-spin" />}
{isSuccess ? "Download concluído" : "Baixando modelo..."}
</span>
{progress.total && progress.completed && <span>{percent}%</span>}
</div>
<div className="w-full bg-[var(--border)] rounded-full h-2">
<div className="w-full bg-[var(--border)] rounded-full h-2 overflow-hidden">
<div
className="bg-[var(--accent)] h-2 rounded-full transition-all duration-300"
style={{
width:
progress.total && progress.completed
? `${(progress.completed / progress.total) * 100}%`
: "0%",
}}
className={`h-2 transition-all duration-300 ${
isSuccess ? "bg-green-500" : "bg-[var(--accent)]"
}`}
style={{ width: `${percent}%` }}
/>
</div>
{progress.digest && (
<p className="text-xs text-[var(--foreground)]/60 mt-1">
{progress.digest.slice(0, 12)}...
</p>
{progress.total && progress.completed ? (
<div className="mt-2 flex items-center justify-between text-xs text-[var(--foreground)]/60">
<span>
{formatBytes(progress.completed)} / {formatBytes(progress.total)}
</span>
{progress?.digest && (
<span>sha256: {progress.digest.slice(0, 12)}...</span>
)}
</div>
) : (
<div className="mt-2 text-xs text-[var(--foreground)]/60">
Aguardando informações de progresso...
</div>
)}
</div>
)}
@@ -119,25 +270,27 @@ export function ModelPullDialog({
disabled={isPulling}
className="px-4 py-2 text-sm rounded-md border border-[var(--border)] transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={isPulling}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
>
{isPulling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Installing...
</>
) : (
<>
<Download className="h-4 w-4" />
Install
</>
)}
{isSuccess ? "Fechar" : "Cancelar"}
</button>
{!isSuccess && (
<button
onClick={handleConfirm}
disabled={isPulling}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
>
{isPulling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Installing...
</>
) : (
<>
<Download className="h-4 w-4" />
Install
</>
)}
</button>
)}
</div>
</div>
</div>