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:
1 parent
1545020afe
commit
a8bb7cc22d
33 files changed
+1298
-235
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user