Files
DemonGatanjieuandClaude Opus 5.5 142280e4ec Scans ask when Civitai cannot be reached; linked models keep outside info
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>
2026-10-16 22:21:57 +08:00

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);
}
}