Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ COMFY_BASE_URL=http://127.0.0.1:8189
COMFY_API_KEY=
# ComfyUI itself (not the proxy), only to fill the base model and LoRA pickers. Leave empty for 127.0.0.1:8188.
COMFYUI_URL=
# Your ComfyUI folder (the one holding models/). Lets the welcome page download the describer model straight into models/text_encoders.
COMFYUI_DIR=
# Required before hosting or tunneling the app. Browser login: username openjutsu. Use HTTPS remotely.
OPENJUTSU_PASSWORD=
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ On Windows, double-click `start_openjutsu.bat` (ComfyUI must be running, or star

4. Open <http://127.0.0.1:3000>.

`.env.local` sets `COMFY_BASE_URL=http://127.0.0.1:8189`. Leave `COMFY_API_KEY` empty for a local proxy. The base model and LoRA pickers read ComfyUI's model lists from `COMFYUI_URL` (default `http://127.0.0.1:8188`); without it they become text fields.
`.env.local` sets `COMFY_BASE_URL=http://127.0.0.1:8189`. Leave `COMFY_API_KEY` empty for a local proxy. `COMFYUI_DIR` is your ComfyUI folder; with it set, the welcome page's **Download model** button saves the selected describer into `models/text_encoders` (without it, use the browser download link). The base model and LoRA pickers read ComfyUI's model lists from `COMFYUI_URL` (default `http://127.0.0.1:8188`); without it they become text fields.

Uploads are limited to 512 MB total per request, including reference pictures. Uploads are streamed to a temporary folder, and up to three can arrive at once per app process; a fourth is asked to retry shortly. After a job ends, its uploads are removed from comfy-api-proxy, but the files stay in ComfyUI's `input/` folder. Generation continues in ComfyUI independently.

Expand Down
24 changes: 24 additions & 0 deletions app/api/models/download/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { apiAccess, HttpError } from "../../../../lib/http.ts";
import { downloadStatus, startDownload } from "../../../../lib/download.ts";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(request: Request) {
const denied = apiAccess(request);
if (denied) return denied;
return Response.json(downloadStatus());
}

export async function POST(request: Request) {
const denied = apiAccess(request);
if (denied) return denied;
try {
const { name } = await request.json();
await startDownload(String(name));
return Response.json(downloadStatus());
} catch (error) {
if (error instanceof HttpError) return Response.json({ error: error.message }, { status: error.status });
return Response.json({ error: "Could not start the download." }, { status: 400 });
}
}
3 changes: 2 additions & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
39 changes: 32 additions & 7 deletions components/welcome.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,40 @@
"use client";

import { useEffect, useState } from "react";
import { downloadUrl, SUGGESTIONS } from "../lib/describers.ts";
import { DEFAULT_DESCRIBER } from "../lib/model.ts";

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" },
];
type Download = { name: string; state: "idle" | "downloading" | "done" | "error"; received: number; total: number; error?: string };

export function Welcome({ value, options, onChange, loading, error, onRefresh, onContinue }: {
value: string; options: string[]; onChange: (value: string) => void; loading: boolean; error: string;
onRefresh: () => void; onContinue: () => void;
}) {
const installed = !error && options.includes(value);
const suggested = SUGGESTIONS.filter((model) => options.includes(model.name));
const selected = SUGGESTIONS.find((model) => model.name === value);
const canDownload = selected && !installed;
const [job, setJob] = useState<Download | null>(null);
const [downloadError, setDownloadError] = useState("");
const downloading = job?.state === "downloading";

async function startDownload() {
setDownloadError("");
const response = await fetch("/api/models/download", { method: "POST", body: JSON.stringify({ name: value }) });
const result = await response.json();
if (!response.ok) return setDownloadError(result.error ?? "Could not start the download.");
setJob(result);
}

useEffect(() => {
if (!downloading) return;
const timer = setInterval(async () => {
try { setJob(await (await fetch("/api/models/download")).json()); } catch {}
}, 1000);
return () => clearInterval(timer);
}, [downloading]);

useEffect(() => { if (job?.state === "done") onRefresh(); }, [job?.state]);
const other = options.filter((name) => !SUGGESTIONS.some((model) => model.name === name));

return <main className="shell welcome">
Expand All @@ -38,7 +59,11 @@ export function Welcome({ value, options, onChange, loading, error, onRefresh, o
<h2 id="describer-heading">A model that can see and write.</h2>
<p className="hint">Use a vision-language model (VLM) with image understanding and text generation. This choice also applies to Find people.</p>
</div>
<label htmlFor="describer">Visual describer</label>
<div className="row"><label htmlFor="describer">Visual describer</label>
{canDownload && <button className="secondary download" disabled={downloading || loading} onClick={startDownload}>
{downloading ? `Downloading… ${job.total ? Math.round(job.received / job.total * 100) + "%" : ""}` : `Download model (${selected.size})`}</button>}</div>
{canDownload && downloadError && <p className="hint" role="alert">{downloadError} <a href={downloadUrl(selected.url)} rel="noreferrer">Browser download</a></p>}
{job?.state === "error" && <p className="error" role="alert">{job.error}</p>}
<select id="describer" className="file-name" value={value} onChange={(event) => onChange(event.target.value)} disabled={loading} aria-describedby="describer-help">
{!options.includes(value) && <option value={value}>{value}{loading ? " — checking…" : " — not installed"}</option>}
{suggested.length > 0 && <optgroup label="Suggested models installed in ComfyUI">
Expand All @@ -54,7 +79,7 @@ export function Welcome({ value, options, onChange, loading, error, onRefresh, o
<div className="welcome-install">
<strong>Put the model in this folder first</strong>
<code>ComfyUI\models\text_encoders</code>
<p className="hint">Use your ComfyUI installation folder. 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.</p>
<p className="hint">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.</p>
<div className="row"><span className="hint" role="status">{loading ? "Reading installed models…" : error ? "ComfyUI model list unavailable" : installed ? "Selected file is installed" : "Selected file is not installed"}</span>
<button className="secondary" disabled={loading} onClick={onRefresh}>{loading ? "Refreshing…" : "Refresh models"}</button></div>
{error && <p className="error" role="alert">{error}</p>}
Expand Down
8 changes: 8 additions & 0 deletions lib/describers.ts
Original file line number Diff line number Diff line change
@@ -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";
47 changes: 47 additions & 0 deletions lib/download.ts
Original file line number Diff line number Diff line change
@@ -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." };
}
})();
}
29 changes: 29 additions & 0 deletions test/download.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});