diff --git a/.env.example b/.env.example index 8bceb8c..6b7668a 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index f7d6a6e..08b2c57 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ On Windows, double-click `start_openjutsu.bat` (ComfyUI must be running, or star 4. Open . -`.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. diff --git a/app/api/models/download/route.ts b/app/api/models/download/route.ts new file mode 100644 index 0000000..d9c3da4 --- /dev/null +++ b/app/api/models/download/route.ts @@ -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 }); + } +} 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..e95f14a 100644 --- a/components/welcome.tsx +++ b/components/welcome.tsx @@ -1,12 +1,10 @@ "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; @@ -14,6 +12,29 @@ 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 selected = SUGGESTIONS.find((model) => model.name === value); + const canDownload = selected && !installed; + const [job, setJob] = useState(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
@@ -38,7 +59,11 @@ 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.

- +
+ {canDownload && }
+ {canDownload && downloadError &&

{downloadError} Browser download

} + {job?.state === "error" &&

{job.error}

}