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
@@ -1,5 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { pullModel, ensureOllamaAvailable } from "@/lib/ollama";
|
||||
import { getCatalogModels } from "@/lib/catalog";
|
||||
import { addSavedRemoteModel } from "@/lib/user-remote-models";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -26,6 +28,11 @@ export async function POST(req: NextRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Buscar metadados do catálogo para melhorar mensagens de erro
|
||||
const baseName = String(model).split(":")[0];
|
||||
const catalog = await getCatalogModels();
|
||||
const catalogEntry = catalog.find((m) => m.name === baseName);
|
||||
|
||||
const response = await pullModel(model);
|
||||
|
||||
for await (const chunk of response) {
|
||||
@@ -34,18 +41,59 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Persistir referência do modelo baixado para reutilização futura
|
||||
try {
|
||||
await addSavedRemoteModel({ name: String(model), installed: true });
|
||||
} catch {}
|
||||
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({ status: "success" }) + "\n",
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Pull streaming error:", error);
|
||||
const message = typeof error?.message === "string" ? error.message : "Failed to pull model";
|
||||
const baseName = String(model).split(":")[0];
|
||||
let hint: string | undefined;
|
||||
|
||||
// Recarregar entrada de catálogo para enriquecer erro
|
||||
let catalogEntry: { url?: string; tags_count?: number } | undefined;
|
||||
try {
|
||||
const catalog = await getCatalogModels();
|
||||
catalogEntry = catalog.find((m) => m.name === baseName);
|
||||
} catch {}
|
||||
|
||||
// Heurísticas: manifest não encontrado → provavelmente falta tag válida
|
||||
if (/manifest/i.test(message) || /does not exist/i.test(message)) {
|
||||
hint = "Modelo/tag não encontrado no registro. Selecione uma tag válida ou verifique a página do modelo.";
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
error: "Failed to pull model",
|
||||
detail: message,
|
||||
model,
|
||||
name: baseName,
|
||||
};
|
||||
|
||||
// Anexar URL do catálogo quando disponível
|
||||
if (catalogEntry?.url) {
|
||||
payload.url = catalogEntry.url;
|
||||
}
|
||||
|
||||
// Se usuário não especificou tag (sem ":") e sabemos que há múltiplas tags
|
||||
if (!String(model).includes(":") && typeof catalogEntry?.tags_count === "number") {
|
||||
payload.tags_count = catalogEntry.tags_count;
|
||||
if (!hint && catalogEntry.tags_count > 0) {
|
||||
hint = "Este modelo pode não ter a tag 'latest'. Escolha uma tag específica.";
|
||||
}
|
||||
}
|
||||
|
||||
if (hint) payload.hint = hint;
|
||||
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({ error: "Failed to pull model" }) + "\n",
|
||||
),
|
||||
new TextEncoder().encode(JSON.stringify(payload) + "\n"),
|
||||
);
|
||||
controller.close();
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { searchRemoteModels } from "@/lib/ollama";
|
||||
import { getSavedRemoteModels } from "@/lib/user-remote-models";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get("q") || undefined;
|
||||
|
||||
const remoteModels = await searchRemoteModels(query);
|
||||
const saved = await getSavedRemoteModels();
|
||||
const q = (query || "").trim().toLowerCase();
|
||||
const filtered = q
|
||||
? saved.filter((m) => m.name.toLowerCase().includes(q))
|
||||
: saved;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
models: remoteModels,
|
||||
});
|
||||
const models = filtered.map((m) => ({
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
url: m.url,
|
||||
tags_count: m.tags_count,
|
||||
installed: m.installed === true,
|
||||
remote: true,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ success: true, models });
|
||||
} catch (error) {
|
||||
console.error("Error searching remote models:", error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCatalogModels } from "@/lib/catalog";
|
||||
|
||||
// Cache simples em memória para evitar múltiplos scrapes por nome
|
||||
const TAGS_CACHE = new Map<string, { tags: string[]; expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h
|
||||
|
||||
async function fetchTagsFromPage(url: string): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) return [];
|
||||
const html = await res.text();
|
||||
|
||||
// Heurística best-effort: procurar padrões de tag na página
|
||||
// Exemplos possíveis: ":7b", ":8b", ":q4_K_S", etc.
|
||||
// Evitar listas enormes ou falsas-positivas: limitar tamanho e caracteres
|
||||
const tagRegex = /:\s*([A-Za-z0-9._-]{1,32})/g;
|
||||
const found = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = tagRegex.exec(html)) !== null) {
|
||||
const tag = match[1];
|
||||
if (tag && !/^(http|https)$/i.test(tag)) {
|
||||
found.add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
// Retornar até 50 tags para não sobrecarregar UI
|
||||
return Array.from(found).slice(0, 50);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const name = (searchParams.get("name") || "").trim();
|
||||
if (!name) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Cache
|
||||
const cached = TAGS_CACHE.get(name);
|
||||
const now = Date.now();
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return NextResponse.json({ success: true, name, tags: cached.tags });
|
||||
}
|
||||
|
||||
const catalog = await getCatalogModels();
|
||||
const entry = catalog.find((m) => m.name === name);
|
||||
const url = entry?.url;
|
||||
|
||||
// Sem URL conhecida → não conseguimos buscar tags no registro
|
||||
if (!url) {
|
||||
TAGS_CACHE.set(name, { tags: [], expiresAt: now + CACHE_TTL_MS });
|
||||
return NextResponse.json({ success: true, name, tags: [] });
|
||||
}
|
||||
|
||||
const tags = await fetchTagsFromPage(url);
|
||||
TAGS_CACHE.set(name, { tags, expiresAt: now + CACHE_TTL_MS });
|
||||
return NextResponse.json({ success: true, name, url, tags });
|
||||
} catch (error) {
|
||||
console.error("Error fetching model tags:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch tags" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import os from "os";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const totalMem = os.totalmem();
|
||||
const freeMem = os.freemem();
|
||||
const usedMem = totalMem - freeMem;
|
||||
const load = os.loadavg();
|
||||
const memUsage = process.memoryUsage();
|
||||
|
||||
// Base com os/fallback
|
||||
const baseInfo: any = {
|
||||
os: {
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
release: os.release(),
|
||||
uptimeSec: os.uptime(),
|
||||
},
|
||||
cpu: {
|
||||
model: os.cpus()?.[0]?.model || "unknown",
|
||||
cores: os.cpus()?.length || 0,
|
||||
speed: os.cpus()?.[0]?.speed || 0,
|
||||
},
|
||||
memory: {
|
||||
total: totalMem,
|
||||
free: freeMem,
|
||||
used: usedMem,
|
||||
modules: [] as Array<{ size: number; clockSpeed?: number; type?: string }>,
|
||||
},
|
||||
gpus: [] as Array<{ index: number; name: string; vendor?: string; vramMB?: number; bus?: string }>,
|
||||
metrics: {
|
||||
loadAvg: { "1m": load[0] ?? 0, "5m": load[1] ?? 0, "15m": load[2] ?? 0 },
|
||||
process: {
|
||||
rss: memUsage.rss,
|
||||
heapUsed: memUsage.heapUsed,
|
||||
heapTotal: memUsage.heapTotal,
|
||||
external: memUsage.external,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Tentar usar systeminformation, se disponível
|
||||
try {
|
||||
// Import dinâmico sem acionar o bundler (evita Module not found)
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||
const dynImport: (m: string) => Promise<any> = new Function(
|
||||
"m",
|
||||
"return import(m)"
|
||||
) as any;
|
||||
const si = await dynImport("systeminformation");
|
||||
|
||||
const [cpu, osInfo, memLayout, graphics, currentLoad] = await Promise.all([
|
||||
si.cpu(),
|
||||
si.osInfo(),
|
||||
si.memLayout(),
|
||||
si.graphics(),
|
||||
si.currentLoad(),
|
||||
]);
|
||||
|
||||
baseInfo.os = {
|
||||
platform: osInfo.platform || baseInfo.os.platform,
|
||||
arch: os.arch(),
|
||||
release: osInfo.release || baseInfo.os.release,
|
||||
uptimeSec: baseInfo.os.uptimeSec,
|
||||
distro: osInfo.distro,
|
||||
};
|
||||
baseInfo.cpu = {
|
||||
model: cpu.brand || baseInfo.cpu.model,
|
||||
cores: cpu.cores || baseInfo.cpu.cores,
|
||||
physicalCores: cpu.physicalCores,
|
||||
speed: Number(cpu.speed) || baseInfo.cpu.speed,
|
||||
};
|
||||
baseInfo.memory.modules = (memLayout || []).map((m: any) => ({ size: m.size, clockSpeed: m.clockSpeed, type: m.type }));
|
||||
baseInfo.gpus = (graphics?.controllers || []).map((c: any, idx: number) => ({ index: idx, name: c.model || c.vendor || "GPU", vendor: c.vendor, vramMB: c.vram, bus: c.bus }));
|
||||
baseInfo.metrics.loadAvg = {
|
||||
"1m": currentLoad?.avgload ?? baseInfo.metrics.loadAvg["1m"],
|
||||
"5m": baseInfo.metrics.loadAvg["5m"],
|
||||
"15m": baseInfo.metrics.loadAvg["15m"],
|
||||
};
|
||||
} catch {
|
||||
// sem systeminformation, seguimos com fallback
|
||||
}
|
||||
|
||||
// Fallback adicional para detectar GPUs no Windows via PowerShell
|
||||
try {
|
||||
if ((!baseInfo.gpus || baseInfo.gpus.length === 0) && (baseInfo.os.platform === "win32" || os.platform() === "win32")) {
|
||||
const { execFileSync } = await import("child_process");
|
||||
const out = execFileSync("powershell", [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM,PNPDeviceID | ConvertTo-Json -Depth 3"
|
||||
], { encoding: "utf8", timeout: 2000 });
|
||||
const parsed = JSON.parse(out);
|
||||
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
||||
baseInfo.gpus = arr.map((g: any, idx: number) => ({ index: idx, name: g.Name || "GPU", vendor: undefined, vramMB: g.AdapterRAM ? Math.round(g.AdapterRAM / 1024 / 1024) : undefined, bus: g.PNPDeviceID }));
|
||||
}
|
||||
} catch {
|
||||
// ignore fallback errors
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, info: baseInfo });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ success: false, error: String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user