feat(mcp): AI apps can download models

Three MCP tools: download_missing_models (Model Check's Download all for
the open workflow, through the page so the placement rules and the put-in
afterwards are the UI's own), download_model (one model from a Civitai
version link, a Hugging Face / GitHub file link or a Civitai hash, into a
folder of its type) and download_status. The default models folder of a
type is shared by the dialog and the tools.

A Civitai version link without a matching file name now takes the
version's primary model file (people rename their files); a hash, when
there is one, still decides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-06 02:53:49 +08:00
co-authored by Claude Opus 5.5
parent a04f1525d4
commit 224d339afe
8 changed files with 150 additions and 17 deletions
+1
View File
@@ -58,6 +58,7 @@
- **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).
- **AI apps can download too (AI 也能下载)**: three new MCP tools: `download_missing_models` (what Model Check's Download all does, for the open workflow), `download_model` (one model from a Civitai version link, a Hugging Face or GitHub file link, or a Civitai hash, into the folder of its type) and `download_status`. Files go where Settings → Model downloads says, are checked the same way, and are never written over an existing file.
- **Hugging Face through the China mirror (Hugging Face 国内镜像)**: Settings → Model downloads can send Hugging Face downloads through hf-mirror.com, which is on by default for the Chinese interface. When a download from Hugging Face itself fails, the row suggests the mirror.
- **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.
+5 -2
View File
@@ -81,7 +81,9 @@ def _civitai_page(version):
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."""
"""The version's file with this hash (SHA-256 or a prefix such as AutoV2; None if it has
none such). Without a hash: the file of this name, else the version's primary model file —
the link names that version, and people rename their files."""
files = [item for item in files or [] if isinstance(item, dict) and item.get("downloadUrl")]
wanted = str(file_hash or "").upper()
if wanted:
@@ -95,7 +97,8 @@ def _pick_civitai_file(files, file_hash="", name=""):
for item in files:
if str(item.get("name", "")).lower() == name.lower():
return item
return None
models = [item for item in files if item.get("type", "Model") == "Model"]
return next((item for item in models if item.get("primary")), models[0] if models else None)
def _from_civitai_version(version, file_hash="", name=""):
+42
View File
@@ -96,6 +96,31 @@ def fix_workflow_models(ctx):
return _page("fix_models", timeout=90)
def download_missing_models(ctx, names=None):
return _page("download_missing", {"names": [str(name) for name in names or []]}, timeout=120)
def download_model(ctx, type, url="", hash="", folder=None, name=""):
if not (url or hash):
raise ToolError("Give url (a Civitai, Hugging Face or GitHub link to the file or model version) or hash.")
return _page("download_model", {"url": str(url), "hash": str(hash), "type": str(type),
"folder": None if folder is None else str(folder), "name": str(name or "")}, timeout=90)
def download_status(ctx):
status, data = _local(ctx, "GET", "/anomalous/download/status")
if status != 200:
raise ToolError(f"Download status unavailable ({status}).")
jobs = []
for job in (data or {}).get("jobs", []):
total = job.get("total") or 0
jobs.append({"file": job.get("rel"), "type": job.get("type"), "state": job.get("state"), "error": job.get("error") or None,
"percent": round(job["received"] * 100 / total) if total else None, "size": total or None,
"checked": job.get("verified"), "for_model": job.get("value") or None})
return {"downloads": jobs, "meaning": "state: queued, running, verifying, done (checked: its fingerprint matched), "
"failed (error says why; starting it again continues it), cancelled."}
def run_workflow(ctx, batch=1):
batch = max(1, min(8, int(batch)))
return _page("run_workflow", {"batch": batch})
@@ -238,6 +263,23 @@ ACTIONS = dict([
"Puts back every missing model that Anomalous found with certainty (same file by hash). "
"Likely candidates are left for the user. One Ctrl+Z step.",
fix_workflow_models, destructive=True),
_action("download_missing_models", "Download missing models",
"Downloads the models the open workflow is missing whose source is certain: Civitai found by the "
"workflow's fingerprint, or a file link the workflow carries. A file name alone is never used. Files go "
"where the user's download settings say and are checked against the fingerprint before use; finished "
"ones are put into their nodes. names limits it to those files. Large files take a while.",
download_missing_models, {"names": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_LIMIT,
"description": "Only these model file names (default: all missing)."}},
open_world=True),
_action("download_model", "Download a model",
"Downloads one model into this computer's models folder of `type` (loras, checkpoints, vae, "
"diffusion_models, text_encoders, upscale_models…): from url (a Civitai model-version link, or a Hugging "
"Face / GitHub link to the file) or by its Civitai hash. folder is a subfolder inside that type's folder "
"(default: the user's download settings); name the file name (default: the site's). Never replaces a file.",
download_model, {"type": {"type": "string"}, "url": {"type": "string"}, "hash": {"type": "string"},
"folder": {"type": "string"}, "name": {"type": "string"}}, ["type"], open_world=True),
_action("download_status", "Downloads", "How the downloads of this ComfyUI run are going.", download_status,
read_only=True),
_action("run_workflow", "Run the workflow",
"Queues the open workflow in ComfyUI, like pressing Run. Uses the GPU; results appear in the output folder.",
run_workflow, {"batch": {"type": "integer", "minimum": 1, "maximum": 8, "default": 1}}),
+3 -2
View File
@@ -173,9 +173,10 @@ websocket event to every open page and waits (30 s, 90 s for model checks; at mo
waiting). The first page to POST `{id, claim: true}` to `/anomalous/mcp/bridge` gets it
(hidden tabs wait 1.5 s so a visible one wins) and posts `{id, outcome}` back. No page,
no answer in time, or the page's refusal becomes a tool error. Scans and speech call this
server's own `/anomalous/scan_all`, `/anomalous_tts/characters`, `/prompt` and
server's own `/anomalous/scan_all`, `/anomalous/download/status`, `/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"`.
minutes for the file). Downloads go through the page (`model_download.js` downloadMissing /
downloadFrom), so the placement rules and the put-in after a download are the UI's own. Canvas changes are posted to the activity log with `via: "mcp"`.
## Model downloads
+3
View File
@@ -101,6 +101,9 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
| run_workflow | 像按“运行”一样把工作流加入队列 |
| open_in_anomalous | 在插件里打开某个模型的详情页给你看 |
| scan_models | 扫描新模型、重新查找没匹配上的,或扫描指定的模型(会联网访问 Civitai) |
| download_missing_models | 下载当前工作流缺的模型:只下能确定来源的(C 站按指纹找到、或工作流带着文件链接),放在设置里“模型下载”说的位置,核对指纹后自动换上 |
| download_model | 按链接(C 站模型版本、Hugging Face / GitHub 文件)或 C 站指纹下载一个模型到指定类型的文件夹,不覆盖已有文件 |
| download_status | 看下载进度和结果 |
| list_voices / speak | 列出 Anomalous TTS 的角色;让某个角色读一段台词并保存(最多等两分钟) |
## 5. 注意
+15
View File
@@ -15,6 +15,7 @@ import { keepUndo, workflowKey } from './canvas_undo.js';
import { recordCanvasStep } from './canvas_history.js';
import { chainEnd, mainModelLoaders, planLoraInsertion, spliceLora } from './graph_splice.js';
import { checkWorkflowModels, fixWorkflowModels, isProblem } from './model_check.js';
import { downloadFrom, downloadMissing } from './model_download.js';
import { promptBoxes } from './prompt_boxes.js';
import { findModelComboWidget, getNativeWidgetValues, setWidgetValue } from './ui_node_model_picker.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
@@ -245,6 +246,18 @@ async function fixModels(_args, getOwner) {
});
}
/** Downloads run on the server; each finished file is put into its nodes like a press in Model Check. */
async function downloadMissingModels({ names }, getOwner) {
currentGraph();
const result = await downloadMissing(getOwner(), names);
return { ...result, note: 'Downloads run one at a time in the background; download_status follows them. Each finished file '
+ 'is checked against its fingerprint and put into the nodes that use it.' };
}
async function downloadModel(args, getOwner) {
return { ...await downloadFrom(getOwner(), args), note: 'download_status follows it.' };
}
async function runWorkflow({ batch }) {
currentGraph();
await app.queuePrompt(0, batch || 1);
@@ -268,6 +281,8 @@ const ACTIONS = {
place_combo: placeCombo,
check_models: checkModels,
fix_models: fixModels,
download_missing: downloadMissingModels,
download_model: downloadModel,
run_workflow: runWorkflow,
open_model: openModel,
};
+78 -1
View File
@@ -10,12 +10,14 @@
import { app } from "../../../scripts/app.js";
import { translate as t } from './locales.js';
import { applyModelFix } from './model_check.js';
import { applyModelFix, checkWorkflowModels } from './model_check.js';
import { 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, '/');
@@ -112,6 +114,81 @@ export function downloadFor(value) {
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 {
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).
*/
export async function downloadFrom(owner, { url = '', hash = '', type = '', folder = null, name = '' }) {
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 = 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 ?? 'Downloads', 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;
+3 -12
View File
@@ -11,12 +11,12 @@
import { translate as t } from './locales.js';
import { destinationFor, unsafeFormat } from './download_places.js';
import {
cancelDownload, doneCount, downloadFor, lookupDownloads, startDownload, syncDownloads, watchDownloads,
cancelDownload, defaultRoot, doneCount, downloadFor, lookupDownloads, rememberRoot, 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' };
@@ -44,15 +44,6 @@ export function formatSize(bytes) {
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}`;
@@ -71,7 +62,7 @@ function dialogItem(entry, info, settings) {
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: [] };
let root = defaultRoot(info.type, roots) || { index: 0, path: '', subfolders: [] };
const line = el('label', 'anomalous-download-where');
line.append(el('span', 'anomalous-download-label', t('downloadSaveTo')));
if (roots.length > 1) {