From 224d339afe37a2023d53ff85b81dc9d0ffc8a108 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Tue, 6 Oct 2026 02:53:49 +0800 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + api/download_sources.py | 7 ++- api/mcp_actions.py | 42 +++++++++++++++++ docs/architecture/backend.md | 5 +- docs/guides/mcp.md | 3 ++ web/modules/mcp_bridge.js | 15 ++++++ web/modules/model_download.js | 79 +++++++++++++++++++++++++++++++- web/modules/ui_model_download.js | 15 ++---- 8 files changed, 150 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 462e187..b71c877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/api/download_sources.py b/api/download_sources.py index dc485a6..af1aa9e 100644 --- a/api/download_sources.py +++ b/api/download_sources.py @@ -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=""): diff --git a/api/mcp_actions.py b/api/mcp_actions.py index 323ddf5..4d9f189 100644 --- a/api/mcp_actions.py +++ b/api/mcp_actions.py @@ -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}}), diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index ddcfd0c..0aba078 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -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 diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 71dda3a..560fa8f 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -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. 注意 diff --git a/web/modules/mcp_bridge.js b/web/modules/mcp_bridge.js index da2d10b..ebc6a35 100644 --- a/web/modules/mcp_bridge.js +++ b/web/modules/mcp_bridge.js @@ -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, }; diff --git a/web/modules/model_download.js b/web/modules/model_download.js index 6e968bc..2af033d 100644 --- a/web/modules/model_download.js +++ b/web/modules/model_download.js @@ -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; diff --git a/web/modules/ui_model_download.js b/web/modules/ui_model_download.js index 7d6309d..8bb4edd 100644 --- a/web/modules/ui_model_download.js +++ b/web/modules/ui_model_download.js @@ -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) {