Files a scan found on the same Civitai model page (metadata model_id; never a similar file name) share one card in the model list, the newest version shown or the one picked last (kept in this browser). The card's version label opens the shared action menu to switch. The details list the versions here under the name, and "All versions on Civitai" (GET /anomalous/download/versions, this computer only, asked only when pressed) lists the page's versions to open or download beside this one, checked by SHA-256, a taken file name getting the version's name added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
294 lines
14 KiB
JavaScript
294 lines
14 KiB
JavaScript
/**
|
|
* 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, checkWorkflowModels, modelBoxes } from './model_check.js';
|
|
import { DEFAULT_FOLDER, destinationFor, expandFolder } from './download_places.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 ROOT_KEY = 'anomalous_download_root_';
|
|
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}|${item.mirror}`;
|
|
|
|
/** Whether Hugging Face files come from its mirror: as set, else for a Chinese interface. */
|
|
export function hfMirrorOn(settings) {
|
|
return typeof settings?.hf_mirror === 'boolean' ? settings.hf_mirror : window.anomalous_browser_lang === 'zh';
|
|
}
|
|
|
|
/**
|
|
* Hugging Face's model search for a file's name (its mirror when that is on), for a foundation
|
|
* component no source was found for: the user looks for it there.
|
|
*/
|
|
export function hfSearchUrl(value, settings) {
|
|
const stem = fileName(value).replace(/\.[^.]+$/, '');
|
|
return `https://${hfMirrorOn(settings) ? 'hf-mirror.com' : 'huggingface.co'}/models?search=${encodeURIComponent(stem)}`;
|
|
}
|
|
|
|
/**
|
|
* 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 settings = await fetchDownloadSettings().catch(() => ({ place: 'workflow', folder: DEFAULT_FOLDER, hf_mirror: null }));
|
|
const mirror = hfMirrorOn(settings);
|
|
const items = entries.map((entry, index) => {
|
|
const item = { key: String(index), hash: entry.record?.hash || '', url: workflowLink(entry), value: entry.value, mirror };
|
|
return { ...item, types: inferModelFolderTypes(entry.node, entry.widget), known: lookups.has(lookupKey(item)) };
|
|
});
|
|
const data = await post('/anomalous/download/lookup', { items, hf_mirror: mirror });
|
|
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;
|
|
// A name alone is looked up too (ComfyUI-Manager's model list); a network failure is asked again.
|
|
if (!item.known && result.reason !== 'network') lookups.set(lookupKey(item), result);
|
|
if (!info) continue;
|
|
info = { ...info, type: result.type, roots: data.roots?.[result.type] || [], link: item.url };
|
|
found.set(entries[Number(item.key)], info);
|
|
}
|
|
return { found, settings, managerList: data.manager_list !== false };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/** The models folder of a type a download goes to unless changed: the one picked last, else
|
|
* the first that exists (ComfyUI also lists folders that do not, such as output/loras). */
|
|
export function defaultRoot(type, roots = []) {
|
|
const existing = roots.filter(root => root.exists);
|
|
const usable = existing.length ? existing : roots.slice(0, 1);
|
|
let index = -1;
|
|
try { index = Number(localStorage.getItem(ROOT_KEY + type) ?? -1); } catch { /* the first one */ }
|
|
return usable.find(root => root.index === index) || usable[0] || null;
|
|
}
|
|
|
|
export function rememberRoot(type, index) {
|
|
try { localStorage.setItem(ROOT_KEY + type, String(index)); } catch { /* not kept */ }
|
|
}
|
|
|
|
/**
|
|
* For AI apps (MCP download_missing_models): starts the download of every missing model of the
|
|
* open workflow whose source is certain (or of those named in `names`), where the download
|
|
* settings put it. Returns what started and why the others did not.
|
|
*/
|
|
export async function downloadMissing(owner, names = []) {
|
|
const wanted = new Set((names || []).map(name => fileName(name).toLowerCase()));
|
|
const entries = (await checkWorkflowModels()).filter(entry => entry.state === 'missing'
|
|
&& (!wanted.size || wanted.has(fileName(entry.value).toLowerCase())));
|
|
const once = [...new Map(entries.map(entry => [entry.value, entry])).values()]; // one per model
|
|
const { found, settings } = once.length ? await lookupDownloads(once) : { found: new Map(), settings: null };
|
|
const started = [];
|
|
const notStarted = [];
|
|
for (const entry of once) {
|
|
const info = found.get(entry);
|
|
if (ACTIVE.has(downloadFor(entry.value)?.state)) {
|
|
notStarted.push({ model: entry.value, reason: 'already downloading' });
|
|
} else if (!info?.found) {
|
|
notStarted.push({ model: entry.value, reason: info?.reason || (entry.record?.hash || workflowLink(entry) ? 'not_found' : 'no_source') });
|
|
} else if (info.by_name) {
|
|
// Only a file of the same name: the user decides (download_model with this url, if they agree).
|
|
notStarted.push({ model: entry.value, reason: 'same name only (ComfyUI-Manager model list); the workflow has no fingerprint to check it',
|
|
url: info.download_url, list_name: info.list_name || '' });
|
|
} else {
|
|
const root = defaultRoot(info.type, info.roots);
|
|
const rel = destinationFor(entry, info, settings, root?.subfolders || []);
|
|
try {
|
|
await startDownload(owner, entry, info, { root: root?.index ?? 0, rel });
|
|
started.push({ model: entry.value, to: rel, models_folder: root?.path || '', size: info.size || null,
|
|
source: info.mirror ? 'huggingface (mirror)' : info.source, page: info.page || '', checked: Boolean(info.sha256 || entry.record?.hash) });
|
|
} catch (error) {
|
|
notStarted.push({ model: entry.value, reason: error.code || String(error.message || error) });
|
|
}
|
|
}
|
|
}
|
|
return { started, not_started: notStarted };
|
|
}
|
|
|
|
/**
|
|
* For AI apps (MCP download_model): downloads one model named by a Civitai / Hugging Face /
|
|
* GitHub file link or a Civitai hash into the `type` folder: `folder` (relative) when given,
|
|
* else the download settings' folder ({base} filled in); `name` (the site's when empty); `root`: the
|
|
* index of the models folder to put it in, else the one downloads usually go to.
|
|
*/
|
|
export async function downloadFrom(owner, { url = '', hash = '', type = '', folder = null, name = '', root: rootIndex = null }) {
|
|
const settings = await fetchDownloadSettings();
|
|
const data = await post('/anomalous/download/lookup', {
|
|
items: [{ key: '0', hash: String(hash || ''), url: String(url || ''), value: String(name || ''), types: [String(type)] }],
|
|
hf_mirror: hfMirrorOn(settings),
|
|
});
|
|
const result = data.results?.[0] || {};
|
|
if (!result.type) throw new Error(`"${type}" is not a model folder type of this ComfyUI (loras, checkpoints, vae…).`);
|
|
if (!result.found) throw new Error(`No file to download: ${result.reason || 'not_found'}.`);
|
|
const info = { ...result, roots: data.roots?.[result.type] || [] };
|
|
const root = info.roots.find(item => item.index === rootIndex && item.exists) || defaultRoot(info.type, info.roots);
|
|
const file = fileName(name || info.file_name);
|
|
const dir = folder !== null && folder !== undefined
|
|
? slashes(folder).split('/').filter(Boolean).join('/')
|
|
: expandFolder(settings.folder ?? DEFAULT_FOLDER, info.base_model, root?.subfolders || []);
|
|
const rel = dir ? `${dir}/${file}` : file;
|
|
const job = await startDownload(owner, { value: '', record: { hash: String(hash || '') } }, info, { root: root?.index ?? 0, rel });
|
|
return { started: true, id: job.id, to: rel, models_folder: root?.path || '', size: info.size || null,
|
|
source: info.mirror ? 'huggingface (mirror)' : info.source, model: [info.model_name, info.version_name].filter(Boolean).join(' — '),
|
|
base_model: info.base_model || '', checked: Boolean(info.sha256 || hash) };
|
|
}
|
|
|
|
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 modelBoxes(node)) {
|
|
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) });
|
|
}
|