Make Model Check offer same-name files as candidates, not as the same file

For a workflow without fingerprints, a missing ordinary model took the
local same-name file's hash as its evidence, so it showed as "the same
file under another name" and counted among those put in at once. Only the
workflow's own record is evidence now; the one same-name file the node can
load is a candidate, and a size candidate with the same name is shown as
likely, first. Foundation components still go by fingerprint only. Model
Check also reads GGUF values and resolves upscale models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-03 14:23:05 +08:00
co-authored by Claude Opus 5.5
parent 2104bb659f
commit 4cfff5382e
7 changed files with 38 additions and 18 deletions
+1
View File
@@ -66,6 +66,7 @@
- **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: an empty studio is a drop area with **Import character**, which also sits in the toolbar. **GPT-SoVITS settings** at the bottom of the sidebar opens a dialog with where characters are kept, pretrained files and missing Python packages; missing pretrained files only get a small dot on that entry, with "don't remind me", once your characters need them, and the import form notes what the chosen language will download. **Batch import (批量导入)**: the import window takes one or many characters. It first asks what you have (one character, many characters or a package, or files for a character you already have), with three steps at the top and a spotlight tour the first time ("?" replays it). Each character card leads with a checklist that lights up the next step (GPT model, SoVITS model, clips, main voice, name) and offers its button right there; the card says how many steps are left. Drop files or whole folders, or pick a folder on this PC (a GPT-SoVITS package): a folder holding one set of GPT + SoVITS weights and its clips is one character named after the folder (two characters whose weights are both called `model` stay apart; `派蒙/v2` becomes "派蒙 v2"); weights in a package's weight folders pair up by name (latest epoch kept); clips and line files follow their character folder, the weights in their folder or the character their folder or file name points at; an annotation file (`.list`) no character claims goes to every character; two clips with the same name (`开心/01.wav`, `难过/01.wav`) both go in, the second as `难过_01.wav`; anything unclear waits in a card of its own, one line per folder that can be assigned at once, and is uploaded only once it is put into a character. Clips outside 3–10 s are left out with their line files. Python environments and base models (`runtime`, `pretrained_models`, `venv`…) are skipped, and a package's program and training folders (`GPT_SoVITS`, `logs`, `output`, `TEMP`, `tools`) only inside a package: your own folders called `output` or `GPT_SoVITS`, and the folder you chose or dropped, are always read; the window says which folders were skipped or too deep (6 levels) instead of files going missing quietly. A big add opens 20 characters (about 800 files) at a time; **Next batch** opens the rest, and it opens by itself once a batch is imported, so a huge folder does not freeze the window. Every clip has **▶** to listen while importing — uploading, unassigned or picked on this PC — and clicking another clip switches to it for quick comparing. Long clip lists show the first 60, and a very large upload first suggests picking the folder on this PC instead; uploads run three at a time. Each character is a card: one line with its name, a short status and what it holds, unfolding into its model tiles and clips; files move between characters with the picker on each row. **Import N characters** imports each ready one on its own, so one failure leaves the others alone. Status is colour-coded throughout: green ready, amber still missing something, red a failed or refused file, blue uploading; **Import** stays at the bottom. Adding to a character skips files it already has and merges annotation files (Anomalous_TTS interface 5). There is one storage place (default `models/gpt_sovits`); changing it can move the existing characters there with progress. Download pretrained files with progress or use an existing GPT-SoVITS package, all without editing `extra_model_paths.yaml` or restarting. **Import character** builds a character from dropped or picked GPT / SoVITS weights, reference clips and annotation files: the name, SoVITS version, clip lengths and lines are filled in automatically, and everything is copied (originals stay). Drop files on a card, or use **Add files**, to add clips and emotions to an existing character. Lines are found in same-name `.txt` / `.lab` files, annotation files or, as a marked guess, the file name; a text file dropped on a clip's line box fills it. Needs Anomalous_TTS with interface version 11; file actions only work on the computer running ComfyUI.
### Fixes
- **Model Check is honest about same-name files (模型检查不再把同名当成同一个文件)**: for a workflow without fingerprints, a missing LoRA or checkpoint with a file of the same name on this computer was listed as "the same file under another name or folder" and counted among those to put in at once. It is now a candidate you take or leave on its row: "same name", or "same name and exactly the same size, very likely the one" (listed first). VAE, CLIP and other foundation components still go by fingerprint only. Model Check now also checks GGUF models and finds upscale models moved to another folder.
- **GGUF and .pth models show up (GGUF、.pth 模型能看到了)**: the Models page, the folder list's counts, search and model lookups only knew `.safetensors`, `.ckpt` and `.pt`, in lower case, so quantized GGUF models (Wan, Flux, Qwen…), most upscalers (`.pth`), `.sft` and `.bin` files and names like `MODEL.SAFETENSORS` were missing. They are all listed now. Scans still read only `.safetensors`; the others stay under **Not scanned yet**.
- **Language follows ComfyUI again (语言跟随 ComfyUI)**: with the language left on "Follow ComfyUI" (the default), changing ComfyUI's language now switches the browser the next time it opens; before, it kept the old language until the page was reloaded.
- **Empty list on the audio pages (音频页左边列表是空的)**: after reloading ComfyUI, a browser that reopened straight onto Voices or Audio showed an empty list column until you switched to the image side and back.
+1 -1
View File
@@ -14,7 +14,7 @@ SIDECAR_SUFFIXES = (
)
RESOLVABLE_MODEL_TYPES = (
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet',
'vae', 'vae_approx', 'clip', 'text_encoders', 'clip_vision',
'vae', 'vae_approx', 'clip', 'text_encoders', 'clip_vision', 'upscale_models',
)
+8 -2
View File
@@ -25,13 +25,19 @@ simply does not exist in any local file, it returns `{"found": False}` cleanly w
falsely flagging a conflict.
Paths, filenames, source filenames, display/custom names, previews, workflow
fingerprints, and fuzzy/visual similarity are never candidate evidence. They may
fingerprints, and fuzzy/visual similarity never establish identity. They may
be used only after identity is established to return a local dropdown value,
locate presentation media, and verify the value against ComfyUI's native choices.
One exception, for ordinary models only: when the workflow carries no record at
all (a workflow saved without the plugin), the single file of the same name
among the widget's own choices is offered as a candidate (`via: 'name'`); a
size-only candidate whose name also matches is shown as likely (`via:
'name-size'`). Both take a press on their row and never count among the files
put in at once. Several files of that name leave the model ambiguous.
Foundation components—`vae`, `vae_approx`, `clip`, `text_encoders`, and
`clip_vision`—are hash-only automatic-recovery categories. Byte size alone
cannot automatically repair them. When size provenance is available it may be
cannot automatically repair them, and they are never offered by name. When size provenance is available it may be
shown as the same explicit manual candidate, but a supplied hash mismatch never
falls back to a filename or size-only guess.
This is a Model Check confidence boundary, not a scanner-support boundary. The
+4
View File
@@ -768,6 +768,8 @@ export const i18n = {
doctorWhyFound: '本机有同一个文件,只是名字或文件夹不同:{file}',
doctorWhySpelling: '只是路径里斜杠的写法不同:{file}',
doctorWhyCandidate: '本机有一个大小完全相同的文件:{file}。工作流没记它的指纹,不能确定是同一个,用不用由你决定。',
doctorWhySameName: "本机有一个同名文件:{file}。工作流没记它的指纹,名字一样不一定是同一个模型,用不用由你决定。",
doctorWhyLikely: "本机有一个同名、大小也完全相同的文件:{file},很可能就是它。工作流没记它的指纹,还是由你确认。",
doctorWhyConflict: '按指纹和大小找到的不是同一个文件,没有替换。',
doctorWhyAmbiguous: '本机有好几个文件都可能是它,没法确定是哪一个。',
doctorWhyChanged: '文件还在,但内容和保存工作流时不一样(可能换成了别的版本)。ComfyUI 会照常使用它。',
@@ -2722,6 +2724,8 @@ export const i18n = {
doctorWhyFound: 'This computer has the same file under another name or folder: {file}',
doctorWhySpelling: 'Only the slashes in the path differ: {file}',
doctorWhyCandidate: 'This computer has a file of exactly the same size: {file}. The workflow has no fingerprint for it, so it may not be the same model; your call.',
doctorWhySameName: "This computer has a file of the same name: {file}. The workflow has no fingerprint for it, and the same name is not always the same model; your call.",
doctorWhyLikely: "This computer has a file of the same name and exactly the same size: {file}, very likely the one. The workflow has no fingerprint for it, so you confirm.",
doctorWhyConflict: 'The fingerprint and the size point to different files; nothing was replaced.',
doctorWhyAmbiguous: 'Several files on this computer could be it; cannot tell which.',
doctorWhyChanged: 'The file is there, but its content differs from when the workflow was saved (maybe another version). ComfyUI uses it as usual.',
+18 -11
View File
@@ -8,19 +8,22 @@
*
* An entry: { node, widget, value, state, target, via, record, local }, state one of
* ready · changed (there, but not the file the workflow was saved with) · fixable (target
* is the same file, via 'hash' or 'spelling') · candidate (target has the same size only)
* · conflict · ambiguous · missing (`record` says whether the workflow carries a hash).
* is the same file, via 'hash' or 'spelling') · candidate (a press decides: via 'size', the
* same size; 'name', the one file of that name for a workflow with no record; 'name-size',
* both, likely the same) · conflict · ambiguous · missing (`record` says whether the
* workflow carries a hash). Foundation components (VAE, CLIP…) never go by name.
*/
import { app } from "../../../scripts/app.js";
import { findWorkflowHashRecord } from './recipe_provenance.js';
import { inferModelFolderTypes, requiresHashForModelRecovery } from './model_policies.js';
const MODEL_FILE = /\.(safetensors|ckpt|pt|bin|pth|sft)$/i;
const MODEL_FILE = /\.(safetensors|ckpt|pt|bin|pth|sft|gguf)$/i;
const BATCH = 256;
const PROBLEMS = new Set(['fixable', 'candidate', 'conflict', 'ambiguous', 'missing']);
const slashes = (value) => String(value).replace(/\\/g, '/');
const fileName = (value) => slashes(value).split('/').pop().toLowerCase();
const choices = (widget) => (Array.isArray(widget.options?.values) ? widget.options.values : []);
const nativeValue = (widget, filename) => choices(widget).find(v => typeof v === 'string' && slashes(v) === slashes(filename)) || null;
@@ -53,12 +56,12 @@ function localRecord(value) {
const savedRecord = (ref) => asRecord(findWorkflowHashRecord(app.graph, ref.node.id, ref.value));
/** What identifies the model: the workflow's record, or for ordinary models (not the
* hash-only foundation components) the local cache's entry under the same name. */
function evidence(ref) {
const saved = savedRecord(ref);
if (saved || requiresHashForModelRecovery(ref.node, ref.widget)) return saved;
return localRecord(ref.value);
/** Files of the same name (any folder, any case) the node can load. A name is never proof,
* only a candidate, and only for ordinary models: foundation components go by hash. */
function sameNameChoices(ref) {
if (requiresHashForModelRecovery(ref.node, ref.widget)) return [];
const name = fileName(ref.value);
return choices(ref.widget).filter(value => typeof value === 'string' && fileName(value) === name);
}
function identityChanged(ref) {
@@ -121,7 +124,7 @@ export async function checkWorkflowModels({ refresh = false } = {}) {
const entries = [];
const items = [];
for (const ref of modelRefs()) {
const record = evidence(ref);
const record = savedRecord(ref);
const entry = { ...ref, record, local: localRecord(ref.value), state: 'missing', target: null, via: '' };
entries.push(entry);
if (choices(ref.widget).includes(ref.value)) {
@@ -134,6 +137,10 @@ export async function checkWorkflowModels({ refresh = false } = {}) {
} else if (record?.hash || record?.size) {
const type = inferModelFolderTypes(ref.node, ref.widget).join(',');
items.push({ key: String(entries.length - 1), hash: record.hash, size: record.size, type });
} else {
const named = sameNameChoices(ref);
if (named.length === 1) Object.assign(entry, { state: 'candidate', target: named[0], via: 'name' });
else if (named.length > 1) entry.state = 'ambiguous';
}
}
if (!items.length) return entries;
@@ -156,7 +163,7 @@ export async function checkWorkflowModels({ refresh = false } = {}) {
if (!target) continue; // not a file this node can load
Object.assign(entry, result.found
? { state: 'fixable', target, via: 'hash' }
: { state: 'candidate', target, via: 'size' });
: { state: 'candidate', target, via: fileName(target) === fileName(entry.value) ? 'name-size' : 'size' });
}
return entries;
}
+1
View File
@@ -33,6 +33,7 @@ export function inferModelFolderTypes(node, widget) {
if (widgetName.includes('ckpt') || widgetName.includes('checkpoint') || key.includes('checkpoint')) return ['checkpoints'];
if (widgetName.includes('unet') || widgetName.includes('diffusion_model') || key.includes('unet loader')) return ['diffusion_models', 'unet'];
if (widgetName.includes('clip_name') || widgetName.includes('text_encoder') || nodeType.includes('cliploader')) return ['clip', 'text_encoders'];
if (nodeType.includes('upscalemodel') || widgetName.includes('upscale_model')) return ['upscale_models'];
return [];
}
+5 -4
View File
@@ -1,7 +1,7 @@
/**
* 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 size, pick one by hand (the
* 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).
*/
@@ -10,7 +10,8 @@ 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';
const MODEL_EXT = /\.(safetensors|ckpt|pt|bin|pth|sft)$/i;
const MODEL_EXT = /\.(safetensors|ckpt|pt|bin|pth|sft|gguf)$/i;
const CANDIDATE_WHY = { size: 'doctorWhyCandidate', name: 'doctorWhySameName', 'name-size': 'doctorWhyLikely' };
const ORDER = ['fixable', 'candidate', 'conflict', 'ambiguous', 'missing', 'changed'];
const MARKS = { fixable: '↻', candidate: '?', conflict: '✕', ambiguous: '?', missing: '✕', changed: '!', ready: '✓' };
@@ -40,7 +41,7 @@ function stat(value, label, tone) {
function reason(entry) {
switch (entry.state) {
case 'fixable': return t(entry.via === 'spelling' ? 'doctorWhySpelling' : 'doctorWhyFound', { file: entry.target });
case 'candidate': return t('doctorWhyCandidate', { file: entry.target });
case 'candidate': return t(CANDIDATE_WHY[entry.via], { file: entry.target });
case 'conflict': return t('doctorWhyConflict');
case 'ambiguous': return t('doctorWhyAmbiguous');
case 'changed': return t('doctorWhyChanged');
@@ -166,7 +167,7 @@ export async function renderDoctorPage(owner, panel, { refresh = false } = {}) {
if (token !== panel._doctorRender || panel.style.display === 'none') return;
const problems = entries.filter(entry => isProblem(entry) || entry.state === 'changed')
.sort((a, b) => ORDER.indexOf(a.state) - ORDER.indexOf(b.state));
.sort((a, b) => ORDER.indexOf(a.state) - ORDER.indexOf(b.state) || (b.via === 'name-size') - (a.via === 'name-size'));
const fixable = entries.filter(entry => entry.state === 'fixable');
const here = entries.filter(entry => entry.state === 'ready' || entry.state === 'changed');
const replaced = here.filter(entry => replacedFrom(entry.widget));