From 822a732b283827b16c6096c42daae31f0a30ed0a Mon Sep 17 00:00:00 2001 From: Walther Huggo Date: Thu, 5 Nov 2026 10:01:56 -0300 Subject: [PATCH 1/2] Add a Download model button to the welcome page Shows a button next to the Visual describer label that downloads the selected suggested model from Hugging Face when it is not installed in ComfyUI. Co-Authored-By: Claude Sonnet 5.5 --- app/globals.css | 3 ++- components/welcome.tsx | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/globals.css b/app/globals.css index 88d03b1..c2b7a20 100644 --- a/app/globals.css +++ b/app/globals.css @@ -235,7 +235,8 @@ input[type="range"] { accent-color: var(--accent); width: 220px; max-width: 100% .welcome .panel { padding: 24px; gap: 12px; } .welcome .panel h2 { font-size: 1.25rem; margin-bottom: 6px; } .welcome-picker { border-color: #657931; } -.welcome-picker > label { font-size: 0.88rem; font-weight: 600; margin-top: 4px; } +.welcome-picker label { font-size: 0.88rem; font-weight: 600; margin-top: 4px; } +.welcome-picker .download { padding: 6px 14px; text-decoration: none; font-size: 0.88rem; } .welcome-picker > select { width: 100%; min-width: 0; } .welcome-install { padding: 16px; background: var(--bg); border: 1px solid var(--line); border-radius: 12px; display: grid; gap: 10px; margin-top: 6px; } .welcome-install code { color: var(--accent); overflow-wrap: anywhere; } diff --git a/components/welcome.tsx b/components/welcome.tsx index c762c82..30b30e0 100644 --- a/components/welcome.tsx +++ b/components/welcome.tsx @@ -14,7 +14,9 @@ export function Welcome({ value, options, onChange, loading, error, onRefresh, o }) { const installed = !error && options.includes(value); const suggested = SUGGESTIONS.filter((model) => options.includes(model.name)); - const other = options.filter((name) => !SUGGESTIONS.some((model) => model.name === name)); + const selected = SUGGESTIONS.find((model) => model.name === value); + const download = selected && !installed ? selected.url.replace("/blob/", "/resolve/") + "?download=true" : ""; + const other =options.filter((name) => !SUGGESTIONS.some((model) => model.name === name)); return
@@ -38,7 +40,8 @@ export function Welcome({ value, options, onChange, loading, error, onRefresh, o

A model that can see and write.

Use a vision-language model (VLM) with image understanding and text generation. This choice also applies to Find people.

- +
+ {download && Download model ({selected!.size})}
onChange(event.target.value)} disabled={loading} aria-describedby="describer-help"> {!options.includes(value) && } {suggested.length > 0 && @@ -57,7 +79,7 @@ export function Welcome({ value, options, onChange, loading, error, onRefresh, o
Put the model in this folder first ComfyUI\models\text_encoders -

Use your ComfyUI installation folder. Use Download model above to get the selected file, or add a compatible single-file model with its vision weights, then refresh the list. If it still does not appear, restart ComfyUI and refresh again.

+

Use your ComfyUI installation folder. Use Download model above to save the selected file here automatically (needs COMFYUI_DIR in .env.local), or add a compatible single-file model with its vision weights, then refresh the list. If it still does not appear, restart ComfyUI and refresh again.

{loading ? "Reading installed models…" : error ? "ComfyUI model list unavailable" : installed ? "Selected file is installed" : "Selected file is not installed"}
{error &&

{error}

} diff --git a/lib/describers.ts b/lib/describers.ts new file mode 100644 index 0000000..4ca57b7 --- /dev/null +++ b/lib/describers.ts @@ -0,0 +1,8 @@ +export const SUGGESTIONS = [ + { name: "qwen3vl_8b_int8_convrot.safetensors", label: "Qwen3-VL 8B · INT8", size: "9.35 GB", note: "Openjutsu default", url: "https://huggingface.co/Comfy-Org/Qwen3-VL/blob/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors" }, + { name: "qwen3vl_4b_fp8_scaled.safetensors", label: "Qwen3-VL 4B · FP8", size: "5.24 GB", note: "Smaller Qwen alternative", url: "https://huggingface.co/Comfy-Org/Qwen3-VL/blob/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors" }, + { name: "gemma4_e2b_it_int8_convrot.safetensors", label: "Gemma 4 E2B · INT8", size: "5.2 GB", note: "Smaller vision alternative", url: "https://huggingface.co/Comfy-Org/gemma-4/blob/main/text_encoders/gemma4_e2b_it_int8_convrot.safetensors" }, +]; + +// Browser download link for a suggested model. +export const downloadUrl = (url: string) => url.replace("/blob/", "/resolve/") + "?download=true"; diff --git a/lib/download.ts b/lib/download.ts new file mode 100644 index 0000000..4922078 --- /dev/null +++ b/lib/download.ts @@ -0,0 +1,47 @@ +import { createWriteStream } from "node:fs"; +import { mkdir, rename, rm, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { downloadUrl, SUGGESTIONS } from "./describers.ts"; +import { HttpError } from "./http.ts"; + +export type DownloadStatus = { name: string; state: "idle" | "downloading" | "done" | "error"; received: number; total: number; error?: string }; + +// One download at a time, kept on globalThis so every route handler sees it. +const current = ((globalThis as { [key: symbol]: unknown })[Symbol.for("openjutsu.download")] ??= + { status: { name: "", state: "idle", received: 0, total: 0 } as DownloadStatus }) as { status: DownloadStatus }; + +export const downloadStatus = () => current.status; + +export async function startDownload(name: string) { + const model = SUGGESTIONS.find((item) => item.name === name); + if (!model) throw new HttpError(400, "Unknown model."); + const root = process.env.COMFYUI_DIR?.trim(); + if (!root) throw new HttpError(409, "Set COMFYUI_DIR in .env.local to your ComfyUI folder and restart Openjutsu, or use Browser download."); + if (current.status.state === "downloading") throw new HttpError(409, "A model is already downloading."); + + const dir = join(root, "models", "text_encoders"); + const file = join(dir, name); + const exists = await stat(file).then(() => true, () => false); + current.status = { name, state: exists ? "done" : "downloading", received: 0, total: 0 }; + if (exists) return; + + void (async () => { + const part = `${file}.part`; + try { + await mkdir(dir, { recursive: true }); + const response = await fetch(downloadUrl(model.url), { redirect: "follow" }); + if (!response.ok || !response.body) throw new Error(`Hugging Face answered ${response.status}.`); + current.status.total = Number(response.headers.get("content-length")) || 0; + const body = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream); + body.on("data", (chunk: Buffer) => { current.status.received += chunk.length; }); + await pipeline(body, createWriteStream(part)); + await rename(part, file); + current.status.state = "done"; + } catch (error) { + await rm(part, { force: true }); + current.status = { ...current.status, state: "error", error: error instanceof Error ? error.message : "Download failed." }; + } + })(); +} diff --git a/test/download.test.ts b/test/download.test.ts new file mode 100644 index 0000000..6015352 --- /dev/null +++ b/test/download.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { downloadStatus, startDownload } from "../lib/download.ts"; + +const NAME = "qwen3vl_4b_fp8_scaled.safetensors"; + +test("download rejects unknown models and a missing COMFYUI_DIR", async () => { + delete process.env.COMFYUI_DIR; + await assert.rejects(startDownload("evil.safetensors"), { status: 400 }); + await assert.rejects(startDownload(NAME), { status: 409 }); +}); + +test("download saves a suggested model into models/text_encoders", async () => { + const root = await mkdtemp(join(tmpdir(), "openjutsu-")); + process.env.COMFYUI_DIR = root; + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response("weights", { headers: { "content-length": "7" } })) as typeof fetch; + try { + await startDownload(NAME); + for (let i = 0; i < 100 && downloadStatus().state === "downloading"; i++) await new Promise((r) => setTimeout(r, 20)); + assert.equal(downloadStatus().state, "done"); + assert.equal(await readFile(join(root, "models", "text_encoders", NAME), "utf8"), "weights"); + } finally { + globalThis.fetch = realFetch; + } +});