Before an online scan (the scan page, a card's radar, after a download or an import), scan_connection.js asks GET /anomalous/civitai_check, which tries both of Civitai's addresses. When neither answers, a dialog says why and offers Scan from the files (the offline scan, so nothing waits on every model), Set a proxy (Settings → Models, Network) or Cancel. A check that reached Civitai is trusted for five minutes. anomalousConfirm takes okClass, so this choice is not drawn as a delete. A model that is a link with no info beside it shows the info beside the file it links to (metadata._info_base): A1111's Civitai Helper leaves .civitai.info there. Only read there; covers, edits and scans stay beside the link. Checked on the isolated ComfyUI with the check and the scan request stubbed in the page: the dialog's three choices (from files posts offline_only, set a proxy opens the Models tab, cancel posts nothing), an answering Civitai scans online without asking, and a second scan skips the check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
242 lines
10 KiB
JavaScript
242 lines
10 KiB
JavaScript
/**
|
|
* Starting scans and following them: every model folder, picked models or listed models from
|
|
* the scan page (all through /anomalous/scan_all), and one model from its card. Progress goes
|
|
* to scan_progress.js; when a scan ends its result (GET /anomalous/last_scan) is shown, and
|
|
* node drop-downs, hashes and the model grid are refreshed. An online scan first asks whether
|
|
* Civitai can be reached (scan_connection.js) and, when not, whether to scan from the files.
|
|
* No page DOM here: ui_scan_page.js renders the page.
|
|
*/
|
|
|
|
import { app } from '../../../scripts/app.js';
|
|
import { translate as t } from './locales.js';
|
|
import { updateScanProgress, finishScanProgress, failScanProgress } from './scan_progress.js';
|
|
import { oneModelLine, resultLine } from './scan_results.js';
|
|
import { showWorkbenchToast } from './ui_prompt_toast.js';
|
|
import { checkWorkflowModels, fixWorkflowModels } from './model_check.js';
|
|
import { updateDoctorBanner } from './ui_doctor_banner.js';
|
|
import { onlineOrAsk } from './scan_connection.js';
|
|
|
|
const POLL_MS = 2000;
|
|
const SCAN_RADAR_ICON_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="display:block;"><circle cx="12" cy="12" r="9"/><line x1="12" y1="3" x2="12" y2="21" stroke-opacity="0.35"/><line x1="3" y1="12" x2="21" y2="12" stroke-opacity="0.35"/><line x1="12" y1="12" x2="18.5" y2="5.5" stroke-width="2"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>`;
|
|
|
|
let running = false;
|
|
|
|
export function setScanButtonState(btn, isScanning) {
|
|
if (!btn) return;
|
|
btn.innerHTML = SCAN_RADAR_ICON_SVG;
|
|
btn.classList.toggle('anomalous-radar-spinning', Boolean(isScanning));
|
|
btn.style.opacity = isScanning ? '0.85' : '1';
|
|
btn.style.animation = '';
|
|
}
|
|
|
|
function setActiveScanButtonState(isScanning) {
|
|
setScanButtonState(document.getElementById('anomalous-scan-btn'), isScanning);
|
|
}
|
|
|
|
/** Whether a scan started here is still running (the page disables its buttons). */
|
|
export function isScanRunning() {
|
|
return running;
|
|
}
|
|
|
|
/**
|
|
* The scraper's request fields for the page's options:
|
|
* { offline, virtualRename, physicalRename, forceOverwrite, retryUnmatched }.
|
|
*/
|
|
export function scanRequestBody(options) {
|
|
const offline = Boolean(options.offline);
|
|
const virtualRename = !offline && Boolean(options.virtualRename);
|
|
const physicalRename = !offline && Boolean(options.physicalRename);
|
|
return {
|
|
offline_only: offline,
|
|
skip_rename: !virtualRename && !physicalRename,
|
|
virtual_rename: virtualRename,
|
|
physical_rename: physicalRename,
|
|
force_overwrite: !offline && Boolean(options.forceOverwrite),
|
|
retry_unmatched: !offline && Boolean(options.retryUnmatched),
|
|
};
|
|
}
|
|
|
|
/** Listed models ({type, path_idx, rel}) as the scan's `targets`, one per folder. */
|
|
export function targetsForItems(items) {
|
|
const folders = new Map();
|
|
for (const item of items) {
|
|
const cut = item.rel.lastIndexOf('/');
|
|
const subfolder = cut < 0 ? '/' : `/${item.rel.slice(0, cut)}`;
|
|
const key = `${item.type}|${item.path_idx}|${subfolder}`;
|
|
if (!folders.has(key)) folders.set(key, { type: item.type, path_idx: item.path_idx, subfolder, files: [] });
|
|
folders.get(key).files.push(item.filename);
|
|
}
|
|
return [...folders.values()];
|
|
}
|
|
|
|
async function postJson(url, body) {
|
|
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
const data = await res.json().catch(() => ({}));
|
|
if (data.status !== 'ok') throw new Error(data.message || `HTTP ${res.status}`);
|
|
return data;
|
|
}
|
|
|
|
/** The last scan's result ({} before the first, or when it cannot be read). */
|
|
export async function fetchLastScan() {
|
|
try {
|
|
return await (await fetch('/anomalous/last_scan')).json();
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** Polls a status URL until the scan there stops; resolves with the last status. */
|
|
function followScan(statusUrl, extra = {}, everyMs = POLL_MS, title = '') {
|
|
return new Promise((resolve, reject) => {
|
|
const poll = setInterval(async () => {
|
|
try {
|
|
const status = await (await fetch(statusUrl)).json();
|
|
updateScanProgress({ ...status, ...extra }, title);
|
|
if (!status.scanning) {
|
|
clearInterval(poll);
|
|
resolve(status);
|
|
}
|
|
} catch (error) {
|
|
clearInterval(poll);
|
|
reject(error);
|
|
}
|
|
}, everyMs);
|
|
});
|
|
}
|
|
|
|
async function afterScan(owner, autoFix) {
|
|
try {
|
|
if (app?.refreshComboInNodes) await app.refreshComboInNodes();
|
|
if (window.anomalous_reload_hashes) await window.anomalous_reload_hashes();
|
|
} catch (error) {
|
|
console.warn('[AMB] Error reloading hashes or combo nodes:', error);
|
|
}
|
|
if (autoFix) {
|
|
// The scan may have hashed the files the open workflow could not find.
|
|
const entries = await checkWorkflowModels();
|
|
const count = fixWorkflowModels(entries);
|
|
updateDoctorBanner(owner, count ? await checkWorkflowModels() : entries, count);
|
|
if (count) showWorkbenchToast(t('doctorBannerPutIn', { count }));
|
|
}
|
|
owner.loadModels?.();
|
|
}
|
|
|
|
/** The ended scan's line, with a way to its result when the progress box floats. */
|
|
export function showScanResult(owner, result) {
|
|
finishScanProgress(resultLine(result), {
|
|
label: t('scanProgressShowResult'),
|
|
onClick: () => {
|
|
owner.show?.();
|
|
owner.openScanPage?.('result');
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Runs one scan from the scan page: every active model folder, or `options.targets` (picked or
|
|
* listed models, targetsForItems). Resolves with the scan's result when it has ended, or null
|
|
* when it could not start (the reason shown).
|
|
*/
|
|
export async function startScan(owner, options) {
|
|
if (running) return null;
|
|
running = true;
|
|
setActiveScanButtonState(true);
|
|
try {
|
|
const how = options.offline ? 'offline' : await onlineOrAsk(owner);
|
|
if (!how) return null;
|
|
const body = scanRequestBody({ ...options, offline: how === 'offline' });
|
|
if (options.targets) body.targets = options.targets;
|
|
const data = await postJson('/anomalous/scan_all', body);
|
|
updateScanProgress({ scanning: true, phase: 'preparing', recovered: data.recovered });
|
|
const status = await followScan('/anomalous/global_scan_status');
|
|
const result = await fetchLastScan();
|
|
if (status.interrupted) failScanProgress(t('scanProgressInterrupted'));
|
|
else showScanResult(owner, result);
|
|
await afterScan(owner, options.autoFix);
|
|
return result;
|
|
} catch (error) {
|
|
failScanProgress(t('scanPageStartFailed', { error: String(error.message || error) }));
|
|
return null;
|
|
} finally {
|
|
running = false;
|
|
setActiveScanButtonState(false);
|
|
}
|
|
}
|
|
|
|
/** The radar button on a model card: scan just that model, in its own folder. */
|
|
export async function triggerDirectModelScan(model, triggerBtn = null, browserInstance = null) {
|
|
const browser = browserInstance || this || {};
|
|
if (!model || !model.filename) return;
|
|
|
|
const modelLabel = model.name || model.filename;
|
|
const titleText = t('scanOneTitle', { name: modelLabel });
|
|
const how = await onlineOrAsk(browser);
|
|
if (!how) return;
|
|
|
|
if (triggerBtn) {
|
|
triggerBtn.classList.add('anomalous-radar-spinning');
|
|
if (triggerBtn.firstElementChild) {
|
|
triggerBtn.firstElementChild.style.animation = 'anomalous-radar-spin 1.2s linear infinite';
|
|
triggerBtn.firstElementChild.style.stroke = '#10b981';
|
|
}
|
|
triggerBtn.style.pointerEvents = 'none';
|
|
triggerBtn.style.opacity = '0.7';
|
|
}
|
|
setActiveScanButtonState(true);
|
|
updateScanProgress({ scanning: true, phase: 'preparing', total: 1, current: 0, filename: model.filename }, titleText);
|
|
|
|
const resetBtn = () => {
|
|
setActiveScanButtonState(false);
|
|
if (triggerBtn) {
|
|
triggerBtn.classList.remove('anomalous-radar-spinning');
|
|
if (triggerBtn.firstElementChild) {
|
|
triggerBtn.firstElementChild.style.animation = '';
|
|
triggerBtn.firstElementChild.style.stroke = '';
|
|
}
|
|
triggerBtn.style.pointerEvents = '';
|
|
triggerBtn.style.opacity = '';
|
|
}
|
|
};
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
type: browser.currentType || 'checkpoints',
|
|
path_idx: browser.currentPathIdx || 0,
|
|
subfolder: browser.currentSubfolder || '/',
|
|
});
|
|
// A model that was looked up without an answer is looked up again.
|
|
await postJson(`/anomalous/scan?${params}`, {
|
|
target_files: [model.filename],
|
|
offline_only: how === 'offline',
|
|
skip_rename: true,
|
|
virtual_rename: false,
|
|
physical_rename: false,
|
|
force_overwrite: false,
|
|
retry_unmatched: how === 'online' && model.metadata?.info_source === 'local',
|
|
});
|
|
showWorkbenchToast(titleText);
|
|
const status = await followScan(`/anomalous/scan_status?${params}`, {}, 1200, titleText);
|
|
resetBtn();
|
|
if (status.interrupted) {
|
|
failScanProgress(t('scanProgressInterrupted'));
|
|
showWorkbenchToast(t('scanProgressInterrupted'));
|
|
} else {
|
|
const line = oneModelLine(await fetchLastScan());
|
|
finishScanProgress(line);
|
|
showWorkbenchToast(line);
|
|
}
|
|
browser.loadModels?.();
|
|
try {
|
|
if (app?.refreshComboInNodes) await app.refreshComboInNodes();
|
|
if (window.anomalous_reload_hashes) await window.anomalous_reload_hashes();
|
|
} catch (e) {
|
|
console.warn('[AMB] Error reloading hashes or combo nodes:', e);
|
|
}
|
|
} catch (e) {
|
|
resetBtn();
|
|
const message = String(e.message || e) || t('scanPageStartFailed', { error: '' });
|
|
failScanProgress(message);
|
|
showWorkbenchToast(message);
|
|
}
|
|
}
|