Files
DemonGatanjieuandClaude Opus 5.5 c30180cb8d Fix Tidy misjudging Anima LLLite and IP-Adapter files
An Anima LLLite in controlnet is not misplaced while the ComfyUI-Anima-LLLite pack
(which reads it from there) is loaded, and identical copies kept one per loader folder
(controlnet and model_patches) are listed as shared, both in use, not as duplicates to
recycle. An IP-Adapter carrying LoRA layers (the Anima IP-Adapter) is no longer read as a
LoRA. The header cache keeps the new "form" field (older entries are read again).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:27:49 +08:00

269 lines
12 KiB
JavaScript

/**
* The models page's Tidy tab (`owner.modelView === 'tidy'`): two steps, each a list of cards
* with the model's cover and name. Step 1, models in a folder whose loader cannot read them:
* from (red) → to (green), one plain reason, "Move there" (the place can be changed). Step 2,
* identical files side by side: the copy to keep is marked (the one in the folder that fits,
* changeable by clicking another), "Recycle the extra copies". Models that load where they
* are anyway, or an extra copy of a file in the right folder (step 2 has it), are not listed
* in step 1. Nothing changes without a press; after one the tab checks again
* and keeps a note of what was done (`owner.modelTidy.notes`). model_placement.js does the
* requests. Under the two steps, "Not used lately" (ui_model_usage.js).
*/
import { app } from "../../../scripts/app.js";
import { translate as t } from './locales.js';
import { checkPlacement, moveErrorText, moveModel, recycleModel, tidyMisplaced } from './model_placement.js';
import { fetchImportFolders } from './model_import.js';
import { formatSize } from './file_size.js';
import { joinPath } from './ui_model_download.js';
import { renderUsageSection } from './ui_model_usage.js';
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;
}
const fileName = (rel) => String(rel).split('/').pop();
const folderOf = (rel) => String(rel).split('/').slice(0, -1).join('/');
const kindName = (kind) => t(`importKind_${kind}`);
const loaderName = (kind) => t(`tidyLoader_${kind}`);
const place = (kind, rel) => [kindName(kind), folderOf(rel)].filter(Boolean).join(' / ');
function state(owner) {
owner.modelTidy ||= { notes: [], deep: false, controller: null };
return owner.modelTidy;
}
/** Opens the tab, fresh (no notes from last time). */
export function showModelTidy(owner) {
Object.assign(state(owner), { notes: [], deep: false });
owner.modelView = 'tidy';
owner.goTo?.('models');
owner.loadModels();
}
/** After a move or a removal: ComfyUI's lists, then the folders and this tab again. */
async function changed(owner, note) {
state(owner).notes.push(note);
try {
await app.refreshComboInNodes?.();
} catch (error) {
console.warn('[AMB] Tidy: could not reload the model lists.', error);
}
owner.loadFolders?.();
}
/** A model's cover (a video's still frame), or a placeholder. */
function cover(item, className = 'anomalous-tidy-cover') {
const box = el('div', className);
if (item.preview_url) {
const image = el('img');
image.loading = 'lazy';
image.alt = '';
image.src = `${item.preview_url}&variant=${/\.(mp4|webm|mov|avi)(&|$)/i.test(item.preview_url) ? 'poster' : 'card'}`;
box.append(image);
} else {
box.append(el('span', 'anomalous-tidy-cover-none', t('noPreview')));
}
return box;
}
function title(item) {
const line = el('div', 'anomalous-tidy-name');
line.append(el('strong', '', item.name));
if (item.version) line.append(el('span', 'anomalous-card-version anomalous-tidy-version', item.version));
return line;
}
function misplacedCard(owner, item, folders) {
const card = el('div', 'anomalous-tidy-card');
const body = el('div', 'anomalous-tidy-body');
body.append(title(item), el('div', 'anomalous-tidy-file', `${fileName(item.rel)} · ${formatSize(item.size)}`));
const roots = (folders?.types?.[item.kind] || []).filter(root => root.exists);
let root = roots.find(entry => entry.index === item.to.root) || roots[0] || { index: item.to.root, path: '' };
let rel = item.to.rel;
const route = el('div', 'anomalous-tidy-route');
const to = el('span', 'anomalous-tidy-place is-to', place(item.kind, rel));
route.append(el('span', 'anomalous-tidy-place is-from', place(item.type, item.rel)), el('span', 'anomalous-tidy-arrow', '→'), to);
body.append(route, el('div', 'anomalous-tidy-why', t(`tidyWhy_${item.kind}`) === `tidyWhy_${item.kind}`
? t('tidyBreaks', { loader: loaderName(item.type), right: loaderName(item.kind) })
: t(`tidyWhy_${item.kind}`, { loader: loaderName(item.type), right: loaderName(item.kind) })));
// Where exactly: folded behind "Change place".
const where = el('div', 'anomalous-tidy-where');
where.hidden = true;
if (roots.length > 1) {
const select = el('select', 'anomalous-download-root');
for (const entry of roots) {
const option = el('option', '', entry.path);
option.value = String(entry.index);
select.append(option);
}
select.value = String(root.index);
select.onchange = () => {
root = roots.find(entry => String(entry.index) === select.value) || root;
showPath();
};
where.append(select);
}
const input = el('input', 'anomalous-download-rel');
input.type = 'text';
input.spellcheck = false;
input.value = rel;
const full = el('div', 'anomalous-download-path');
const showPath = () => {
rel = input.value.trim().replace(/\\/g, '/');
to.textContent = place(item.kind, rel);
full.textContent = root.path ? joinPath(root.path, rel) : rel;
};
input.oninput = showPath;
showPath();
where.append(input, full);
const failure = el('div', 'anomalous-download-note is-bad');
failure.hidden = true;
const actions = el('div', 'anomalous-tidy-actions');
const go = button('anomalous-scan-primary anomalous-scan-small-btn', t('tidyMove'), async () => {
go.disabled = true;
failure.hidden = true;
try {
const moved = await moveModel(item, { type: item.kind, root: root.index, rel });
const notes = [t('tidyMoved', { name: item.name, path: place(moved.type, moved.rel) })];
if (loaderName(item.type) !== loaderName(item.kind)) notes.push(t('tidyMovedHint', { from: loaderName(item.type), to: loaderName(item.kind) }));
if (moved.left?.length) notes.push(t('tidyLeft', { count: moved.left.length }));
await changed(owner, notes.join(' '));
} catch (error) {
go.disabled = false;
failure.textContent = moveErrorText(error);
failure.hidden = false;
}
});
const change = button('anomalous-download-rules', t('tidyChangePlace'), () => {
where.hidden = !where.hidden;
if (!where.hidden) input.focus();
});
actions.append(go, change);
body.append(where, actions, failure);
card.append(cover(item), body);
return card;
}
function duplicateCard(owner, group) {
const card = el('div', 'anomalous-tidy-card is-group');
let keep = group.files.find(file => file.fits) || group.files[0];
const copies = el('div', 'anomalous-tidy-copies');
const draw = () => copies.replaceChildren(...group.files.map(file => {
const copy = button(`anomalous-tidy-copy${file === keep ? ' is-kept' : ''}`, '', () => {
keep = file;
draw();
});
copy.setAttribute('aria-pressed', String(file === keep));
copy.append(cover(file, 'anomalous-tidy-cover is-small'),
el('span', 'anomalous-tidy-copy-place', place(file.type, file.rel)),
el('span', 'anomalous-tidy-copy-file', fileName(file.rel)),
el('span', `anomalous-tidy-copy-tag${file === keep ? ' is-kept' : ''}`, t(file === keep ? 'tidyKeep' : 'tidyExtra')));
return copy;
}));
draw();
const body = el('div', 'anomalous-tidy-body');
body.append(title(group.files[0]),
el('div', 'anomalous-tidy-file', t('tidyDupGroup', { count: group.files.length, size: formatSize(group.size) })),
el('div', 'anomalous-tidy-why', t('tidyDupWhy')), copies);
const failure = el('div', 'anomalous-download-note is-bad');
failure.hidden = true;
const go = button('anomalous-scan-primary anomalous-scan-small-btn', t('tidyRecycleExtra', { count: group.files.length - 1 }), async () => {
const extra = group.files.filter(file => file !== keep);
if (!confirm(t('tidyRecycleConfirm', { count: extra.length, size: formatSize(group.size * extra.length), keep: place(keep.type, keep.rel) }))) return;
go.disabled = true;
failure.hidden = true;
try {
for (const file of extra) await recycleModel(file);
await changed(owner, t('tidyRecycled', { count: extra.length, size: formatSize(group.size * extra.length) }));
} catch (error) {
go.disabled = false;
failure.textContent = moveErrorText(error);
failure.hidden = false;
}
});
const actions = el('div', 'anomalous-tidy-actions');
actions.append(go);
body.append(actions, failure);
card.append(body);
return card;
}
/** Identical files each loading in its own folder (one per loader): said once, nothing to do. */
function sharedNote(groups) {
const note = el('div', 'anomalous-tidy-shared');
note.append(el('div', 'anomalous-tidy-why', t('tidySharedWhy')));
for (const group of groups) {
note.append(el('div', 'anomalous-tidy-file', t('tidySharedItem', {
name: group.files[0].name, places: group.files.map(file => place(file.type, file.rel)).join(t('tidySharedJoin')),
})));
}
return note;
}
function step(number, titleKey, count) {
const head = el('div', 'anomalous-tidy-step');
head.append(el('span', 'anomalous-tidy-step-number', String(number)), el('h3', '', t(titleKey, { count })));
return head;
}
/** Draws the tab into the models grid (after the page's tabs). */
export function renderModelTidyView(owner, grid) {
const view = state(owner);
view.controller?.abort();
const controller = new AbortController();
view.controller = controller;
const section = el('section', 'anomalous-tidy');
section.append(el('p', 'anomalous-tidy-lead', t('tidyLead')));
for (const note of view.notes) section.append(el('div', 'anomalous-tidy-note', note));
const body = el('div', 'anomalous-tidy-list');
body.append(el('div', 'anomalous-download-text', t(view.deep ? 'tidyDeepRunning' : 'tidyLoading')));
section.append(body, renderUsageSection(owner, note => changed(owner, note)));
grid.appendChild(section);
Promise.all([checkPlacement(view.deep, controller.signal), fetchImportFolders().catch(() => null)]).then(([data, folders]) => {
if (controller.signal.aborted || !section.isConnected) return;
const misplaced = tidyMisplaced(data);
const parts = [];
if (!misplaced.length && !data.duplicates.length) {
const done = el('div', 'anomalous-tidy-done');
done.append(el('strong', '', t('tidyAllGood')), el('span', '', t('tidyChecked', { count: data.checked })));
parts.push(done);
}
if (misplaced.length) {
parts.push(step(1, 'tidyStepMisplaced', misplaced.length), ...misplaced.map(item => misplacedCard(owner, item, folders)));
}
if (data.duplicates.length) {
parts.push(step(misplaced.length ? 2 : 1, 'tidyStepDuplicates', data.duplicates.length), ...data.duplicates.map(group => duplicateCard(owner, group)));
}
if (data.shared?.length) parts.push(sharedNote(data.shared));
if (data.unchecked?.groups) {
const more = el('div', 'anomalous-tidy-unchecked');
more.append(el('span', 'anomalous-download-note', t('tidyUnchecked', { groups: data.unchecked.groups, size: formatSize(data.unchecked.bytes) })),
button('anomalous-scan-row-btn', t('tidyDeep'), () => {
view.deep = true;
owner.loadModels();
}));
parts.push(more);
}
body.replaceChildren(...parts);
}).catch((error) => {
if (controller.signal.aborted) return;
console.warn('[AMB] Tidy: the check failed.', error);
body.replaceChildren(el('div', 'anomalous-download-text is-bad', t('tidyFailed')));
});
}