feat(model-check): download missing models, to the folder you choose

Model Check offers Download (and Download all) for a missing model whose
source names one exact file: Civitai by the workflow's fingerprint, or a
Civitai version / Hugging Face / GitHub file link the workflow carries.
A dialog shows each file's size, source and full destination, with the
models folder (when there are several) and subfolder changeable; Settings
> Model downloads sets the default: where the workflow says (the node
needs no change) or a set folder, {base} sorting by base model and
matching an existing folder in any spelling.

Downloads run one at a time into <file>.part, continue after an
interruption, are checked against the SHA-256 before they get their name,
never replace a file, and send the Civitai key to Civitai only. Finished
files are put into their nodes (one Ctrl+Z step each), scanned for info
and covers, and recorded in the activity log.

A change of fingerprints alone (after any scan) no longer takes a Ctrl+Z
step that seemed to do nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-06 01:28:51 +08:00
co-authored by Claude Opus 5.5
parent fa11c55293
commit 698a256a16
21 changed files with 1655 additions and 17 deletions
+12 -1
View File
@@ -113,6 +113,12 @@ covered by the `styles.css` manifest.
User guide: `docs/guides/mcp.md`.
- `api/lora_info.py` reads a LoRA's safetensors header to tell whether it trains the text
encoder (`/anomalous/lora_info`), so inserting it wires the CLIP line only then.
- `api/model_download.py` downloads the models a workflow misses (Model Check's
"Download", `/anomalous/download/*`): one at a time into `<file>.part`, continued after
an interruption, checked against the SHA-256 before it gets its name, never over an
existing file; also where downloads go by default (`download_settings.json`).
`api/download_sources.py` finds the one exact file: Civitai by hash, a Civitai version
link, or a Hugging Face / GitHub file link the workflow carries.
- `api/workflow_schema.py`, `api/recipe_schema.py`, `api/recipe_images.py`, and
`api/recipe_store.py` own recipe validation/shaping, images, CRUD, history, and
integrity receipts; `api/recipes.py` is the HTTP facade. `api/recipe_constants.py`
@@ -470,7 +476,12 @@ covered by the `styles.css` manifest.
say "doctor") and puts a found file into its node; `ui_doctor.js` is the
Model Check page and `ui_doctor_banner.js` the bar over the canvas when an opened workflow
misses models (it also owns the check-on-open preference that Settings → Workflows and the
bar's "Don't show again" set). `ui_node_assistant.js` owns the current-node panel (model actions, LoRA
bar's "Don't show again" set). `model_download.js` finds a missing model's source,
decides where its file goes (the download settings, `{base}` = its base model's folder),
follows the downloads and puts each finished file into its nodes (the placement rules
are `download_places.js`, without imports so they are tested alone); `ui_model_download.js`
is the page's Download buttons, progress and the dialog that shows and changes where
each file goes. `ui_node_assistant.js` owns the current-node panel (model actions, LoRA
insertion, model cards and history), `ui_node_model_picker.js` owns native combo
replacement, `ui_node_parameters.js` renders the panel's parameters section, and
`node_parameter_sets.js` merges the node type's saved values (material files and recipe
+10 -2
View File
@@ -52,14 +52,22 @@
- **Model folders, readable (模型文件夹设置更好懂)**: folder management is a view of the settings page instead of a pop-up. Grouping is two plain choices, **By model type** or **By folders on disk** (it was "Category mode / Physical mode"); the types shown on the Models page come first, each with its usual name (Checkpoint, Diffusion…) and folder name, a switch and ↑↓ to order them; the ones that are off follow, and the many folders ComfyUI registers that few people use (configs, datasets…) fold away. Each change applies at once; there is no Save and reload button any more.
- The output gallery shows 512 px thumbnails instead of full images (cached like model cards), so scrolling through hundreds of outputs no longer fills the browser's memory and graphics memory with full-size pictures. Dragging an image to the canvas and the large viewer still use the original. When the browser has been closed for 90 seconds, its gallery images are released too, like the model grid.
### ⬇️ Download missing models from Model Check (模型检查一键下载缺失模型)
- **Download** next to a missing model, and **Download all** for every one, when its source is certain: Civitai found the very file by the workflow's fingerprint, or the workflow carries a link to the file (a shared workflow's model links, or the download links in ComfyUI's own templates on Hugging Face). A file name alone is never enough, and a link to a different file than the workflow's is not offered.
- **You see where it goes before it starts**: the dialog shows, per file, its size, source and base model, the full path it will be saved to, and the free disk space. The models folder (when you have several, e.g. through extra_model_paths) and the subfolder can be changed right there; the folder you pick is remembered per type.
- **Your folder habits (按你的文件夹习惯放)**: Settings → Model downloads sets the default. *Where the workflow says* (default) puts the file in the subfolder the workflow names, so nothing in the workflow changes; a model without one goes into the folder set below (`Downloads`). *A set folder* always uses that folder, and the node is moved to the new place (Ctrl+Z undoes it). Write `{base}` in the folder to sort by base model (SDXL, Flux, Illustrious…); a folder of that name you already have is used, in whatever spelling.
- **Safe by default**: it downloads into a temporary file and checks the fingerprint before giving it its name, so a broken or wrong download never shows up in your models; an existing file is never replaced; an interrupted download continues where it stopped. `.ckpt` / `.pt` files get a warning (they can run code), and a source without a fingerprint says it cannot be checked. Your Civitai API key (needed for some models; Settings → Model downloads, or the scan page) is only sent to Civitai.
- Progress shows in the row with Cancel; when a file is done, the nodes using it get it, a short message says so, the new files are scanned for their information and cover, and the activity log records the download (source, size, whether the fingerprint matched).
- **Ctrl+Z after a scan (扫描后的 Ctrl+Z)**: after any scan, the first Ctrl+Z used to seem to do nothing: the workflow's saved fingerprints had changed, which ComfyUI counted as a step. A change of fingerprints alone no longer takes a step.
### 🧩 Inserting a LoRA wires it properly (插入 LoRA 连线更聪明)
- **Current node → Insert LoRA before / after** now works on the usual workflows it refused before: a checkpoint whose CLIP feeds both prompts (both now hang behind the LoRA), and UNet / Flux / Anima workflows with their own CLIP loader. Whether the CLIP line goes through the LoRA is decided by the LoRA file itself: one that also trains the text encoder (most SD 1.5 / SDXL / Illustrious LoRAs) takes it, from the checkpoint or the CLIP loader; one that does not (most Flux and Anima LoRAs) is inserted as a model-only LoRA loader. Inserting before a sampler moves only that sampler's model, and the CLIP of the prompts wired into it.
### 🤖 AI apps can use your library (MCP,让 AI 用你的库)
- While ComfyUI runs, AI apps that speak MCP (Claude Code, Claude Desktop, Cursor, Cherry Studio, VS Code…) can connect to `http://127.0.0.1:8188/anomalous/mcp` and ask about your library: models with their Civitai information, trigger words and your notes, which models are unmatched and why, output images and the settings that made them, combos, workflow recipes, saved prompts, generated speech and the activity log. A model's cover or an image's thumbnail can be attached for apps that look at pictures.
- It can also act on the workflow open in ComfyUI: rewrite or extend the prompts, swap a node's model, add a LoRA (it goes after the model loader and the LoRAs already there; one that also trains the text encoder takes the CLIP line too, from the checkpoint or, in a UNet workflow, from its CLIP loader, and everything those fed, such as both prompts, moves behind it; one that does not, like most Flux and Anima LoRAs, patches the model only), place one of your combos, check and put back missing models, and run the workflow; and start scans, or have an Anomalous TTS voice read a line. Every canvas change is one Ctrl+Z step, shows a note on the page and is in the activity log marked "AI". Nothing deletes or renames files; AI apps ask before calling a tool that changes something.
- Only this computer can connect, and requests from web pages, other hosts or through a proxy are refused. It costs no memory while nobody is connected. Setup for each app: `docs/guides/mcp.md`.
### 🕘 Activity log (操作记录)
- **See what Anomalous changed (看 Anomalous 改了什么)**: the new **Activity** page on the rail lists, by day, every change the plugin made: on the canvas, which node settings changed (from → to), which nodes were added or removed, and which workflow was opened; in files, models, covers, recipes, materials, notes, presets, images, audio and imported characters. Filter by canvas or files; open a canvas entry for its details and **Find on canvas** to jump to the node. Your own edits on the canvas are not recorded as the plugin's. The home page shows the latest few.
- **More detail (记录更详细)**: a scan is recorded when it ends, with what it found and each model it changed (open one from the entry), instead of a bare "scanned models" when it started. Editing a model records each field from → to, and deleting one lists the files moved to the Recycle Bin.
+4 -3
View File
@@ -1,7 +1,7 @@
from . import (
activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog,
model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_report, scan_summary,
lora_info, mcp_server, translation_routes, version_manager,
lora_info, mcp_server, model_download, translation_routes, version_manager,
)
from .scanner import *
from .config import *
@@ -108,7 +108,7 @@ def setup_routes(app):
app.router.add_post('/anomalous/resolve_paths_to_previews', model_catalog.api_resolve_paths_to_previews)
app.router.add_get('/anomalous/scan_summary', scan_summary.api_scan_summary)
app.router.add_get('/anomalous/last_scan', scan_report.api_last_scan)
app.router.add_get('/anomalous/scan_model', scan_report.api_scan_model)
app.router.add_get('/anomalous/scan_model', scan_report.api_scan_model)
app.router.add_get('/anomalous/lora_info', lora_info.api_lora_info)
# Audio & Voice Studio Routes
@@ -118,5 +118,6 @@ def setup_routes(app):
app.router.add_get('/anomalous/audio_previews', audio_catalog.api_get_audio_previews)
app.router.add_post('/anomalous/save_audio_preview', audio_catalog.api_save_audio_preview)
version_manager.register_routes(app)
activity_log.register_routes(app)
activity_log.register_routes(app)
mcp_server.register_routes(app)
model_download.register_routes(app)
+237
View File
@@ -0,0 +1,237 @@
"""Where a model the open workflow is missing can be downloaded from, for Model Check's
"Download" (api/model_download.py fetches it).
Only sources that name one exact file count: Civitai by the file's hash (the workflow's
fingerprint), a Civitai model version or file link, or a direct file link on Hugging Face
(or its mirror) or GitHub, as a shared workflow carries them (AMB's model links, ComfyUI's own
`properties.models`). A file name alone is never a source. Each answer says the file's size
and SHA-256 when the site gives them, so the download can be checked.
"""
import json
import re
import urllib.error
import urllib.parse
import urllib.request
try:
from ..civitai_client import USER_AGENT, _load_api_key
except ImportError: # loaded outside the package (tests)
from civitai_client import USER_AGENT, _load_api_key
CIVITAI_HOSTS = ("civitai.com", "civitai.red")
HF_HOSTS = ("huggingface.co", "hf-mirror.com")
OTHER_HOSTS = ("github.com",)
TIMEOUT = 15
SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
class SourceUnreachable(Exception):
"""The site did not answer (network, timeout, server error)."""
def host_of(url):
try:
parsed = urllib.parse.urlsplit(str(url).strip())
except ValueError:
return ""
return (parsed.hostname or "").lower() if parsed.scheme == "https" else ""
def _site(host, sites):
return any(host == site or host.endswith("." + site) for site in sites)
def allowed_download_url(url):
"""Whether `url` is an https link on a site downloads may start from."""
host = host_of(url)
return bool(host) and _site(host, CIVITAI_HOSTS + HF_HOSTS + OTHER_HOSTS)
def is_civitai(url):
return _site(host_of(url), CIVITAI_HOSTS)
def civitai_headers():
headers = {"User-Agent": USER_AGENT}
key = _load_api_key()
if key:
headers["Authorization"] = f"Bearer {key}"
return headers
def _get_json(url, headers):
"""The JSON at `url`; None on 404; SourceUnreachable when it could not be asked."""
try:
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=TIMEOUT) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
if error.code in (400, 404):
return None
raise SourceUnreachable(f"HTTP {error.code}") from error
except (urllib.error.URLError, OSError, ValueError) as error:
raise SourceUnreachable(str(error)) from error
def _civitai_page(version):
domain = "civitai.red" if (version.get("model") or {}).get("nsfw") or (version.get("nsfwLevel") or 1) > 1 else "civitai.com"
model_id = version.get("modelId")
return f"https://{domain}/models/{model_id}?modelVersionId={version.get('id')}" if model_id else ""
def _pick_civitai_file(files, file_hash="", name=""):
"""The version's file with this hash (SHA-256 or a prefix such as AutoV2), else this name; None if neither."""
files = [item for item in files or [] if isinstance(item, dict) and item.get("downloadUrl")]
wanted = str(file_hash or "").upper()
if wanted:
for item in files:
hashes = {str(value).upper() for value in (item.get("hashes") or {}).values()}
sha = str((item.get("hashes") or {}).get("SHA256") or "").upper()
if wanted in hashes or (len(wanted) >= 10 and sha.startswith(wanted)):
return item
return None
if name:
for item in files:
if str(item.get("name", "")).lower() == name.lower():
return item
return None
def _from_civitai_version(version, file_hash="", name=""):
picked = _pick_civitai_file(version.get("files"), file_hash, name)
if not picked:
return None
sha = str((picked.get("hashes") or {}).get("SHA256") or "")
return {
"found": True,
"source": "civitai",
"download_url": picked["downloadUrl"],
"file_name": picked.get("name") or "",
"size": int(float(picked.get("sizeKB") or 0) * 1024),
"sha256": sha.lower() if SHA256.match(sha) else "",
"format": ((picked.get("metadata") or {}).get("format") or ""),
"base_model": version.get("baseModel") or "",
"model_name": (version.get("model") or {}).get("name") or "",
"version_name": version.get("name") or "",
"page": _civitai_page(version),
}
def civitai_by_hash(file_hash):
version = _get_json(f"https://civitai.com/api/v1/model-versions/by-hash/{urllib.parse.quote(file_hash)}", civitai_headers())
return _from_civitai_version(version, file_hash) if version else None
def _civitai_version_id(url):
parsed = urllib.parse.urlsplit(url)
query = urllib.parse.parse_qs(parsed.query)
if query.get("modelVersionId"):
return query["modelVersionId"][0]
match = re.search(r"/api/download/models/(\d+)", parsed.path) or re.search(r"/model-versions/(\d+)", parsed.path)
return match.group(1) if match else ""
def civitai_by_link(url, name, file_hash=""):
"""A Civitai link that names a model version: its file of this hash or name."""
version_id = _civitai_version_id(url)
if not version_id.isdigit():
return None
version = _get_json(f"https://civitai.com/api/v1/model-versions/{version_id}", civitai_headers())
return _from_civitai_version(version, file_hash, name) if version else None
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def _hf_file_url(url):
"""https://<hf>/<repo>/resolve/<rev>/<path> for a blob or resolve link to a file, else ''."""
parsed = urllib.parse.urlsplit(url)
parts = [part for part in parsed.path.split("/") if part]
for marker in ("resolve", "blob"):
if marker in parts:
at = parts.index(marker)
if at >= 2 and len(parts) > at + 2:
parts[at] = "resolve"
return urllib.parse.urlunsplit(("https", parsed.netloc, "/" + "/".join(parts), "", ""))
return ""
def hugging_face_file(url):
"""A Hugging Face file link: its size and SHA-256 from the site's headers."""
file_url = _hf_file_url(url)
if not file_url:
return None
opener = urllib.request.build_opener(_NoRedirect)
request = urllib.request.Request(file_url, method="HEAD", headers={"User-Agent": USER_AGENT})
try:
response = opener.open(request, timeout=TIMEOUT)
headers, redirected = response.headers, False
except urllib.error.HTTPError as error:
if error.code in (401, 403):
return {"found": False, "reason": "gated", "page": url}
if error.code == 404:
return None
if error.code not in (301, 302, 303, 307, 308):
raise SourceUnreachable(f"HTTP {error.code}") from error
headers, redirected = error.headers, True
except (urllib.error.URLError, OSError) as error:
raise SourceUnreachable(str(error)) from error
etag = str(headers.get("x-linked-etag") or headers.get("etag") or "").strip('"').replace("W/", "").strip('"')
# A redirect's own length is not the file's: without x-linked-size (small files kept
# outside large-file storage) the size is unknown.
size = headers.get("x-linked-size") or (0 if redirected else headers.get("content-length")) or 0
return {
"found": True,
"source": "huggingface",
"download_url": file_url,
"file_name": urllib.parse.unquote(file_url.rsplit("/", 1)[-1]),
"size": int(size) if str(size).isdigit() else 0,
"sha256": etag.lower() if SHA256.match(etag) else "",
"format": "",
"base_model": "",
"model_name": "",
"version_name": "",
"page": url,
}
def plain_link(url):
"""A direct file link elsewhere (a GitHub release asset): no size or hash to check against."""
path = urllib.parse.urlsplit(url).path
if "/releases/download/" not in path:
return None
return {
"found": True, "source": "link", "download_url": url,
"file_name": urllib.parse.unquote(path.rsplit("/", 1)[-1]), "size": 0, "sha256": "",
"format": "", "base_model": "", "model_name": "", "version_name": "", "page": url,
}
def find_source(file_hash="", url="", name=""):
"""Where one missing model can come from: {found, source, download_url, file_name, size,
sha256, base_model, model_name, version_name, page}, or {found: False, reason}."""
looked = False
try:
if file_hash:
looked = True
found = civitai_by_hash(file_hash)
if found:
return found
if url and host_of(url):
looked = True
host = host_of(url)
if _site(host, CIVITAI_HOSTS):
found = civitai_by_link(url, name, file_hash)
elif _site(host, HF_HOSTS):
found = hugging_face_file(url)
elif _site(host, OTHER_HOSTS):
found = plain_link(url)
else:
found = None
if found:
return found
except SourceUnreachable as error:
return {"found": False, "reason": "network", "error": str(error)}
return {"found": False, "reason": "not_found" if looked else "no_source"}
+424
View File
@@ -0,0 +1,424 @@
"""Downloading the models the open workflow is missing (Model Check's "Download").
POST /anomalous/download/lookup {items: [{key, hash, url, value, types}]} -> where each can come
from (download_sources.find_source) and the model folders of its type: [{index, path, free,
subfolders}].
POST /anomalous/download/start {download_url, type, root, rel, sha256, size, ...} -> {id}
GET /anomalous/download/status -> {jobs}
POST /anomalous/download/cancel {id}
GET/POST /anomalous/download/settings -> where downloads go ({place, folder}), kept in
user/anomalous/download_settings.json.
One download runs at a time, into "<file>.part" next to where the file goes; an interrupted one
continues from there. The finished file is checked against the SHA-256 the workflow or the site
gives, and only then gets its name; a file that is already there is never replaced. The Civitai
key goes only to Civitai (not along a redirect to its file storage).
"""
import asyncio
import hashlib
import json
import os
import shutil
import threading
import time
import urllib.error
import urllib.request
import uuid
from aiohttp import web
import folder_paths
from .activity_log import add_entry, log_path
from .download_sources import SHA256, USER_AGENT, allowed_download_url, find_source, is_civitai, _load_api_key
from .path_utils import atomic_write_json, resolve_within
MODEL_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".sft", ".gguf")
CHUNK = 1024 * 1024
SPACE_MARGIN = 200 * 1024 * 1024
MAX_SUBFOLDERS = 300
KEEP_FINISHED = 50
DEFAULT_SETTINGS = {"place": "workflow", "folder": "Downloads"}
_lock = threading.Lock()
_jobs = {} # id -> job dict, in the order started
_queue = []
_worker = None
class DownloadError(Exception):
def __init__(self, code, message=""):
super().__init__(message or code)
self.code = code
async def _body(request):
"""The request's JSON object, or {} when it is not one (each route then refuses it)."""
try:
data = await request.json()
except ValueError:
return {}
return data if isinstance(data, dict) else {}
# --- settings ---------------------------------------------------------------------------
def settings_path():
return os.path.join(os.path.dirname(log_path()), "download_settings.json")
def read_settings():
settings = dict(DEFAULT_SETTINGS)
try:
with open(settings_path(), encoding="utf-8") as source:
saved = json.load(source)
if saved.get("place") in ("workflow", "folder"):
settings["place"] = saved["place"]
if isinstance(saved.get("folder"), str):
settings["folder"] = saved["folder"]
except (OSError, ValueError, AttributeError):
pass
return settings
def clean_folder(folder):
"""A relative folder ('' = the type's own folder), '/' separated; ValueError if it climbs out."""
parts = [part.strip() for part in str(folder or "").replace("\\", "/").split("/") if part.strip()]
for part in parts:
if part in (".", "..") or ":" in part or any(char in part for char in '<>"|?*'):
raise ValueError("Invalid folder")
return "/".join(parts)
async def api_settings(request):
if request.method == "POST":
data = await _body(request)
settings = read_settings()
if data.get("place") in ("workflow", "folder"):
settings["place"] = data["place"]
if "folder" in data:
try:
# {base} is filled with the model's base model when a download starts.
settings["folder"] = clean_folder(data.get("folder"))
except ValueError:
return web.json_response({"error": "Invalid folder"}, status=400)
atomic_write_json(settings_path(), settings)
return web.json_response(settings)
return web.json_response(read_settings())
# --- lookup -----------------------------------------------------------------------------
def folder_type_for(types):
known = getattr(folder_paths, "folder_names_and_paths", {})
for name in types or []:
if isinstance(name, str) and name in known:
return name
return ""
def _subfolders(root):
found = []
for current, dirs, _files in os.walk(root):
dirs[:] = sorted(name for name in dirs if not name.startswith("."))
depth = os.path.relpath(current, root).count(os.sep)
for name in dirs:
found.append(os.path.relpath(os.path.join(current, name), root).replace(os.sep, "/"))
if len(found) >= MAX_SUBFOLDERS:
return found
if depth >= 1:
dirs[:] = []
return found
def roots_of(folder_type):
"""The model folders of a type: [{index, path, exists, free, subfolders}]."""
roots = []
for index, path in enumerate(folder_paths.get_folder_paths(folder_type) or []):
exists = os.path.isdir(path)
probe = path if exists else os.path.dirname(path)
try:
free = shutil.disk_usage(probe).free if os.path.isdir(probe) else 0
except OSError:
free = 0
roots.append({"index": index, "path": path, "exists": exists, "free": free,
"subfolders": _subfolders(path) if exists else []})
return roots
def same_file(expected, sha256):
"""Whether a fingerprint (full SHA-256 or a prefix of 10+, AutoV2) names this SHA-256."""
expected, sha256 = str(expected or "").lower(), str(sha256 or "").lower()
return len(expected) >= 10 and sha256.startswith(expected)
def lookup_item(item):
file_hash = str(item.get("hash") or "").strip()
name = os.path.basename(str(item.get("value") or "").replace("\\", "/"))
found = find_source(file_hash, str(item.get("url") or "").strip(), name)
if found.get("found") and file_hash and found.get("sha256") and len(file_hash) >= 10 \
and all(c in "0123456789abcdefABCDEF" for c in file_hash) and not same_file(file_hash, found["sha256"]):
# The link names another file than the one the workflow was saved with.
return {"found": False, "reason": "different_file", "page": found.get("page", "")}
return found
async def api_lookup(request):
"""Items already looked up (`known`) or with nothing to look up by only get their type and
folders. Once a site does not answer, the rest are not asked (offline: no wait per model)."""
items = (await _body(request)).get("items")
if not isinstance(items, list):
return web.json_response({"error": "items must be a list"}, status=400)
results, types, offline = [], {}, False
for item in items[:100]:
if not isinstance(item, dict):
continue
folder_type = folder_type_for(item.get("types"))
if item.get("known"):
result = {"found": False, "reason": "known"}
elif not (item.get("hash") or item.get("url")):
result = {"found": False, "reason": "no_source"}
elif offline:
result = {"found": False, "reason": "network"}
else:
result = await asyncio.to_thread(lookup_item, item)
offline = result.get("reason") == "network"
result.update(key=item.get("key"), type=folder_type)
if folder_type and folder_type not in types:
types[folder_type] = await asyncio.to_thread(roots_of, folder_type)
results.append(result)
return web.json_response({"results": results, "roots": types, "settings": read_settings()})
# --- downloads --------------------------------------------------------------------------
def _public(job):
return {key: job[key] for key in ("id", "key", "state", "error", "received", "total", "rel", "type",
"root", "value", "file", "source", "verified")}
def plan_job(data):
"""A queued job from a start request, or ValueError / DownloadError saying why not."""
url = str(data.get("download_url") or "")
if not allowed_download_url(url):
raise DownloadError("bad_source")
folder_type = folder_type_for([data.get("type")])
if not folder_type:
raise DownloadError("bad_type")
paths = folder_paths.get_folder_paths(folder_type) or []
root_index = data.get("root", 0)
if not isinstance(root_index, int) or not 0 <= root_index < len(paths):
raise DownloadError("bad_root")
rel = clean_folder(data.get("rel"))
if not rel.lower().endswith(MODEL_EXTENSIONS):
raise DownloadError("bad_name")
root = os.path.realpath(paths[root_index])
dest = resolve_within(root, *rel.split("/"))
if os.path.exists(dest):
raise DownloadError("exists")
size = int(data.get("size") or 0)
probe = root
while probe and not os.path.isdir(probe):
probe = os.path.dirname(probe)
if size and probe and shutil.disk_usage(probe).free < size + SPACE_MARGIN:
raise DownloadError("no_space")
sha256 = str(data.get("sha256") or "").lower()
with _lock:
if any(job["dest"] == dest and job["state"] in ("queued", "running", "verifying") for job in _jobs.values()):
raise DownloadError("already")
return {
"id": uuid.uuid4().hex[:12], "key": str(data.get("key") or ""), "state": "queued", "error": "",
"received": 0, "total": size, "rel": rel, "type": folder_type, "root": root_index,
"value": str(data.get("value") or ""), "file": os.path.basename(dest), "dest": dest, "url": url,
"sha256": sha256 if SHA256.match(sha256) else "", "expected": str(data.get("hash") or "").lower(),
"source": str(data.get("source") or ""), "page": str(data.get("page") or ""),
"verified": False, "cancel": False, "finished": 0,
}
def _open(job, start):
headers = {"User-Agent": USER_AGENT}
if start:
headers["Range"] = f"bytes={start}-"
request = urllib.request.Request(job["url"], headers=headers)
key = _load_api_key() if is_civitai(job["url"]) else None
if key: # not carried along the redirect to the file storage
request.add_unredirected_header("Authorization", f"Bearer {key}")
try:
return urllib.request.urlopen(request, timeout=60)
except urllib.error.HTTPError as error:
if error.code in (401, 403):
raise DownloadError("needs_key" if is_civitai(job["url"]) and not key else "forbidden") from error
if error.code == 404:
raise DownloadError("gone") from error
if error.code == 416 and start:
raise DownloadError("restart") from error
raise DownloadError("network", f"HTTP {error.code}") from error
except (urllib.error.URLError, OSError) as error:
raise DownloadError("network", str(error)) from error
def _hash_existing(path, digest):
with open(path, "rb") as source:
for chunk in iter(lambda: source.read(CHUNK), b""):
digest.update(chunk)
def _fetch(job):
part = job["dest"] + ".part"
os.makedirs(os.path.dirname(job["dest"]), exist_ok=True)
digest = hashlib.sha256()
start = os.path.getsize(part) if os.path.isfile(part) else 0
try:
response = _open(job, start)
except DownloadError as error:
if error.code != "restart":
raise
start, response = 0, _open(job, 0)
with response:
if start and response.status != 206:
start = 0 # the site sends the whole file again
if "text/html" in str(response.headers.get("Content-Type", "")).lower():
raise DownloadError("not_a_file") # a login or error page, not the model
if start:
_hash_existing(part, digest)
length = int(response.headers.get("Content-Length") or 0)
job["total"] = start + length if length else job["total"]
job["received"] = start
with open(part, "ab" if start else "wb") as target:
while True:
if job["cancel"]:
raise DownloadError("cancelled")
chunk = response.read(CHUNK)
if not chunk:
break
target.write(chunk)
digest.update(chunk)
job["received"] += len(chunk)
return part, digest.hexdigest()
def _finish(job, part, sha256):
job["state"] = "verifying"
if job["total"] and job["received"] != job["total"]:
raise DownloadError("network", "incomplete")
expected = job["sha256"] or (job["expected"] if len(job["expected"]) >= 10 else "")
if expected and not same_file(expected, sha256):
os.remove(part) # our own unfinished download, not a file of the user's
raise DownloadError("hash_mismatch")
job["verified"] = bool(expected)
if os.path.exists(job["dest"]):
raise DownloadError("exists")
os.replace(part, job["dest"])
# ComfyUI's file lists and the browser's model metadata see the new file.
for cache in (getattr(folder_paths, "filename_list_cache", None), getattr(folder_paths, "cache_helper", None)):
try:
cache.clear()
except Exception:
pass
try:
from .metadata import clear_metadata_cache
clear_metadata_cache()
except Exception as error:
print(f"[Anomalous Browser] Download: could not clear the model cache: {error}")
def _record(job, sha256):
add_entry("file", "model_download", job["rel"], {"download": {
"type": job["type"], "path_idx": job["root"], "rel": job["rel"], "file": job["file"],
"size": job["received"], "sha256": sha256, "verified": job["verified"],
"source": job["source"], "page": job["page"], "value": job["value"],
}})
def run_job(job):
job["state"] = "running"
try:
part, sha256 = _fetch(job)
_finish(job, part, sha256)
job["state"] = "done"
try:
_record(job, sha256)
except Exception as error:
print(f"[Anomalous Browser] Download: could not record it in the activity log: {error}")
except DownloadError as error:
job["state"] = "cancelled" if error.code == "cancelled" else "failed"
job["error"] = error.code
if error.code == "cancelled" and os.path.isfile(job["dest"] + ".part"):
os.remove(job["dest"] + ".part") # our own unfinished download
if error.code not in ("cancelled",):
print(f"[Anomalous Browser] Download of {job['rel']} failed: {error}")
except Exception as error:
job["state"], job["error"] = "failed", "unknown"
print(f"[Anomalous Browser] Download of {job['rel']} failed: {error}")
job["finished"] = time.time()
def _work():
global _worker
while True:
with _lock:
job = next((item for item in _queue if item["state"] == "queued"), None)
if job is None:
_queue.clear()
_worker = None
return
_queue.remove(job)
run_job(job)
def enqueue(job):
global _worker
with _lock:
_jobs[job["id"]] = job
_queue.append(job)
finished = [item for item in _jobs.values() if item["finished"]]
for old in finished[:-KEEP_FINISHED]:
_jobs.pop(old["id"], None)
if _worker is None:
_worker = threading.Thread(target=_work, daemon=True, name="anomalous-download")
_worker.start()
async def api_start(request):
data = await _body(request)
if not data:
return web.json_response({"error": "bad_request"}, status=400)
try:
job = await asyncio.to_thread(plan_job, data)
except DownloadError as error:
return web.json_response({"error": error.code}, status=409 if error.code in ("exists", "already") else 400)
except (ValueError, TypeError) as error:
return web.json_response({"error": "bad_path", "message": str(error)}, status=400)
enqueue(job)
return web.json_response(_public(job))
async def api_status(request):
with _lock:
jobs = [_public(job) for job in _jobs.values()]
return web.json_response({"jobs": jobs})
async def api_cancel(request):
data = await _body(request)
with _lock:
job = _jobs.get(str(data.get("id") or ""))
if not job:
return web.json_response({"error": "unknown"}, status=404)
if job["state"] == "queued":
job["state"], job["error"], job["finished"] = "cancelled", "cancelled", time.time()
if job in _queue:
_queue.remove(job)
else:
job["cancel"] = True
return web.json_response(_public(job))
def register_routes(app):
app.router.add_post("/anomalous/download/lookup", api_lookup)
app.router.add_post("/anomalous/download/start", api_start)
app.router.add_get("/anomalous/download/status", api_status)
app.router.add_post("/anomalous/download/cancel", api_cancel)
app.router.add_get("/anomalous/download/settings", api_settings)
app.router.add_post("/anomalous/download/settings", api_settings)
+26
View File
@@ -177,6 +177,32 @@ server's own `/anomalous/scan_all`, `/anomalous_tts/characters`, `/prompt` and
`/history` over loopback, so they run exactly as from the UI (speech waits up to two
minutes for the file). Canvas changes are posted to the activity log with `via: "mcp"`.
## Model downloads
`api/model_download.py` serves Model Check's downloads. `POST /anomalous/download/lookup`
asks `api/download_sources.py` where each missing model comes from: Civitai by the
workflow's hash (the version's file with that SHA-256 or AutoV2), else a link the
workflow carries — a Civitai link naming a model version, a Hugging Face or hf-mirror
file link (size and SHA-256 from the site's headers), a GitHub release asset. A file
name alone is never a source, and a link whose file has another SHA-256 than the
workflow's hash is refused (`different_file`). Once a site does not answer, the rest of
the lookup is skipped (`network`). It also returns each type's model folders with free
space and subfolders (two levels).
`POST /anomalous/download/start` takes the source, type, root index and a relative path
and refuses: a non-https link or one off Civitai, Hugging Face, hf-mirror or GitHub; a
path leaving the root (`resolve_within`) or not ending in a model extension; a file that
exists; not enough space. Jobs run one at a time in a worker thread into
`<file>.part`; a new start for the same file continues it with a Range request. The
Civitai key is sent to Civitai only, as an unredirected header, so it never reaches the
file storage the download redirects to. An HTML answer (a login page) fails as
`not_a_file`, 401/403 as `needs_key` or `forbidden`. The finished file must match the
source's SHA-256 (else the workflow's hash) or it is removed as `hash_mismatch`; then it
is renamed into place, ComfyUI's file-list caches are cleared and the activity log gets
a `model_download` entry. `GET /anomalous/download/status` lists the jobs of this run,
`POST /anomalous/download/cancel` stops one (its `.part` goes). The default place is
`user/anomalous/download_settings.json` (`{place: workflow | folder, folder}`).
## Metadata and cache behavior
The output gallery keeps one ordered directory snapshot for at most ten seconds
+9 -1
View File
@@ -86,7 +86,15 @@ The main surfaces are:
missing-model recovery. `model_check.js` decides each workflow model's state and
applies fixes; `ui_doctor.js` (the page) and `ui_doctor_banner.js` (the bar shown
after a workflow opens, unless its "Don't show again" or Settings → Workflows turned the
check on opening off) only render it and call it on a press.
check on opening off) only render it and call it on a press. A missing model with a
certain source gets Download (`ui_model_download.js`; `model_download.js` holds the
logic): the dialog names, per file, the type's folder (a choice when there are several,
remembered per type in `localStorage`) and the subfolder from Settings → Model downloads
— where the workflow says (so the node needs no change) or a set folder, `{base}` there
being the model's base model, matched to an existing folder in any spelling — and the
file keeps the workflow's name. When a download ends, the model lists reload, nodes
still naming that model get the file (one Ctrl+Z step each), and once nothing is
downloading the new files are scanned in one targeted scan.
`ui_node_assistant.js` owns the current-node panel and model history, `ui_node_model_picker.js`
owns the native-widget model replacer, and `ui_node_parameters.js` with
`node_parameter_sets.js` own the panel's one list of saved values for the node type;
+6
View File
@@ -152,6 +152,12 @@ press puts them in: the bar's or the doctor page's for hash matches, the page's
per-row button for a size-only candidate. A scan with "fix the open workflow"
on puts hash matches in when it ends.
A model that is not here can be downloaded from the page when its source names one
exact file: Civitai by the workflow's hash, or a file link the workflow carries. The
download is checked against the SHA-256 before it gets its name, so a wrong file never
lands in a models folder; the file keeps the workflow's name, and nodes are pointed at
it only when it went to another folder than the workflow says.
Provenance-rich workflows skip the redundant full filename-to-hash cache refresh.
Legacy workflows without injected provenance may refresh it for compatibility.
This is an I/O optimization only and does not change identity evidence.
+2
View File
@@ -3,6 +3,7 @@ import { findWorkflowHashRecord } from './modules/recipe_provenance.js';
import { requiresHashForModelRecovery } from './modules/model_policies.js';
import { detectPlatform, isModelFilename } from './modules/model_source_links.js';
import { usableSourceUrl } from './modules/model_source_data.js';
import { absorbFingerprintChange } from './modules/canvas_history.js';
// Global cache for hashes: filename -> hash
window.anomalous_hash_cache = window.anomalous_hash_cache || {};
@@ -47,6 +48,7 @@ app.registerExtension({
window.anomalous_is_empty_state = true;
console.warn("[Anomalous] Failed to fetch hashes", e);
}
absorbFingerprintChange(app);
};
// Pre-fetch all hashes on startup so that dragging generated images (without opening UI) still intercepts
+15
View File
@@ -34,6 +34,7 @@ const ACTION_KEYS = {
voice_import: 'activityVoiceImport',
voice_settings: 'activityVoiceSettings',
voice_storage: 'activityVoiceStorage',
model_download: 'activityModelDownload',
};
async function request(url, options) {
@@ -80,6 +81,11 @@ export function entrySummary(entry) {
return entry.target ? t('activityWithTarget', { action: words, target: entry.target }) : words;
}
function formatSize(bytes) {
const value = Number(bytes) || 0;
return value >= 1024 ** 3 ? `${(value / 1024 ** 3).toFixed(2)} GB` : `${Math.max(0.1, value / 1024 ** 2).toFixed(1)} MB`;
}
const FIELD_KEYS = {
custom_name: 'modelSourceFieldName', custom_notes: 'modelSourceFieldNotes',
source_url: 'modelSourceFieldLink', filename: 'activityFieldFilename',
@@ -95,6 +101,15 @@ export function fileDetailLines(entry) {
const lines = [];
if (detail.scan?.civitai_down) lines.push({ text: t('scanResultCivitaiDown') });
for (const error of detail.scan?.errors || []) lines.push({ text: error });
const download = detail.download;
if (download) {
lines.push({
text: t('activityDownloadLine', { file: download.rel || download.file, size: formatSize(download.size), source: download.source || '?' }),
model: { type: download.type, path_idx: download.path_idx, rel: download.rel, filename: download.file },
});
lines.push({ text: t(download.verified ? 'activityDownloadChecked' : 'activityDownloadUnchecked') });
if (download.value && download.value !== download.rel) lines.push({ text: t('activityDownloadFor', { value: download.value }) });
}
for (const item of detail.files || []) {
if (typeof item === 'string') lines.push({ text: t('activityFileMoved', { name: item }) });
else lines.push({ text: `${STATUS_MARKS[item.status] || '·'} ${item.filename}: ${fileOutcome(item)}`, model: item.rel ? item : null });
+34 -1
View File
@@ -2,7 +2,8 @@
* ComfyUI's Ctrl+Z history. It takes a step on its own events (a mouse button or key let
* go); a canvas write made by a drop (no mouse-up follows a drag), in a click handler or
* after a request finishes would otherwise land in no step, and Ctrl+Z would skip it.
* Call after every canvas write. Takes `app` so pure modules can use it.
* Call after every canvas write. Takes `app` so pure modules can use it. Also keeps a change of
* fingerprints alone (absorbFingerprintChange) out of the history.
*/
export function recordCanvasStep(app) {
@@ -14,3 +15,35 @@ export function recordCanvasStep(app) {
console.warn('[AMB] Ctrl+Z history: step not recorded.', error);
}
}
// What a saved workflow carries from the fingerprint cache (hash_resolver.js), not the canvas.
const DERIVED = ['anomalous_hashes', 'anomalous_model_sources'];
function withoutDerived(state) {
const extra = { ...(state?.extra || {}) };
for (const key of DERIVED) delete extra[key];
return { ...state, extra };
}
/**
* After the fingerprint cache changes (a scan, a download), the workflow serializes with
* other fingerprints although nothing on the canvas changed; ComfyUI would take that as a
* step, and the next Ctrl+Z would seem to do nothing. When that is the only difference, the
* history's current state takes it without a step.
*/
export function absorbFingerprintChange(app) {
try {
const tracker = app?.extensionManager?.workflow?.activeWorkflow?.changeTracker;
const graph = app?.rootGraph || app?.graph;
if (!tracker?.activeState || typeof graph?.serialize !== 'function') return;
const current = JSON.parse(JSON.stringify(graph.serialize()));
const a = withoutDerived(tracker.activeState);
const b = withoutDerived(current);
const same = typeof tracker.constructor?.graphEqual === 'function'
? tracker.constructor.graphEqual(a, b)
: JSON.stringify(a) === JSON.stringify(b);
if (same) tracker.activeState = current;
} catch (error) {
console.warn('[AMB] Ctrl+Z history: could not take the new fingerprints in.', error);
}
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Where a downloaded model goes inside its type's models folder (Model Check's downloads;
* model_download.js, ui_model_download.js). The download settings say: where the workflow
* says (its own subfolder, so the node needs no change; a bare name goes into the settings'
* folder) or always the settings' folder, `{base}` there standing for the model's base model
* (an existing folder of that name, in any spelling, is used). The file keeps the workflow's
* name. No imports, so it is tested on its own.
*/
const UNSAFE_FORMATS = /\.(ckpt|pt|pth|bin)$/i;
// Civitai's base models -> the folder name most people use for them.
const BASE_FAMILIES = [
[/^sd ?1\.[45]/i, 'SD1.5'], [/^sdxl/i, 'SDXL'], [/^sd ?3/i, 'SD3'], [/^pony/i, 'Pony'],
[/^illustrious/i, 'Illustrious'], [/^noob/i, 'NoobAI'], [/^flux/i, 'Flux'], [/^hunyuan/i, 'Hunyuan'],
[/^wan/i, 'Wan'], [/^qwen/i, 'Qwen'], [/^hidream/i, 'HiDream'], [/^chroma/i, 'Chroma'],
];
const slashes = (value) => String(value || '').replace(/\\/g, '/');
const fileName = (value) => slashes(value).split('/').pop();
const dirName = (value) => slashes(value).split('/').slice(0, -1).join('/');
const squash = (value) => String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '');
/** The folder name `{base}` stands for among `siblings` (the folders beside it): one of them
* that names the model's base model, in any spelling, else the base model's usual name. */
export function baseFolder(baseModel, siblings = []) {
const base = String(baseModel || '').trim();
if (!base) return '';
const family = BASE_FAMILIES.find(([pattern]) => pattern.test(base))?.[1] || base.replace(/[<>:"/\\|?*]+/g, ' ').replace(/ +/g, ' ').trim();
const names = new Set([squash(family), squash(base)]);
return siblings.find(name => names.has(squash(name))) || family;
}
/** The settings' folder with `{base}` filled in (dropped when the base model is unknown);
* `subfolders` are the type folder's existing ones ("a", "a/b"). */
export function expandFolder(folder, baseModel, subfolders = []) {
const parts = [];
for (const raw of slashes(folder).split('/')) {
let part = raw.trim();
if (/\{base\}/i.test(part)) {
const prefix = parts.join('/').toLowerCase();
const siblings = subfolders.filter(path => dirName(path).toLowerCase() === prefix).map(fileName);
part = part.replace(/\{base\}/gi, baseFolder(baseModel, siblings)).trim();
}
if (part) parts.push(part);
}
return parts.join('/');
}
/** Where in its type's folder an entry's file goes: "sub/folder/name.ext", '/' separated. */
export function destinationFor(entry, info, settings, subfolders = []) {
const name = fileName(entry.value);
const own = dirName(entry.value);
if (settings?.place !== 'folder' && own) return `${own}/${name}`;
const folder = expandFolder(settings?.folder ?? 'Downloads', info?.base_model, subfolders);
return folder ? `${folder}/${name}` : name;
}
/** Whether a file of this kind can carry code that runs when it is loaded. */
export const unsafeFormat = (value) => UNSAFE_FORMATS.test(String(value || ''));
+110 -2
View File
@@ -680,7 +680,7 @@ export const i18n = {
doctorSources: '模型去哪下载',
doctorNoModels: '当前工作流没有用到模型文件。',
doctorAllHere: '{count} 个模型都在本机,可以直接运行。',
doctorHowItWorks: '“换上”只会用同一个文件(指纹一致)。其余的请自己挑一个本地模型,或者去 Civitai 下载。',
doctorHowItWorks: '“换上”只会用同一个文件(指纹一致)。缺的模型能确定来源的(C 站按指纹找到、或工作流带着文件链接)可以直接下载;其余的请自己挑一个本地模型,或者去 Civitai 找。',
doctorToHandle: '需要处理',
doctorReplaced: '刚换上的',
doctorWas: '原来是 {file}',
@@ -701,6 +701,44 @@ export const i18n = {
doctorWhyChanged: '文件还在,但内容和保存工作流时不一样(可能换成了别的版本)。ComfyUI 会照常使用它。',
doctorWhyNotHere: '按指纹在本机找过,没有这个文件。',
doctorWhyNoRecord: '工作流里没有记下它的指纹,没法自动找,只能自己挑或下载。',
downloadLooking: '查找下载来源…',
downloadOne: '下载 · {size}',
downloadOneNoSize: '下载',
downloadAll: '下载全部 {count} 个({size})',
downloadNoNetwork: '连不上下载网站,暂时查不到来源。',
downloadDifferentFile: '工作流里的链接指向的不是同一个文件,没有提供下载。',
downloadGated: '这个文件要先在 Hugging Face 上登录并同意使用条款,请到网页上下载。',
downloadTitle: '下载缺失的模型',
downloadTitleMany: '下载 {count} 个缺失的模型',
downloadSaveTo: '保存到',
downloadRootNew: '(还没有这个文件夹,会新建)',
downloadUnsafeFormat: '⚠ 这是 .ckpt / .pt 一类的格式,加载时可能运行文件里夹带的代码。只下载你信任的来源。',
downloadNoCheck: 'ⓘ 这个来源没有给出文件指纹,下完没法核对是不是同一个文件。',
downloadSpace: '这次要下载 {need},磁盘剩余 {free}。',
downloadRules: '默认保存到哪里,在 设置 → 模型下载 里改',
downloadStart: '开始下载',
downloadQueued: '排队中…',
downloadVerifying: '核对文件…',
downloadProgress: '{percent}% · {done} / {total}',
downloadRetry: '重试',
downloadSetKey: '填 API Key',
downloadDone: '已下载:{name}',
downloadDonePutIn: '已下载并换上:{name}',
downloadError_network: '下载中断了(网络问题)。点重试会从断开的地方接着下。',
downloadError_needs_key: '这个模型要登录 Civitai 才能下载:填上你的 Civitai API Key 再点下载。',
downloadError_forbidden: '网站拒绝了下载(可能要登录或没有权限)。',
downloadError_gone: '网站上已经没有这个文件了。',
downloadError_hash_mismatch: '下载到的文件和工作流记录的指纹对不上,已经丢弃,没有放进模型文件夹。',
downloadError_exists: '那里已经有同名文件了,不会覆盖。换个位置或文件名。',
downloadError_already: '这个文件已经在下载了。',
downloadError_no_space: '磁盘空间不够。',
downloadError_not_a_file: '网站返回的是网页而不是模型文件(多半要登录)。',
downloadError_bad_name: '文件名要以模型格式结尾,比如 .safetensors。',
downloadError_bad_path: '这个位置不行:只能放在模型文件夹里面。',
downloadError_bad_source: '这个下载来源不在允许的网站里。',
downloadError_bad_type: '认不出这个模型该放进哪类文件夹。',
downloadError_bad_root: '这个模型文件夹不存在了。',
downloadError_unknown: '下载失败了,详细原因在 ComfyUI 的控制台里。',
doctorProof: '指纹',
doctorProofSaved: '工作流里记的',
doctorProofLocal: '本机同名文件',
@@ -932,6 +970,11 @@ export const i18n = {
activityVoiceImport: "导入角色",
activityVoiceSettings: "修改角色设置",
activityVoiceStorage: "更改角色存放位置",
activityModelDownload: "下载模型",
activityDownloadLine: "{file}({size},来自 {source})",
activityDownloadChecked: "✓ 指纹核对一致",
activityDownloadUnchecked: "来源没有指纹,没有核对",
activityDownloadFor: "工作流里写的是 {value}",
sidebarToggle: '收起/展开列表',
sidebarDoctor: '模型检查',
sidebarAssistant: '当前节点',
@@ -975,6 +1018,17 @@ export const i18n = {
settingsWorkflows: "工作流",
settingsCheckOnOpen: '打开工作流时检查缺失模型',
settingsCheckOnOpenHelp: '打开或拖进工作流时,缺模型就在画布上方提示,本机有同一个文件的可以一键换上。关掉后,要检查就点左侧的模型检查。',
settingsDownloads: '模型下载',
settingsDownloadPlace: '缺失的模型下载到',
settingsDownloadPlaceHelp: '“工作流写的位置”:按工作流里记的子文件夹放,下完节点不用改;工作流没写子文件夹的,放进下面这个文件夹。“固定文件夹”:总是放进下面这个文件夹,下完自动把节点改到新位置(可以 Ctrl+Z)。每次下载前,确认框里还能改。',
settingsDownloadPlaceWorkflow: '工作流写的位置',
settingsDownloadPlaceFolder: '固定文件夹',
settingsDownloadFolder: '文件夹',
settingsDownloadFolderHelp: '在各类模型自己的文件夹里面,比如 loras 下的 Downloads。写 {base} 会换成模型的底模,比如 SDXL、Flux、Illustrious;已经有这个名字的文件夹(不分大小写和符号)就用它。留空就放在类型文件夹本身。',
settingsDownloadFolderRoot: '(类型文件夹本身)',
settingsDownloadKey: 'Civitai API Key',
settingsDownloadKeyHelp: '有些模型要登录 Civitai 才能下载,填上你的 Key 就能下。只保存在本机。',
settingsDownloadKeySet: '设置…',
settingsOpening: "打开方式与窗口",
settingsEntryHelp: "在哪里打开这个浏览器。快捷键在 ComfyUI 设置 → Anomalous Model Browser 里设置。",
settingsLayout: "窗口布局",
@@ -2616,7 +2670,7 @@ export const i18n = {
doctorSources: 'Where to download',
doctorNoModels: 'The open workflow uses no model files.',
doctorAllHere: 'All {count} models are on this computer; the workflow can run.',
doctorHowItWorks: '"Put in" only uses the same file (same fingerprint). For the others, pick a local model yourself or download it from Civitai.',
doctorHowItWorks: '"Put in" only uses the same file (same fingerprint). A missing model whose source is certain (found on Civitai by its fingerprint, or a file link the workflow carries) can be downloaded right here; for the others, pick a local model yourself or look on Civitai.',
doctorToHandle: 'To handle',
doctorReplaced: 'Just put in',
doctorWas: 'Was {file}',
@@ -2637,6 +2691,44 @@ export const i18n = {
doctorWhyChanged: 'The file is there, but its content differs from when the workflow was saved (maybe another version). ComfyUI uses it as usual.',
doctorWhyNotHere: 'Looked for its fingerprint on this computer: not here.',
doctorWhyNoRecord: 'The workflow has no fingerprint for it, so it cannot be found automatically. Pick one or download it.',
downloadLooking: 'Looking for a download…',
downloadOne: 'Download · {size}',
downloadOneNoSize: 'Download',
downloadAll: 'Download all {count} ({size})',
downloadNoNetwork: 'Cannot reach the download sites right now.',
downloadDifferentFile: 'The workflow\'s link points to a different file, so it is not offered.',
downloadGated: 'This file needs a Hugging Face login and accepting its terms; download it on the page.',
downloadTitle: 'Download the missing model',
downloadTitleMany: 'Download {count} missing models',
downloadSaveTo: 'Save to',
downloadRootNew: '(not there yet, will be created)',
downloadUnsafeFormat: '⚠ A .ckpt / .pt kind of file can run code it carries when it loads. Only download it from a source you trust.',
downloadNoCheck: 'ⓘ The source gives no fingerprint, so the file cannot be checked after downloading.',
downloadSpace: 'Downloading {need}; {free} free on the disk.',
downloadRules: 'Change where downloads go by default in Settings → Model downloads',
downloadStart: 'Download',
downloadQueued: 'Waiting…',
downloadVerifying: 'Checking the file…',
downloadProgress: '{percent}% · {done} / {total}',
downloadRetry: 'Retry',
downloadSetKey: 'Set API key',
downloadDone: 'Downloaded: {name}',
downloadDonePutIn: 'Downloaded and put in: {name}',
downloadError_network: 'The download broke off (network). Retry continues where it stopped.',
downloadError_needs_key: 'This model needs a Civitai login: set your Civitai API key, then download again.',
downloadError_forbidden: 'The site refused the download (a login or permission may be needed).',
downloadError_gone: 'The site no longer has this file.',
downloadError_hash_mismatch: 'The downloaded file does not match the workflow\'s fingerprint; it was thrown away, not put into the models folder.',
downloadError_exists: 'A file of that name is already there and is never replaced. Choose another place or name.',
downloadError_already: 'This file is already downloading.',
downloadError_no_space: 'Not enough disk space.',
downloadError_not_a_file: 'The site sent a web page instead of the model (probably a login).',
downloadError_bad_name: 'The name must end in a model format such as .safetensors.',
downloadError_bad_path: 'Not a place it can go: only inside the models folder.',
downloadError_bad_source: 'This download source is not one of the allowed sites.',
downloadError_bad_type: 'Cannot tell which kind of models folder this goes into.',
downloadError_bad_root: 'That models folder no longer exists.',
downloadError_unknown: 'The download failed; the ComfyUI console says why.',
doctorProof: 'Fingerprint',
doctorProofSaved: 'In the workflow',
doctorProofLocal: 'Local file of that name',
@@ -2868,6 +2960,11 @@ export const i18n = {
activityVoiceImport: "Imported character",
activityVoiceSettings: "Changed character settings",
activityVoiceStorage: "Changed where characters are kept",
activityModelDownload: "Downloaded a model",
activityDownloadLine: "{file} ({size}, from {source})",
activityDownloadChecked: "✓ Fingerprint matches",
activityDownloadUnchecked: "The source gave no fingerprint; not checked",
activityDownloadFor: "The workflow named it {value}",
sidebarToggle: 'Show or hide the list',
sidebarDoctor: 'Model Check',
sidebarAssistant: 'Current node',
@@ -2911,6 +3008,17 @@ export const i18n = {
settingsWorkflows: "Workflows",
settingsCheckOnOpen: 'Check for missing models when a workflow opens',
settingsCheckOnOpenHelp: 'When you open or drop in a workflow, a bar over the canvas says which models are missing and puts in the ones this computer has under another name in one press. Turned off, run Model Check from the rail when you want it.',
settingsDownloads: 'Model downloads',
settingsDownloadPlace: 'Download missing models to',
settingsDownloadPlaceHelp: '"Where the workflow says": into the subfolder the workflow names, so the node needs no change; a model without one goes into the folder below. "A set folder": always into the folder below, and the node is changed to the new place afterwards (Ctrl+Z undoes it). The dialog before each download can still change it.',
settingsDownloadPlaceWorkflow: 'Where the workflow says',
settingsDownloadPlaceFolder: 'A set folder',
settingsDownloadFolder: 'Folder',
settingsDownloadFolderHelp: 'Inside each kind\'s own models folder, such as Downloads under loras. {base} becomes the model\'s base model, such as SDXL, Flux or Illustrious; a folder of that name you already have (any case or punctuation) is used. Empty means the kind\'s folder itself.',
settingsDownloadFolderRoot: '(the kind\'s folder itself)',
settingsDownloadKey: 'Civitai API key',
settingsDownloadKeyHelp: 'Some models need a Civitai login to download; your key lets them. Kept on this computer only.',
settingsDownloadKeySet: 'Set…',
settingsOpening: "Opening and window",
settingsEntryHelp: "Where you open this browser from. Shortcuts are set in ComfyUI's settings under Anomalous Model Browser.",
settingsLayout: "Window layout",
+1
View File
@@ -195,6 +195,7 @@ export function applyModelFix(entry, target = entry.target, { step = true } = {}
// ComfyUI clears its own missing-model mark through this hook.
node.onWidgetChanged?.(widget.name, target, value, widget);
if (app.lastNodeErrors?.[node.id]) delete app.lastNodeErrors[node.id];
if (target === value) return true; // the same name, now found (a download): nothing to undo
markReplaced(widget, value, target);
// Without a step ComfyUI keeps the old name: a reload or Ctrl+Z brings the missing model back.
if (step) recordCanvasStep(app);
+194
View File
@@ -0,0 +1,194 @@
/**
* Downloading the models the open workflow is missing, behind Model Check's "Download"
* (api/model_download.py does the downloading; ui_model_download.js shows it, and
* download_places.js says where a file goes). No DOM.
*
* When a download ends, the model lists reload, every node still
* naming that model gets the file (one Ctrl+Z step each), and the files of the batch are
* scanned for their information and covers once nothing is downloading.
*/
import { app } from "../../../scripts/app.js";
import { translate as t } from './locales.js';
import { applyModelFix } from './model_check.js';
import { inferModelFolderTypes } from './model_policies.js';
import { startScan, targetsForItems } from './scan_runner.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
const POLL_MS = 1000;
const ACTIVE = new Set(['queued', 'running', 'verifying']);
const slashes = (value) => String(value || '').replace(/\\/g, '/');
const fileName = (value) => slashes(value).split('/').pop();
const jobs = new Map(); // id -> latest status
const listeners = new Set();
const lookups = new Map(); // lookup key -> result
let pollTimer = 0;
let batch = [];
let scanOwner = null;
async function json(url, options) {
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(data.error || `HTTP ${response.status}`);
error.code = data.error || 'http';
throw error;
}
return data;
}
const post = (url, body) => json(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
/** Where downloads go: { place: 'workflow' | 'folder', folder }. */
export const fetchDownloadSettings = () => json('/anomalous/download/settings');
export const saveDownloadSettings = (changes) => post('/anomalous/download/settings', changes);
/** The link a shared workflow keeps for a model: AMB's, else ComfyUI's own (`properties.models`). */
export function workflowLink(entry) {
const value = entry.value;
const kept = app.graph?.extra?.anomalous_model_sources || {};
const saved = kept[value] || kept[slashes(value)] || kept[fileName(value)] || kept[`${entry.node.id}_${value}`];
const url = typeof saved === 'string' ? saved : saved?.url;
if (url) return url;
const name = fileName(value).toLowerCase();
const native = [
...(entry.node.properties?.models || []),
...(app.extensionManager?.workflow?.activeWorkflow?.activeState?.models || []),
];
return native.find(model => String(model?.name || '').split(/[\\/]/).pop().toLowerCase() === name)?.url || '';
}
const lookupKey = (item) => `${item.hash}|${item.url}|${item.value}`;
/**
* Where each entry can be downloaded from: { found: Map entry -> { found, source, size, sha256,
* base_model, page, type, roots, reason… }, settings }. Sources are asked once per page load
* (a network failure again next time); the model folders (free space, subfolders) every time.
*/
export async function lookupDownloads(entries) {
const items = entries.map((entry, index) => {
const item = { key: String(index), hash: entry.record?.hash || '', url: workflowLink(entry), value: entry.value };
return { ...item, types: inferModelFolderTypes(entry.node, entry.widget), known: lookups.has(lookupKey(item)) };
});
const data = await post('/anomalous/download/lookup', { items });
const found = new Map();
for (const result of data.results || []) {
const item = items.find(candidate => candidate.key === result.key);
if (!item) continue;
let info = item.known ? lookups.get(lookupKey(item)) : result;
if (!item.known && result.reason !== 'network' && (item.hash || item.url)) lookups.set(lookupKey(item), result);
if (!info || (!item.hash && !item.url)) continue;
info = { ...info, type: result.type, roots: data.roots?.[result.type] || [], link: item.url };
found.set(entries[Number(item.key)], info);
}
return { found, settings: data.settings || { place: 'workflow', folder: 'Downloads' } };
}
function notify() {
for (const listener of listeners) {
try { listener(jobs); } catch (error) { console.warn('[AMB] Download: a listener failed.', error); }
}
}
/** Calls `listener(jobs)` whenever a download moves on; returns the way to stop. */
export function watchDownloads(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** The newest download of a workflow value (its model), if any. */
export function downloadFor(value) {
let latest = null;
for (const job of jobs.values()) if (job.value === value) latest = job;
return latest;
}
export const downloadsActive = () => [...jobs.values()].some(job => ACTIVE.has(job.state));
export const doneCount = () => [...jobs.values()].filter(job => job.state === 'done').length;
/** Takes in the downloads already running (started before the page reloaded). */
export async function syncDownloads(owner) {
const latest = (await json('/anomalous/download/status')).jobs || [];
for (const job of latest) if (!jobs.has(job.id)) jobs.set(job.id, job);
if (downloadsActive()) {
scanOwner = scanOwner || owner;
poll();
}
}
/** Starts (or continues) one download; `choice` = { root, rel }. Rejects with `.code` when refused. */
export async function startDownload(owner, entry, info, choice) {
scanOwner = owner;
const job = await post('/anomalous/download/start', {
key: entry.value, value: entry.value, download_url: info.download_url, type: info.type,
root: choice.root, rel: choice.rel, size: info.size || 0, sha256: info.sha256 || '',
hash: entry.record?.hash || '', source: info.source, page: info.page || '',
});
jobs.set(job.id, job);
notify();
poll();
return job;
}
export async function cancelDownload(id) {
const job = await post('/anomalous/download/cancel', { id });
jobs.set(job.id, job);
notify();
}
function poll() {
if (pollTimer) return;
pollTimer = setTimeout(async () => {
pollTimer = 0;
let latest = [];
try {
latest = (await json('/anomalous/download/status')).jobs || [];
} catch (error) {
console.warn('[AMB] Download: status failed.', error);
}
for (const job of latest) {
const before = jobs.get(job.id);
jobs.set(job.id, job);
if (job.state === 'done' && before && before.state !== 'done') await afterDownload(job);
}
notify();
if (downloadsActive()) poll();
else scanBatch();
}, POLL_MS);
}
/** ComfyUI's drop-downs (the server already cleared its file-list caches). */
async function reloadModelLists() {
try {
await app.refreshComboInNodes?.();
} catch (error) {
console.warn('[AMB] Download: could not reload the model lists.', error);
}
}
/** Puts the new file into every node still naming the model it was downloaded for. */
async function afterDownload(job) {
await reloadModelLists();
const rel = slashes(job.rel);
let put = 0;
for (const node of app.graph?._nodes || []) {
for (const widget of node.widgets || []) {
if (widget.type !== 'combo' || widget.value !== job.value) continue;
const target = (widget.options?.values || []).find(value => typeof value === 'string' && slashes(value) === rel);
// Same place as the workflow says: nothing changes, ComfyUI's missing mark goes.
if (target && applyModelFix({ node, widget, value: job.value }, target) && target !== job.value) put += 1;
}
}
showWorkbenchToast(t(put ? 'downloadDonePutIn' : 'downloadDone', { name: job.file }));
batch.push({ type: job.type, path_idx: job.root, rel, filename: job.file });
}
/** The downloaded files' information and covers: one scan of the whole batch. */
async function scanBatch() {
if (!batch.length || !scanOwner) return;
const items = batch;
batch = [];
await startScan(scanOwner, { targets: targetsForItems(items) });
}
+14 -4
View File
@@ -2,13 +2,14 @@
* Model Check, the tool page: the models the open workflow uses and what is wrong with each
* (model_check.js), with what can be done about it: put in the same file found under
* another name or folder, take or leave a file of the same name or size, pick one by hand (the
* node model picker) or look it up on Civitai. Nothing in the workflow changes without a
* press here. Uses the scan page's layout classes (18-scan-page.css).
* node model picker), download it when its source is known (ui_model_download.js) or look it
* up on Civitai. Nothing in the workflow changes without a press here. Uses the scan page's layout classes (18-scan-page.css).
*/
import { translate as t } from './locales.js';
import { applyModelFix, checkWorkflowModels, fixWorkflowModels, isProblem, markReplaced, replacedFrom } from './model_check.js';
import { updateDoctorBanner } from './ui_doctor_banner.js';
import { fillDownloads } from './ui_model_download.js';
const MODEL_EXT = /\.(safetensors|ckpt|pt|bin|pth|sft|gguf)$/i;
const CANDIDATE_WHY = { size: 'doctorWhyCandidate', name: 'doctorWhySameName', 'name-size': 'doctorWhyLikely' };
@@ -113,7 +114,7 @@ function pickByHand(owner, panel, entry) {
});
}
function problemRow(owner, panel, entry) {
function problemRow(owner, panel, entry, downloads) {
const row = el('div', `anomalous-scan-row anomalous-doctor-row is-${entry.state}`);
const copy = el('div', 'anomalous-scan-row-copy');
copy.append(
@@ -127,6 +128,12 @@ function problemRow(owner, panel, entry) {
if (!applyModelFix(entry)) alert(t('doctorChangedMeanwhile'));
renderDoctorPage(owner, panel);
});
if (entry.state === 'missing') {
// Filled once its source is known (ui_model_download.js).
const slot = el('span', 'anomalous-download-slot');
downloads.set(entry, slot);
actions.append(slot);
}
if (entry.state === 'fixable') actions.append(put('doctorPutIn'));
if (entry.state === 'candidate') actions.append(put('doctorUseCandidate'));
if (entry.state !== 'fixable') {
@@ -221,7 +228,8 @@ export async function renderDoctorPage(owner, panel, { refresh = false } = {}) {
: t('doctorHowItWorks');
page.append(el('p', 'anomalous-scan-muted', verdict));
if (problems.length) page.append(card(t('doctorToHandle'), problems.map(entry => problemRow(owner, panel, entry))));
const downloads = new Map();
if (problems.length) page.append(card(t('doctorToHandle'), problems.map(entry => problemRow(owner, panel, entry, downloads))));
if (hint) page.append(hint);
if (replaced.length) {
page.append(card(t('doctorReplaced'), replaced.map(entry => simpleRow(entry, t('doctorWas', { file: replacedFrom(entry.widget) })))));
@@ -235,6 +243,8 @@ export async function renderDoctorPage(owner, panel, { refresh = false } = {}) {
page.append(fold);
}
panel.replaceChildren(page);
fillDownloads(owner, panel, downloads, actions, () => renderDoctorPage(owner, panel))
.catch(error => console.warn('[AMB] Doctor: downloads unavailable.', error));
}
/** The rail's doctor button, Home's card and the canvas banner's "Show". */
+285
View File
@@ -0,0 +1,285 @@
/**
* Model Check's downloads, on its page (ui_doctor.js): each missing model with a source gets
* "Download" (and the page "Download all"); a dialog shows, per file, where it will be saved —
* the folder of its type (a choice when there are several) and the subfolder the download
* settings give, both changeable — its size, source and anything to know (a format that can
* carry code, nothing to check it against), and the free space. While a file downloads its
* row shows how far, with Cancel; a failed one says why, with Retry (it continues where it
* stopped). model_download.js does the work.
*/
import { translate as t } from './locales.js';
import { destinationFor, unsafeFormat } from './download_places.js';
import {
cancelDownload, doneCount, downloadFor, lookupDownloads, startDownload, syncDownloads, watchDownloads,
} from './model_download.js';
import { saveApiKey } from './ui_scan_page.js';
import { openSettingsPage } from './ui_settings_page.js';
const ROOT_KEY = 'anomalous_download_root_';
const ACTIVE = ['queued', 'running', 'verifying'];
const SOURCES = { civitai: 'Civitai', huggingface: 'Hugging Face', link: 'GitHub' };
const NOT_FOUND = { network: 'downloadNoNetwork', different_file: 'downloadDifferentFile', gated: 'downloadGated' };
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
return node;
}
export function formatSize(bytes) {
const value = Number(bytes) || 0;
if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(2)} GB`;
if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MB`;
return `${Math.max(1, Math.round(value / 1024))} KB`;
}
const fileName = (value) => String(value).split(/[\\/]/).pop();
function rememberedRoot(type, roots) {
let index = 0;
try { index = Number(localStorage.getItem(ROOT_KEY + type) || 0); } catch { /* the first one */ }
return roots.some(root => root.index === index) ? index : (roots.find(root => root.exists)?.index ?? 0);
}
function rememberRoot(type, index) {
try { localStorage.setItem(ROOT_KEY + type, String(index)); } catch { /* not kept */ }
}
const joinPath = (root, rel) => `${String(root).replace(/[\\/]+$/, '')}${String(root).includes('\\') ? '\\' : '/'}${
String(root).includes('\\') ? rel.replace(/\//g, '\\') : rel}`;
/** One file's part of the dialog; `read()` gives { root, rel } as chosen. */
function dialogItem(entry, info, settings) {
// ComfyUI also lists folders that do not exist (output/loras…): offered only when none does.
const existing = (info.roots || []).filter(root => root.exists);
const roots = existing.length ? existing : (info.roots || []).slice(0, 1);
const box = el('div', 'anomalous-download-item');
const head = el('div', 'anomalous-download-head');
head.append(el('strong', 'anomalous-download-name', fileName(entry.value)));
const facts = [SOURCES[info.source] || info.source, info.size ? formatSize(info.size) : '', info.base_model].filter(Boolean);
head.append(el('span', 'anomalous-download-facts', facts.join(' · ')));
box.append(head);
if (info.model_name) box.append(el('div', 'anomalous-download-model', [info.model_name, info.version_name].filter(Boolean).join(' — ')));
let root = roots.find(item => item.index === rememberedRoot(info.type, roots)) || roots[0] || { index: 0, path: '', subfolders: [] };
const line = el('label', 'anomalous-download-where');
line.append(el('span', 'anomalous-download-label', t('downloadSaveTo')));
if (roots.length > 1) {
const select = el('select', 'anomalous-download-root');
for (const item of roots) {
const option = el('option', '', item.path + (item.exists ? '' : ` ${t('downloadRootNew')}`));
option.value = String(item.index);
option.selected = item.index === root.index;
select.append(option);
}
select.onchange = () => {
root = roots.find(item => String(item.index) === select.value) || root;
// Its own folders decide {base}, unless the path was typed by hand.
if (!input.dataset.edited) input.value = destinationFor(entry, info, settings, root.subfolders);
fillList();
showPath();
};
line.append(select);
}
const input = el('input', 'anomalous-download-rel');
input.type = 'text';
input.spellcheck = false;
input.value = destinationFor(entry, info, settings, root.subfolders);
const list = el('datalist');
list.id = `anomalous-download-list-${Math.random().toString(36).slice(2)}`;
input.setAttribute('list', list.id);
const fillList = () => list.replaceChildren(...(root.subfolders || []).map(folder => {
const option = el('option');
option.value = `${folder}/${fileName(entry.value)}`;
return option;
}));
fillList();
line.append(input, list);
const full = el('div', 'anomalous-download-path');
const showPath = () => { full.textContent = root.path ? joinPath(root.path, input.value.trim()) : input.value.trim(); };
input.oninput = () => {
input.dataset.edited = '1';
showPath();
};
showPath();
box.append(line, full);
if (unsafeFormat(entry.value) || unsafeFormat(info.file_name)) box.append(el('div', 'anomalous-download-note is-warn', t('downloadUnsafeFormat')));
if (!info.sha256 && !(entry.record?.hash?.length >= 10)) box.append(el('div', 'anomalous-download-note', t('downloadNoCheck')));
const error = el('div', 'anomalous-download-note is-bad');
error.hidden = true;
box.append(error);
return {
box,
read: () => ({ root: root.index, rel: input.value.trim().replace(/\\/g, '/'), rootInfo: root }),
fail: (text) => { error.textContent = text; error.hidden = !text; },
};
}
function errorText(code) {
const key = `downloadError_${code}`;
const text = t(key);
return text === key ? t('downloadError_unknown') : text;
}
/** The dialog for `items` ([{ entry, info }]); resolves once downloads started or it was closed. */
export function openDownloadDialog(owner, items, settings) {
return new Promise((resolve) => {
const overlay = el('div', 'anomalous-dialog-overlay anomalous-download-overlay');
const dialog = el('div', 'anomalous-download-dialog');
dialog.setAttribute('role', 'dialog');
dialog.append(el('h3', 'anomalous-download-title', t(items.length > 1 ? 'downloadTitleMany' : 'downloadTitle', { count: items.length })));
const parts = items.map(({ entry, info }) => ({ entry, info, ...dialogItem(entry, info, settings) }));
const listBox = el('div', 'anomalous-download-items');
listBox.append(...parts.map(part => part.box));
dialog.append(listBox);
const total = items.reduce((sum, { info }) => sum + (Number(info.size) || 0), 0);
const space = el('div', 'anomalous-download-space');
const showSpace = () => {
const roots = new Map(parts.map(part => [part.read().rootInfo.path, part.read().rootInfo]));
const free = Math.min(...[...roots.values()].map(root => Number(root.free) || 0));
space.textContent = t('downloadSpace', { need: total ? formatSize(total) : '?', free: free ? formatSize(free) : '?' });
space.classList.toggle('is-bad', Boolean(total && free && free < total));
};
showSpace();
dialog.addEventListener('change', showSpace);
const rules = button('anomalous-download-rules', t('downloadRules'), () => {
close();
openSettingsPage(owner);
});
dialog.append(space, rules);
const footer = el('div', 'anomalous-download-footer');
const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey, true); resolve(false); };
const onKey = (event) => { if (event.key === 'Escape') { event.stopPropagation(); close(); } };
document.addEventListener('keydown', onKey, true);
const go = button('anomalous-scan-primary', t('downloadStart'), async () => {
go.disabled = true;
let left = 0;
for (const part of parts) {
const choice = part.read();
part.fail('');
try {
await startDownload(owner, part.entry, part.info, choice);
rememberRoot(part.info.type, choice.root);
part.box.remove();
} catch (error) {
left += 1;
part.fail(errorText(error.code));
}
}
go.disabled = false;
if (!left) {
overlay.remove();
document.removeEventListener('keydown', onKey, true);
resolve(true);
}
});
footer.append(button('anomalous-scan-secondary', t('dialogCancel'), close), go);
dialog.append(footer);
overlay.append(dialog);
overlay.onclick = (event) => { if (event.target === overlay) close(); };
document.body.append(overlay);
go.focus();
});
}
/** What a row shows for its model's download: progress, a failure, or nothing yet. */
function jobView(owner, job, retry) {
const box = el('span', 'anomalous-download-status');
if (ACTIVE.includes(job.state)) {
const percent = job.total ? Math.floor((job.received / job.total) * 100) : 0;
const text = job.state === 'queued' ? t('downloadQueued')
: job.state === 'verifying' ? t('downloadVerifying')
: t('downloadProgress', { percent, done: formatSize(job.received), total: job.total ? formatSize(job.total) : '?' });
const bar = el('span', 'anomalous-download-bar');
bar.style.setProperty('--amb-download-done', `${percent}%`);
box.append(el('span', 'anomalous-download-text', text), bar,
button('anomalous-scan-row-btn', t('dialogCancel'), () => cancelDownload(job.id)));
} else if (job.state === 'failed') {
box.append(el('span', 'anomalous-download-text is-bad', errorText(job.error)));
if (job.error === 'needs_key') {
box.append(button('anomalous-scan-row-btn is-main', t('downloadSetKey'), async () => {
if (await saveApiKey()) retry();
}));
} else if (!['hash_mismatch', 'gone', 'exists'].includes(job.error)) {
box.append(button('anomalous-scan-row-btn', t('downloadRetry'), retry));
}
}
return box;
}
/**
* Fills the download parts of a rendered Model Check page: `slots` (Map entry -> the span in
* its row) and `pageActions` (the page's buttons) for "Download all". `rerender` redraws the
* page (after a download ends, the model is there).
*/
export async function fillDownloads(owner, panel, slots, pageActions, rerender) {
panel._downloadUnwatch?.();
if (!slots.size) return;
for (const slot of slots.values()) slot.replaceChildren(el('span', 'anomalous-download-text', t('downloadLooking')));
let looked;
try {
await syncDownloads(owner);
looked = await lookupDownloads([...slots.keys()]);
} catch (error) {
console.warn('[AMB] Download: looking for sources failed.', error);
for (const slot of slots.values()) slot.replaceChildren();
return;
}
if (!panel.isConnected || [...slots.values()].some(slot => !slot.isConnected)) return; // redrawn meanwhile
const { found, settings } = looked;
const ready = [];
const draw = () => {
for (const [entry, slot] of slots) {
const info = found.get(entry);
const job = downloadFor(entry.value);
const start = () => openDownloadDialog(owner, [{ entry, info }], settings);
if (job && job.state !== 'cancelled' && job.state !== 'done') {
slot.replaceChildren(jobView(owner, job, start));
} else if (info?.found) {
slot.replaceChildren(button('anomalous-scan-row-btn is-main', info.size
? t('downloadOne', { size: formatSize(info.size) }) : t('downloadOneNoSize'), start));
} else if (info && NOT_FOUND[info.reason]) {
slot.replaceChildren(el('span', 'anomalous-download-text', t(NOT_FOUND[info.reason])));
} else {
slot.replaceChildren();
}
}
};
// Not downloading now (an earlier, ended download of the same name does not count).
const idle = (entry) => !ACTIVE.includes(downloadFor(entry.value)?.state);
for (const [entry] of slots) {
if (found.get(entry)?.found && idle(entry)) ready.push({ entry, info: found.get(entry) });
}
if (ready.length > 1) {
const size = ready.reduce((sum, item) => sum + (Number(item.info.size) || 0), 0);
pageActions.prepend(button('anomalous-scan-primary', t('downloadAll', { count: ready.length, size: formatSize(size) }), () => {
const waiting = ready.filter(({ entry }) => idle(entry));
if (waiting.length) openDownloadDialog(owner, waiting, settings);
}));
}
draw();
let finished = doneCount();
const unwatch = watchDownloads((jobs) => {
if (!panel.isConnected || panel.style.display === 'none') return;
const done = [...jobs.values()].filter(job => job.state === 'done').length;
if (done !== finished) {
finished = done;
rerender();
} else {
draw();
}
});
panel._downloadUnwatch = unwatch;
}
+2 -2
View File
@@ -176,8 +176,8 @@ function settingsPage(owner, panel, hasKey, progressHost) {
return page;
}
/** Asks for the key and saves it; true when saved. */
async function saveApiKey() {
/** Asks for the Civitai key and saves it; true when saved (also Model Check's downloads). */
export async function saveApiKey() {
const value = prompt(t('scanPageApiKeyPrompt'), '');
if (value === null) return false;
try {
+28 -1
View File
@@ -2,7 +2,7 @@
* The settings page (the rail's gear): one card per subject — how the browser looks,
* model cards and what they cost in memory (with the card image cache), model folders
* (a view of its own, ui_folder_manager.js, `owner.settingsView === 'folders'`),
* workflows, how the browser opens, and help. A tool page like the scan page, with a
* workflows, where Model Check's downloads go, how the browser opens, and help. A tool page like the scan page, with a
* way back to the page you came from. Display preferences go through `owner.displayPrefs`
* (ui_settings_hub.js); language, theme and opening mode are ComfyUI settings.
*/
@@ -17,6 +17,8 @@ import { copyDiagnostics } from './feedback.js';
import { renderFolderPage } from './ui_folder_manager.js';
import { openFeedbackDialog } from './ui_feedback_dialog.js';
import { checkOnOpen, setCheckOnOpen } from './ui_doctor_banner.js';
import { fetchDownloadSettings, saveDownloadSettings } from './model_download.js';
import { saveApiKey } from './ui_scan_page.js';
const t = (key, params) => translate(key, params);
// The full written guide: the README, at its Chinese half for Chinese.
@@ -196,6 +198,30 @@ function workflowGroup(owner) {
})));
}
/** Where Model Check's downloads go (model_download.js); filled once the settings are read. */
function downloadGroup(owner, redraw) {
const box = group('settingsDownloads');
fetchDownloadSettings().then(settings => {
const folder = el('input', 'anomalous-download-folder');
folder.type = 'text';
folder.spellcheck = false;
folder.value = settings.folder;
folder.placeholder = t('settingsDownloadFolderRoot');
folder.onchange = () => saveDownloadSettings({ folder: folder.value })
.then(saved => { folder.value = saved.folder; folder.classList.remove('is-bad'); })
.catch(() => folder.classList.add('is-bad'));
box.append(
row('settingsDownloadPlace', 'settingsDownloadPlaceHelp', segment(
[['workflow', 'settingsDownloadPlaceWorkflow'], ['folder', 'settingsDownloadPlaceFolder']], settings.place,
value => saveDownloadSettings({ place: value }).then(redraw).catch(() => {}))),
row('settingsDownloadFolder', 'settingsDownloadFolderHelp', folder),
row('settingsDownloadKey', 'settingsDownloadKeyHelp', button('anomalous-scan-secondary anomalous-scan-small-btn',
t('settingsDownloadKeySet'), () => saveApiKey())),
);
}).catch(error => console.warn('[AMB] Settings: download settings unavailable.', error));
return box;
}
function openingGroup(owner, redraw) {
const mode = comfySetting(ENTRY_MODE_SETTING_ID) || 'floating';
return group('settingsOpening',
@@ -255,6 +281,7 @@ function render(owner) {
cardsGroup(owner, redraw),
foldersGroup(owner),
workflowGroup(owner),
downloadGroup(owner, redraw),
openingGroup(owner, redraw),
helpGroup(owner),
);
+1
View File
@@ -20,3 +20,4 @@
@import url("./styles/19-model-doctor.css?v=20261005-mute-1");
@import url("./styles/20-current-node.css?v=20261004-studio-1");
@import url("./styles/21-prompt-studio.css?v=20261004-draftx-1");
@import url("./styles/22-model-download.css?v=20261006-download-1");
+182
View File
@@ -0,0 +1,182 @@
/* Model Check's downloads: the row's button and progress, and the dialog (ui_model_download.js). */
.anomalous-download-slot:empty { display: none; }
.anomalous-download-slot,
.anomalous-download-status {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.anomalous-download-text {
color: var(--amb-text-muted);
font-size: 12px;
line-height: 1.4;
}
.anomalous-download-text.is-bad { color: #fca5a5; }
.anomalous-download-bar {
position: relative;
width: 56px;
height: 5px;
border-radius: 999px;
background: var(--amb-border);
overflow: hidden;
}
.anomalous-download-bar::after {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: var(--amb-download-done, 0%);
background: var(--amb-link);
transition: width 0.4s ease;
}
.anomalous-download-overlay {
position: fixed;
inset: 0;
z-index: 1000001;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.anomalous-download-dialog {
display: flex;
flex-direction: column;
gap: 14px;
width: min(620px, 100%);
max-height: 90vh;
padding: 22px 24px;
box-sizing: border-box;
overflow-y: auto;
border: 1px solid var(--amb-border-strong);
border-radius: 16px;
background: var(--amb-bg-panel, #24252b);
color: var(--amb-text-main);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
}
.anomalous-download-title {
margin: 0;
font-size: 1.15rem;
}
.anomalous-download-items {
display: flex;
flex-direction: column;
gap: 10px;
}
.anomalous-download-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border: 1px solid var(--amb-border);
border-radius: 10px;
background: var(--amb-bg-card);
}
.anomalous-download-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.anomalous-download-name {
min-width: 0;
overflow-wrap: anywhere;
}
.anomalous-download-facts,
.anomalous-download-model {
flex-shrink: 0;
color: var(--amb-text-muted);
font-size: 12px;
}
.anomalous-download-model { flex-shrink: 1; }
.anomalous-download-where {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
font-size: 12.5px;
}
.anomalous-download-label { color: var(--amb-text-soft, var(--amb-text-muted)); }
.anomalous-download-root,
.anomalous-download-rel,
.anomalous-download-folder {
min-width: 0;
padding: 5px 8px;
border: 1px solid var(--amb-border-strong);
border-radius: 8px;
background: var(--amb-bg-input, rgba(0, 0, 0, 0.25));
color: var(--amb-text-main);
font: inherit;
font-size: 12.5px;
}
.anomalous-download-root { max-width: 100%; }
.anomalous-download-rel { flex: 1 1 240px; }
.anomalous-download-folder { width: 220px; }
.anomalous-download-folder.is-bad { border-color: #fca5a5; }
.anomalous-download-path {
color: var(--amb-text-dim, var(--amb-text-muted));
font-size: 11.5px;
overflow-wrap: anywhere;
}
.anomalous-download-note {
color: var(--amb-text-muted);
font-size: 12px;
line-height: 1.5;
}
.anomalous-download-note.is-warn { color: #fcd34d; }
.anomalous-download-note.is-bad { color: #fca5a5; }
.anomalous-download-space {
color: var(--amb-text-muted);
font-size: 12.5px;
}
.anomalous-download-space.is-bad { color: #fca5a5; }
.anomalous-download-rules {
align-self: flex-start;
padding: 0;
border: none;
background: none;
color: var(--amb-link);
font-size: 12px;
cursor: pointer;
}
.anomalous-download-rules:hover { text-decoration: underline; }
.anomalous-download-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 520px) {
.anomalous-download-head { flex-direction: column; gap: 2px; }
.anomalous-download-dialog { padding: 16px; }
}