@@ -1006,10 +1072,27 @@ function ModelManager({
{section.items.map((model) => {
const installed = modelNames.includes(model.filename);
const downloading = downloadingModelId === model.filename || downloadingModelId === `${model.filename}.zip`;
+ const systemTier = getHardwareTier(specs);
+ const isRecommended = model.recommendedTiers && model.recommendedTiers.includes(systemTier);
return (
-
{model.name}
+
+ {model.name}
+ {isRecommended && (
+
+ [Recommended Fit]
+
+ )}
+
{model.format} • approx. {model.approxSize} {model.resolution !== "N/A" && `• ${model.resolution}`}
diff --git a/app/frontend/src/components/TextChat.jsx b/app/frontend/src/components/TextChat.jsx
index c03edc3..a697dfa 100644
--- a/app/frontend/src/components/TextChat.jsx
+++ b/app/frontend/src/components/TextChat.jsx
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
-import { Bot, LoaderCircle, Send, Trash2, Square, History, Plus } from "lucide-react";
+import { Bot, LoaderCircle, Send, Trash2, Square, History, Plus, Paperclip, X } from "lucide-react";
import {
chatWithLlm,
getDownloadProgress,
@@ -30,6 +30,64 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
const completedDownloadRef = useRef("");
const loadingModelRef = useRef(null);
+ const [attachments, setAttachments] = useState([]);
+ const fileInputRef = useRef(null);
+
+ const isImage = (file) => {
+ return /\.(jpe?g|png|webp)$/i.test(file.name) || file.type.startsWith("image/");
+ };
+
+ const isTextFile = (file) => {
+ return /\.(txt|md|csv|js|jsx|ts|tsx|py|json|css|html|java|cpp|c|h|rs|go|sh|bat|xml|yaml|yml)$/i.test(file.name) || file.type.startsWith("text/");
+ };
+
+ const handleFileChange = (e) => {
+ const files = Array.from(e.target.files);
+ if (!files || files.length === 0) return;
+
+ files.forEach((file) => {
+ if (isImage(file)) {
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ setAttachments((prev) => [
+ ...prev,
+ {
+ id: Math.random().toString(36).substring(7),
+ file,
+ type: "image",
+ name: file.name,
+ dataUrl: event.target.result,
+ },
+ ]);
+ };
+ reader.readAsDataURL(file);
+ } else if (isTextFile(file)) {
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ setAttachments((prev) => [
+ ...prev,
+ {
+ id: Math.random().toString(36).substring(7),
+ file,
+ type: "document",
+ name: file.name,
+ content: event.target.result,
+ },
+ ]);
+ };
+ reader.readAsText(file);
+ } else {
+ showAlert({
+ title: "Unsupported File",
+ message: `File "${file.name}" is not supported. Please select an image (JPEG, PNG, WEBP) or a text/code file.`,
+ danger: true,
+ });
+ }
+ });
+
+ e.target.value = "";
+ };
+
useEffect(() => {
loadingModelRef.current = loadingModel;
}, [loadingModel]);
@@ -179,7 +237,12 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
setSelectedModel(conv.model);
}
// Try to compute approximate token usage from messages
- const total = conv.messages.reduce((sum, m) => sum + m.content.split(/\s+/).length, 0);
+ const total = conv.messages.reduce((sum, m) => {
+ const text = Array.isArray(m.content)
+ ? m.content.map(c => c.text || "").join(" ")
+ : (m.content || "");
+ return sum + text.split(/\s+/).length;
+ }, 0);
setTokenUsage({
prompt_tokens: Math.round(total * 0.7),
completion_tokens: Math.round(total * 0.3),
@@ -204,7 +267,8 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
const sendMessage = async () => {
const text = input.trim();
- if (!text || isBusy || !status.ready) return;
+ const hasAttachments = attachments.length > 0;
+ if ((!text && !hasAttachments) || isBusy || !status.ready) return;
let convId = activeConversationId;
let isNew = false;
@@ -214,12 +278,40 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
isNew = true;
}
- const nextMessages = [...messages, { role: "user", content: text }];
+ // Delimited context blocks for document attachments
+ let documentContext = "";
+ attachments.filter(att => att.type === "document").forEach((att) => {
+ documentContext += `\n[Attached File: ${att.name}]\n${att.content}\n`;
+ });
+
+ const combinedText = documentContext ? `${text}\n\n${documentContext}`.trim() : text;
+ const imageAttachments = attachments.filter(att => att.type === "image");
+
+ let userMessageContent;
+ if (imageAttachments.length > 0) {
+ userMessageContent = [
+ {
+ type: "text",
+ text: combinedText
+ },
+ ...imageAttachments.map((img) => ({
+ type: "image_url",
+ image_url: {
+ url: img.dataUrl
+ }
+ }))
+ ];
+ } else {
+ userMessageContent = combinedText;
+ }
+
+ const nextMessages = [...messages, { role: "user", content: userMessageContent }];
setMessages(nextMessages);
setInput("");
setIsBusy(true);
- const firstTitle = isNew ? (text.slice(0, 26) + (text.length > 26 ? "..." : "")) : null;
+ const displayTitleText = text || (imageAttachments.length > 0 ? "Sent Image" : "Sent File");
+ const firstTitle = isNew ? (displayTitleText.slice(0, 26) + (displayTitleText.length > 26 ? "..." : "")) : null;
saveConversationState(convId, nextMessages, selectedModel, firstTitle);
try {
@@ -238,6 +330,9 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
setTokenUsage(response.usage);
}
saveConversationState(convId, finalMessages, selectedModel);
+
+ // Clean up attached files on success
+ setAttachments([]);
} catch (err) {
const finalMessages = [...nextMessages, { role: "assistant", content: `Error: ${err.message}`, error: true }];
setMessages(finalMessages);
@@ -510,7 +605,30 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
{messages.map((message, index) => (
{message.role === "user" ? "You" : "Local AI"}
-
{message.content}
+
+ {Array.isArray(message.content) ? (
+
+ {message.content.map((item, idx) => {
+ if (item.type === "text") {
+ return
{item.text};
+ }
+ if (item.type === "image_url") {
+ return (
+

+ );
+ }
+ return null;
+ })}
+
+ ) : (
+
{message.content}
+ )}
+
))}
{isBusy && status.ready &&
Generating...
}
@@ -519,22 +637,99 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
-
diff --git a/scripts/serve.cjs b/scripts/serve.cjs
index fa2a102..e3b849d 100644
--- a/scripts/serve.cjs
+++ b/scripts/serve.cjs
@@ -220,42 +220,217 @@ function getGpuInfo() {
if (cachedGpuInfo) return cachedGpuInfo;
if (osPlatform === "win32") {
+ try {
+ const stdout = execSync(
+ 'powershell -NoProfile -Command "Get-CimInstance Win32_VideoController | ForEach-Object { [PSCustomObject]@{ Name = $_.Name; VRAM = [math]::Round($_.AdapterRAM / 1GB, 2) } } | ConvertTo-Json"',
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
+ ).trim();
+ if (stdout) {
+ let gpus = [];
+ try {
+ const parsed = JSON.parse(stdout);
+ if (Array.isArray(parsed)) {
+ gpus = parsed;
+ } else if (parsed && typeof parsed === "object") {
+ gpus = [parsed];
+ }
+ } catch (_) {}
+
+ if (gpus.length > 0) {
+ const discreteKeywords = ["nvidia", "amd", "arc", "geforce", "radeon", "rtx", "gtx"];
+ let selectedGpu = gpus.find(gpu => {
+ const name = String(gpu.Name || "").toLowerCase();
+ return discreteKeywords.some(kw => name.includes(kw));
+ });
+ if (!selectedGpu) {
+ selectedGpu = gpus[0];
+ }
+ cachedGpuInfo = {
+ name: selectedGpu.Name || "Unknown GPU",
+ vram_gb: typeof selectedGpu.VRAM === "number" ? selectedGpu.VRAM : 0
+ };
+ return cachedGpuInfo;
+ }
+ }
+ } catch (_) {}
+ // Fallback if Powershell fails or returns empty
try {
const output = execSync(
"powershell -NoProfile -Command \"Get-CimInstance Win32_VideoController | Select-Object -First 1 -ExpandProperty Name\"",
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
).trim();
if (output) {
- cachedGpuInfo = { name: output };
+ cachedGpuInfo = { name: output, vram_gb: 0 };
return cachedGpuInfo;
}
} catch (_) {}
}
- if (osPlatform === "linux") {
- const linuxGpu = detectLinuxGpuFromSysfs();
- if (linuxGpu) {
- cachedGpuInfo = linuxGpu;
- return cachedGpuInfo;
- }
- }
-
if (osPlatform === "darwin") {
try {
- const output = execSync(
- "system_profiler SPDisplaysDataType | awk -F': ' '/Chipset Model|Vendor/ {print $2; exit}'",
- { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
- ).trim();
- if (output) {
- cachedGpuInfo = { name: output };
- return cachedGpuInfo;
+ const output = execSync("system_profiler SPDisplaysDataType", {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ });
+ const lines = output.split("\n");
+ let currentGpu = null;
+ const gpus = [];
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed.startsWith("Chipset Model:")) {
+ if (currentGpu) {
+ gpus.push(currentGpu);
+ }
+ currentGpu = {
+ name: trimmed.substring("Chipset Model:".length).trim(),
+ vram_gb: 0,
+ };
+ } else if (currentGpu && (trimmed.startsWith("VRAM (Total):") || trimmed.startsWith("VRAM (Dynamic, Max):"))) {
+ const valStr = trimmed.split(":")[1].trim(); // e.g. "2 GB" or "1536 MB"
+ let bytesVal = 0;
+ const numMatch = valStr.match(/^([\d\.]+)\s*(GB|MB)/i);
+ if (numMatch) {
+ const val = parseFloat(numMatch[1]);
+ const unit = numMatch[2].toUpperCase();
+ if (unit === "GB") {
+ bytesVal = val;
+ } else if (unit === "MB") {
+ bytesVal = val / 1024;
+ }
+ }
+ currentGpu.vram_gb = Math.round(bytesVal * 100) / 100;
+ }
+ }
+ if (currentGpu) {
+ gpus.push(currentGpu);
+ }
+
+ if (gpus.length > 0) {
+ const isAppleSilicon = gpus.some(g => {
+ const name = g.name.toLowerCase();
+ return name.includes("apple") || name.includes("m1") || name.includes("m2") || name.includes("m3") || name.includes("m4");
+ }) || (os.arch() === "arm64");
+
+ if (isAppleSilicon) {
+ const totalRamGb = Math.round((os.totalmem() / (1024 ** 3)) * 100) / 100;
+ let selected = gpus.find(g => g.name.toLowerCase().includes("apple")) || gpus[0];
+ cachedGpuInfo = {
+ name: selected.name,
+ vram_gb: totalRamGb
+ };
+ return cachedGpuInfo;
+ } else {
+ const discreteKeywords = ["nvidia", "amd", "radeon", "geforce"];
+ let selected = gpus.find(gpu => {
+ const name = gpu.name.toLowerCase();
+ return discreteKeywords.some(kw => name.includes(kw));
+ });
+ if (!selected) {
+ selected = gpus[0];
+ }
+ cachedGpuInfo = {
+ name: selected.name,
+ vram_gb: selected.vram_gb
+ };
+ return cachedGpuInfo;
+ }
}
} catch (_) {}
- cachedGpuInfo = { name: "Apple Metal GPU" };
+ // Fallback if system_profiler command fails
+ const totalRamGb = Math.round((os.totalmem() / (1024 ** 3)) * 100) / 100;
+ cachedGpuInfo = { name: "Apple Metal GPU", vram_gb: totalRamGb };
return cachedGpuInfo;
}
- cachedGpuInfo = { name: "Unavailable" };
+ if (osPlatform === "linux") {
+ // AMD cards: Scan /sys/class/drm/card*/device/vendor and mem_info_vram_total
+ try {
+ if (fs.existsSync("/sys/class/drm")) {
+ const cards = fs.readdirSync("/sys/class/drm").filter(name => name.startsWith("card"));
+ for (const card of cards) {
+ const vendorPath = `/sys/class/drm/${card}/device/vendor`;
+ const vramPath = `/sys/class/drm/${card}/device/mem_info_vram_total`;
+ if (fs.existsSync(vendorPath) && fs.existsSync(vramPath)) {
+ const vendor = fs.readFileSync(vendorPath, "utf8").trim();
+ if (vendor === "0x1002") {
+ const vramBytesStr = fs.readFileSync(vramPath, "utf8").trim();
+ const vramBytes = parseFloat(vramBytesStr);
+ if (!isNaN(vramBytes)) {
+ cachedGpuInfo = {
+ name: "AMD Radeon GPU",
+ vram_gb: Math.round((vramBytes / (1024 ** 3)) * 100) / 100
+ };
+ return cachedGpuInfo;
+ }
+ }
+ }
+ }
+ }
+ } catch (_) {}
+
+ // NVIDIA cards: read /proc/driver/nvidia/gpus/*/information or run nvidia-smi
+ try {
+ if (fs.existsSync("/proc/driver/nvidia/gpus")) {
+ const gpuDirs = fs.readdirSync("/proc/driver/nvidia/gpus");
+ for (const gpuDir of gpuDirs) {
+ const infoPath = `/proc/driver/nvidia/gpus/${gpuDir}/information`;
+ if (fs.existsSync(infoPath)) {
+ const info = fs.readFileSync(infoPath, "utf8");
+ const modelMatch = info.match(/Model:\s+(.*)/);
+ if (modelMatch) {
+ const modelName = modelMatch[1].trim();
+ let vram_gb = 0;
+ try {
+ const smiOut = execSync("nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits", {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"]
+ }).trim();
+ const parsedVal = parseFloat(smiOut);
+ if (!isNaN(parsedVal)) {
+ vram_gb = Math.round((parsedVal / 1024) * 100) / 100;
+ }
+ } catch (_) {}
+ cachedGpuInfo = {
+ name: modelName,
+ vram_gb: vram_gb || 8.0
+ };
+ return cachedGpuInfo;
+ }
+ }
+ }
+ }
+ } catch (_) {}
+
+ try {
+ const smiOut = execSync("nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits", {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"]
+ }).trim();
+ if (smiOut) {
+ const lines = smiOut.split("\n");
+ if (lines[0]) {
+ const [name, totalMb] = lines[0].split(",").map(p => p.trim());
+ const parsedVal = parseFloat(totalMb);
+ cachedGpuInfo = {
+ name: name || "NVIDIA GPU",
+ vram_gb: !isNaN(parsedVal) ? Math.round((parsedVal / 1024) * 100) / 100 : 8.0
+ };
+ return cachedGpuInfo;
+ }
+ }
+ } catch (_) {}
+
+ const linuxGpu = detectLinuxGpuFromSysfs();
+ if (linuxGpu) {
+ cachedGpuInfo = {
+ name: linuxGpu.name,
+ vram_gb: 0
+ };
+ return cachedGpuInfo;
+ }
+ }
+
+ cachedGpuInfo = { name: "Unavailable", vram_gb: 0 };
return cachedGpuInfo;
}
@@ -365,13 +540,109 @@ if (osPlatform === "darwin") {
function getHardwareSpecs() {
const cpus = os.cpus();
const gpu = getGpuInfo();
+ const ramTotalGb = roundGb(os.totalmem());
+ const gpuVramGb = gpu.vram_gb || 0;
+
+ // Tiering logic
+ let tier = "low";
+ let tierReason = "";
+
+ const isAppleSilicon = osPlatform === "darwin" && (os.arch() === "arm64" || String(cpus[0]?.model || "").toLowerCase().includes("apple"));
+
+ if (isAppleSilicon) {
+ if (ramTotalGb >= 16) {
+ tier = "high";
+ tierReason = `Apple Silicon Mac with ${ramTotalGb} GB unified memory.`;
+ } else if (ramTotalGb >= 8) {
+ tier = "mid";
+ tierReason = `Apple Silicon Mac with ${ramTotalGb} GB unified memory.`;
+ } else {
+ tier = "low";
+ tierReason = `Apple Silicon Mac with ${ramTotalGb} GB unified memory.`;
+ }
+ } else {
+ if (gpuVramGb >= 12) {
+ tier = "high";
+ tierReason = `Discrete GPU with ${gpuVramGb} GB VRAM.`;
+ } else if (gpuVramGb >= 6) {
+ tier = "mid";
+ tierReason = `GPU with ${gpuVramGb} GB VRAM.`;
+ } else {
+ tier = "low";
+ tierReason = `GPU with ${gpuVramGb} GB VRAM and ${ramTotalGb} GB RAM.`;
+ }
+ }
+
+ // Recommended models catalog matching contract exactly
+ let recommended_models = [];
+ if (tier === "high") {
+ recommended_models = [
+ {
+ name: "Llama 3 8B Instruct (Q8_0)",
+ filename: "Meta-Llama-3-8B-Instruct-Q8_0.gguf",
+ approxSize: "8.5 GB",
+ url: "https://huggingface.co/QuantFactory/Meta-Llama-3-8B-Instruct-GGUF/resolve/main/Meta-Llama-3-8B-Instruct-Q8_0.gguf",
+ notes: "Near-lossless precision for high quality responses.",
+ reason: tierReason
+ },
+ {
+ name: "Mistral 7B Instruct v0.3 (Q8_0)",
+ filename: "Mistral-7B-Instruct-v0.3.Q8_0.gguf",
+ approxSize: "7.7 GB",
+ url: "https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.3-GGUF/resolve/main/Mistral-7B-Instruct-v0.3.Q8_0.gguf",
+ notes: "Superb instruction-following and context window size.",
+ reason: tierReason
+ }
+ ];
+ } else if (tier === "mid") {
+ recommended_models = [
+ {
+ name: "Llama 3 8B Instruct (Q4_K_M)",
+ filename: "Meta-Llama-3-8B-Instruct-Q4_K_M.gguf",
+ approxSize: "4.8 GB",
+ url: "https://huggingface.co/QuantFactory/Meta-Llama-3-8B-Instruct-GGUF/resolve/main/Meta-Llama-3-8B-Instruct-Q4_K_M.gguf",
+ notes: "Great balance of speed and performance.",
+ reason: tierReason
+ },
+ {
+ name: "Phi-3 Mini Instruct (Q4_K_M)",
+ filename: "Phi-3-mini-4k-instruct-q4.gguf",
+ approxSize: "2.2 GB",
+ url: "https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-gguf/resolve/main/Phi-3-mini-4k-instruct-q4.gguf",
+ notes: "Lightweight and fast, optimized by Microsoft.",
+ reason: tierReason
+ }
+ ];
+ } else {
+ recommended_models = [
+ {
+ name: "Qwen 2.5 Coder 0.5B Instruct (Q4_K_M)",
+ filename: "qwen2.5-coder-0.5b-instruct-q4_k_m.gguf",
+ approxSize: "491 MB",
+ url: "https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B-Instruct-GGUF/resolve/main/qwen2.5-coder-0.5b-instruct-q4_k_m.gguf",
+ notes: "Extremely fast, lightweight assistant, perfect for low RAM/VRAM machines.",
+ reason: tierReason
+ },
+ {
+ name: "SmolLM2 1.7B Instruct (Q4_K_M)",
+ filename: "smollm2-1.7b-instruct-q4_k_m.gguf",
+ approxSize: "1.1 GB",
+ url: "https://huggingface.co/HuggingFaceTB/SmolLM2-1.7B-Instruct-GGUF/resolve/main/smollm2-1.7b-instruct-q4_k_m.gguf",
+ notes: "Excellent lightweight assistant with strong logic, reasoning, and prompt expansion capabilities.",
+ reason: tierReason
+ }
+ ];
+ }
+
return {
os_name: `${os.type()} ${os.release()}`,
cpu_name: cpus[0]?.model || "Unavailable",
cpu_cores_physical: Math.max(1, Math.round(cpus.length / 2)),
cpu_cores_logical: cpus.length || 1,
- ram_total_gb: roundGb(os.totalmem()),
+ ram_total_gb: ramTotalGb,
gpu_name: gpu.name,
+ gpu_vram_gb: gpuVramGb,
+ recommended_models
};
}
@@ -381,13 +652,14 @@ function getTelemetry() {
if (osPlatform === "darwin" && cachedMacRamUsedGb !== null) {
ram_used_gb = cachedMacRamUsedGb;
}
+ const gpu = getGpuInfo();
return {
cpu_usage: getCpuUsagePercent(),
ram_used_gb,
ram_total_gb: roundGb(os.totalmem()),
- gpu_name: vram?.gpu_name || getGpuInfo().name,
+ gpu_name: vram?.gpu_name || gpu.name,
vram_used_gb: vram?.vram_used_gb || 0,
- vram_total_gb: vram?.vram_total_gb || 0,
+ vram_total_gb: vram?.vram_total_gb || gpu.vram_gb || 0,
};
}
@@ -1518,6 +1790,34 @@ async function startLlm(settings = {}) {
backendBinary: path.basename(backend.path),
};
+ let mmprojPath = null;
+ const lowerFilename = filename.toLowerCase();
+ const isMultimodal = lowerFilename.includes("llava") ||
+ lowerFilename.includes("vision") ||
+ lowerFilename.includes("qwen2vl") ||
+ lowerFilename === "ggml-model-q4_k.gguf";
+
+ if (isMultimodal) {
+ if (settings.mmproj) {
+ const customProjPath = path.join(LLM_MODELS, path.basename(settings.mmproj));
+ if (fs.existsSync(customProjPath)) {
+ mmprojPath = customProjPath;
+ }
+ }
+ if (!mmprojPath) {
+ try {
+ const files = fs.readdirSync(LLM_MODELS);
+ const mmprojFile = files.find(file => {
+ const lower = file.toLowerCase();
+ return lower.endsWith(".gguf") && (lower === "mmproj-model-f16.gguf" || lower.includes("mmproj"));
+ });
+ if (mmprojFile) {
+ mmprojPath = path.join(LLM_MODELS, mmprojFile);
+ }
+ } catch (_) {}
+ }
+ }
+
const args = [
"--model", modelPath,
"--host", "127.0.0.1",
@@ -1526,6 +1826,9 @@ async function startLlm(settings = {}) {
"--threads", String(llmSettings.threads),
"--n-gpu-layers", String(llmSettings.gpuLayers),
];
+ if (mmprojPath) {
+ args.push("--mmproj", mmprojPath);
+ }
const spawnEnv = { ...process.env };
const backendDir = path.dirname(backend.path);
if (osPlatform === "linux") {
@@ -1959,7 +2262,7 @@ function describeDownloadHttpError(statusCode, url, headers = {}) {
return `HTTP ${statusCode}`;
}
-function startModelDownload(url, overrideFilename = null, targetDir = MODELS, kind = "image") {
+function startModelDownload(url, overrideFilename = null, targetDir = MODELS, kind = "image", redirectCount = 0) {
if (downloadState.active && !overrideFilename) {
console.log(" [download] Already downloading a model");
return;
@@ -2035,6 +2338,10 @@ function startModelDownload(url, overrideFilename = null, targetDir = MODELS, ki
failDownload("Redirect response did not include a Location header");
return;
}
+ if (redirectCount > 10) {
+ failDownload("Too many redirects");
+ return;
+ }
console.log(` [download] Redirected to ${redirectUrl}`);
// Clean up redirected request to avoid triggering error handlers later
@@ -2045,7 +2352,7 @@ function startModelDownload(url, overrideFilename = null, targetDir = MODELS, ki
try { fs.unlinkSync(tempPath); } catch (_) {}
activeDownload = null;
downloadState.active = false;
- startModelDownload(redirectUrl, filename, targetDir, kind);
+ startModelDownload(redirectUrl, filename, targetDir, kind, redirectCount + 1);
return;
}