feat(scan): a scan page replaces the scan wizard; unmatched models can be looked up again

The rail's scan button and Home's "Scan model folders" open a tool page
instead of the seven-switch dialog: counts of models matched on Civitai,
not matched and not scanned yet (GET /anomalous/scan_summary), one button
to scan, progress inside the page (scan_progress.js hosts its panel there;
it floats again when the page or the browser is closed). Offline, renaming
files, fetching everything again, picking models and the API key (with its
set / not set state) are under Advanced; the two risky switches reset after
each scan. The provenance switch moved to Settings -> Model settings.

Scanner:
- --retry-unmatched: only models whose info was written from the file alone
  (Civitai had no match or could not be reached) are looked up again, with
  the saved hash when the file is unchanged and without replacing covers.
  Before, such models stayed local-only unless every model was refetched,
  which also deleted custom covers.
- A display name set in the model editor is no longer replaced by Civitai's
  name on each scan or when the info is fetched again.
- Both scan routes map request fields through _scraper_flags; folder cache
  clearing is one helper (scanner.py 602 -> 579 lines).
- sidecar_info / is_unmatched live in model_identity.py for scraper and API.

ui_scan_wizard.js is gone: scan_runner.js starts and follows scans,
ui_scan_page.js renders the page; 36 wizard-only locale keys removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-02 01:14:32 +08:00
co-authored by Claude Opus 5.5
parent f13b725d97
commit 5af9b6d260
-918
View File
@@ -1,918 +0,0 @@
/**
* Scan wizard UI and scan launch/polling coordination.
*/
import { app } from '../../../scripts/app.js';
import { translate } from './locales.js';
import { updateScanProgress, finishScanProgress, failScanProgress } from './scan_progress.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
const t = (key, params) => translate(key, params);
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>`;
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);
}
export function openScanWizard({ isGlobal = false, targetFiles = null } = {}) {
let wizard = document.getElementById('anomalous-wizard-modal');
if (wizard) {
if (typeof wizard.cleanupModal === 'function') wizard.cleanupModal();
else if (wizard.parentNode) wizard.parentNode.removeChild(wizard);
}
wizard = document.createElement('div');
wizard.id = 'anomalous-wizard-modal';
wizard.style.position = 'fixed';
wizard.style.top = '0';
wizard.style.left = '0';
wizard.style.width = '100vw';
wizard.style.height = '100vh';
wizard.style.backgroundColor = 'rgba(0,0,0,0.6)';
wizard.style.zIndex = '999999';
wizard.style.display = 'flex';
wizard.style.justifyContent = 'center';
wizard.style.alignItems = 'center';
wizard.style.fontFamily = 'Roboto, "Segoe UI", sans-serif';
const closeWizard = () => {
document.removeEventListener('keydown', handleKeydown);
if (wizard && wizard.parentNode) {
wizard.parentNode.removeChild(wizard);
}
};
wizard.cleanupModal = closeWizard;
const handleKeydown = (e) => {
if (e.key === 'Escape') closeWizard();
};
document.addEventListener('keydown', handleKeydown);
wizard.onclick = (e) => {
if (e.target === wizard) closeWizard();
};
const content = document.createElement('div');
content.style.background = '#1E1E1E';
content.style.borderRadius = '12px';
content.style.padding = '28px 32px 20px 32px';
content.style.width = '760px';
content.style.maxWidth = '95%';
content.style.maxHeight = '85vh';
content.style.display = 'flex';
content.style.flexDirection = 'column';
content.style.boxShadow = '0 11px 15px -7px rgba(0,0,0,0.2), 0 24px 38px 3px rgba(0,0,0,0.14), 0 9px 46px 8px rgba(0,0,0,0.12)';
content.style.color = '#fff';
content.style.position = 'relative';
// Top Header Area
const headerArea = document.createElement('div');
headerArea.style.display = 'flex';
headerArea.style.justifyContent = 'space-between';
headerArea.style.alignItems = 'center';
headerArea.style.marginBottom = '24px';
const title = document.createElement('h2');
if (targetFiles) {
title.textContent = t('sidebarSingleModelScan', { files: targetFiles });
} else {
title.textContent = t(isGlobal ? 'sidebarGlobalScanWizard' : 'sidebarScanWizardTitle');
}
title.style.margin = '0';
title.style.fontSize = '1.6em';
title.style.fontWeight = '500';
// Toolbar right
const topToolbar = document.createElement('div');
topToolbar.style.display = 'flex';
topToolbar.style.gap = '8px';
const createGhostBtn = (icon, textKey, onClick) => {
const btn = document.createElement('button');
btn.textContent = `${icon} ${t(textKey)}`;
btn.style.padding = '6px 12px';
btn.style.background = 'transparent';
btn.style.color = '#ccc';
btn.style.border = '1px solid rgba(255,255,255,0.1)';
btn.style.borderRadius = '6px';
btn.style.cursor = 'pointer';
btn.style.fontSize = '0.85em';
btn.style.transition = 'all 0.2s';
btn.onmouseover = () => { btn.style.background = 'rgba(255,255,255,0.08)'; btn.style.color = '#fff'; };
btn.onmouseout = () => { btn.style.background = 'transparent'; btn.style.color = '#ccc'; };
btn.onclick = onClick;
return btn;
};
const apiKeyBtn = createGhostBtn('🔑', 'sidebarApiKey', () => {
const newKey = prompt(t('sidebarApiKeyPrompt'), '');
if (newKey !== null) {
fetch('/anomalous/save_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: newKey.trim() })
}).then(res => res.json()).then(data => {
if (data.status !== 'ok') throw new Error(data.message || 'Save failed');
alert(t('sidebarKeySaved'));
}).catch(error => alert(t('sidebarSaveFailed') + error.message));
}
});
topToolbar.appendChild(apiKeyBtn);
headerArea.appendChild(title);
headerArea.appendChild(topToolbar);
content.appendChild(headerArea);
let scanMode = 'civitai';
let enableRename = true;
let enableAutoCheck = true;
let enableForceOverwrite = false;
let updateSections = () => { };
const formGroup = document.createElement('div');
formGroup.style.display = 'flex';
formGroup.style.flexDirection = 'column';
formGroup.style.gap = '24px';
formGroup.style.overflowY = 'auto';
formGroup.style.flex = '1';
formGroup.style.minHeight = '0';
formGroup.style.paddingRight = '6px';
const createChoiceCard = (id, icon, titleKey, descKey, isSelected) => {
const card = document.createElement('div');
card.style.flex = '1';
card.style.background = isSelected ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.04)';
card.style.border = `2px solid ${isSelected ? '#e5e7eb' : 'transparent'}`;
card.style.borderRadius = '8px';
card.style.padding = '16px';
card.style.cursor = 'pointer';
card.style.transition = 'all 0.2s';
card.style.display = 'flex';
card.style.flexDirection = 'column';
card.style.gap = '12px';
card.style.alignItems = 'flex-start';
card.onmouseover = () => {
if (!isSelected) card.style.background = 'rgba(255, 255, 255, 0.08)';
};
card.onmouseout = () => {
if (!isSelected) card.style.background = 'rgba(255, 255, 255, 0.04)';
};
const topRow = document.createElement('div');
topRow.style.display = 'flex';
topRow.style.alignItems = 'center';
topRow.style.gap = '12px';
const iconDiv = document.createElement('div');
iconDiv.innerText = icon;
iconDiv.style.fontSize = '1.6em';
const tTitle = document.createElement('div');
tTitle.textContent = t(titleKey);
tTitle.style.fontWeight = '500';
tTitle.style.fontSize = '1.05em';
tTitle.style.color = isSelected ? '#ffffff' : '#e5e7eb';
topRow.appendChild(iconDiv);
topRow.appendChild(tTitle);
const tDesc = document.createElement('div');
tDesc.textContent = t(descKey);
tDesc.style.fontSize = '0.85em';
tDesc.style.color = '#9ca3af';
tDesc.style.lineHeight = '1.5';
card.appendChild(topRow);
card.appendChild(tDesc);
return card;
};
let targetMode = 'all';
let selectedForScan = new Map();
if (!targetFiles) {
const section0 = document.createElement('div');
const sec0Title = document.createElement('div');
sec0Title.textContent = t('sidebarStep0');
sec0Title.style.fontWeight = '500';
sec0Title.style.color = '#e5e7eb';
sec0Title.style.marginBottom = '12px';
sec0Title.style.fontSize = '0.95em';
section0.appendChild(sec0Title);
const tContainer = document.createElement('div');
tContainer.style.display = 'flex';
tContainer.style.flexDirection = 'row';
tContainer.style.gap = '16px';
tContainer.style.marginBottom = '16px';
const customActionDiv = document.createElement('div');
customActionDiv.style.display = 'none';
customActionDiv.style.marginTop = '16px';
const openSelectorBtn = document.createElement('button');
openSelectorBtn.textContent = t('sidebarOpenSelector');
openSelectorBtn.style.cssText = 'width:100%;padding:12px;background:#e5e7eb;color:#111;border:none;border-radius:6px;cursor:pointer;font-weight:600;font-size:1.05em;box-shadow:0 2px 4px rgba(0,0,0,0.2);';
const selectedCountSpan = document.createElement('div');
selectedCountSpan.style.cssText = 'text-align:center;color:#e5e7eb;margin-top:8px;font-size:0.9em;';
selectedCountSpan.textContent = t('sidebarSelectedZero');
const updateSelectedCount = () => {
let total = 0;
for (const set of selectedForScan.values()) total += set.size;
selectedCountSpan.textContent = t('sidebarSelectedCount', { count: total });
};
openSelectorBtn.onclick = () => {
this._openAdvancedModelSelector(selectedForScan, (newSelection) => {
selectedForScan = newSelection;
updateSelectedCount();
});
};
customActionDiv.appendChild(openSelectorBtn);
customActionDiv.appendChild(selectedCountSpan);
let tCard1, tCard2;
const updateTargetCards = () => {
if (tCard1 && tCard2) {
tContainer.removeChild(tCard1);
tContainer.removeChild(tCard2);
}
tCard1 = createChoiceCard('all', '📂', 'sidebarTargetGlobal', 'sidebarTargetGlobalDesc', targetMode === 'all');
tCard2 = createChoiceCard('custom', '☑️', 'sidebarTargetCustom', 'sidebarTargetCustomDesc', targetMode === 'custom');
tCard1.onclick = () => {
targetMode = 'all';
updateTargetCards();
customActionDiv.style.display = 'none';
};
tCard2.onclick = () => {
targetMode = 'custom';
updateTargetCards();
customActionDiv.style.display = 'block';
};
tContainer.appendChild(tCard1);
tContainer.appendChild(tCard2);
};
updateTargetCards();
section0.appendChild(tContainer);
section0.appendChild(customActionDiv);
formGroup.appendChild(section0);
}
// === Step 1: Fetch Data ===
const section1 = document.createElement('div');
const sec1Title = document.createElement('div');
sec1Title.textContent = t('sidebarStep1');
sec1Title.style.fontWeight = '500';
sec1Title.style.color = '#e5e7eb';
sec1Title.style.marginBottom = '12px';
sec1Title.style.fontSize = '0.95em';
section1.appendChild(sec1Title);
const cardsContainer = document.createElement('div');
cardsContainer.style.display = 'flex';
cardsContainer.style.flexDirection = 'row';
cardsContainer.style.gap = '16px';
let card1, card2;
const updateCards = () => {
if (card1 && card2) {
cardsContainer.removeChild(card1);
cardsContainer.removeChild(card2);
}
card1 = createChoiceCard('civitai', '🌍', 'sidebarOnline', 'sidebarOnlineDesc', scanMode === 'civitai');
card2 = createChoiceCard('offline', '🔌', 'sidebarOffline', 'sidebarOfflineDesc', scanMode === 'offline');
card1.onclick = () => { scanMode = 'civitai'; updateCards(); updateSections(); };
card2.onclick = () => { scanMode = 'offline'; updateCards(); updateSections(); };
cardsContainer.appendChild(card1);
cardsContainer.appendChild(card2);
};
updateCards();
section1.appendChild(cardsContainer);
formGroup.appendChild(section1);
// Material Switch Builder
const createMaterialSwitch = (initialState, onChange) => {
const track = document.createElement('div');
track.style.width = '36px';
track.style.height = '14px';
track.style.borderRadius = '7px';
track.style.background = initialState ? 'rgba(255, 255, 255, 0.45)' : 'rgba(255,255,255,0.15)';
track.style.position = 'relative';
track.style.cursor = 'pointer';
track.style.transition = 'background 0.3s';
track.style.display = 'flex';
track.style.alignItems = 'center';
const thumb = document.createElement('div');
thumb.style.width = '20px';
thumb.style.height = '20px';
thumb.style.borderRadius = '50%';
thumb.style.background = initialState ? '#ffffff' : '#888888';
thumb.style.position = 'absolute';
thumb.style.left = initialState ? '16px' : '0px';
thumb.style.transition = 'left 0.3s, background 0.3s';
thumb.style.boxShadow = '0 1px 3px rgba(0,0,0,0.4)';
let state = initialState;
track.onclick = () => {
state = !state;
track.style.background = state ? 'rgba(255, 255, 255, 0.45)' : 'rgba(255,255,255,0.15)';
thumb.style.background = state ? '#ffffff' : '#888888';
thumb.style.left = state ? '16px' : '0px';
onChange(state);
};
track.appendChild(thumb);
return track;
};
const createListRow = (icon, titleKey, descKey, actionEl) => {
const row = document.createElement('div');
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.justifyContent = 'space-between';
row.style.padding = '12px 0';
row.style.borderBottom = '1px solid rgba(255,255,255,0.05)';
const left = document.createElement('div');
left.style.display = 'flex';
left.style.alignItems = 'flex-start';
left.style.gap = '16px';
const iconEl = document.createElement('div');
if (typeof icon === 'string' && icon.startsWith('<')) {
iconEl.innerHTML = icon;
} else {
iconEl.innerText = icon;
}
iconEl.style.fontSize = '1.4em';
iconEl.style.lineHeight = '1.2';
iconEl.style.width = '24px';
iconEl.style.textAlign = 'center';
const textDiv = document.createElement('div');
const titleEl = document.createElement('div');
titleEl.textContent = t(titleKey);
titleEl.style.fontWeight = '500';
titleEl.style.fontSize = '1.0em';
titleEl.style.color = '#fff';
const d = document.createElement('div');
d.innerHTML = t(descKey);
d.style.fontSize = '0.85em';
d.style.color = '#aaa';
d.style.marginTop = '4px';
textDiv.appendChild(titleEl);
textDiv.appendChild(d);
left.appendChild(iconEl);
left.appendChild(textDiv);
row.appendChild(left);
if (actionEl) row.appendChild(actionEl);
return row;
};
// === Step 2: Normalize Naming ===
const section2 = document.createElement('div');
const sec2Title = document.createElement('div');
sec2Title.textContent = t('sidebarStep2');
sec2Title.style.fontWeight = '500';
sec2Title.style.color = '#e5e7eb';
sec2Title.style.marginBottom = '8px';
sec2Title.style.fontSize = '0.95em';
section2.appendChild(sec2Title);
const s2List = document.createElement('div');
let enableVirtualRename = true;
let enablePhysicalRename = false;
const dualChannelRow = document.createElement('div');
dualChannelRow.style.display = 'flex';
dualChannelRow.style.flexDirection = 'column';
dualChannelRow.style.gap = '14px';
dualChannelRow.style.padding = '0 16px 12px 16px';
dualChannelRow.style.marginLeft = '40px';
const updateDualChannelUI = () => {
dualChannelRow.style.opacity = enableRename ? '1' : '0.4';
dualChannelRow.style.pointerEvents = enableRename ? 'auto' : 'none';
};
const virtualSwitch = createMaterialSwitch(enableVirtualRename, (s) => enableVirtualRename = s);
let physicalProtectionNotice = null;
const physicalSwitch = createMaterialSwitch(enablePhysicalRename, (s) => {
enablePhysicalRename = s;
if (physicalProtectionNotice) {
physicalProtectionNotice.style.display = s ? 'block' : 'none';
}
});
const vContainer = document.createElement('div');
vContainer.style.display = 'flex';
vContainer.style.flexDirection = 'column';
const vRow = document.createElement('div');
vRow.style.display = 'flex';
vRow.style.alignItems = 'center';
vRow.style.gap = '8px';
vRow.innerHTML = `<span style="font-size:0.9em; color:#ddd;">✨ ${t('sidebarVirtualRename')}</span>`;
vRow.appendChild(virtualSwitch);
const vDesc = document.createElement('div');
vDesc.style.fontSize = '0.8em';
vDesc.style.color = '#888';
vDesc.style.marginTop = '4px';
vDesc.textContent = t('sidebarVirtualRenameDesc');
vContainer.appendChild(vRow);
vContainer.appendChild(vDesc);
const pContainer = document.createElement('div');
pContainer.style.display = 'flex';
pContainer.style.flexDirection = 'column';
const pRow = document.createElement('div');
pRow.style.display = 'flex';
pRow.style.alignItems = 'center';
pRow.style.gap = '8px';
pRow.innerHTML = `<span style="font-size:0.9em; color:#ddd; display:inline-flex; align-items:center; gap:6px;"><svg style="width:14px;height:14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>${t('sidebarPhysicalRename')}</span>`;
pRow.appendChild(physicalSwitch);
const pDesc = document.createElement('div');
pDesc.style.fontSize = '0.8em';
pDesc.style.color = '#888';
pDesc.style.marginTop = '4px';
pDesc.innerHTML = t('sidebarPhysicalRenameDesc');
pContainer.appendChild(pRow);
pContainer.appendChild(pDesc);
physicalProtectionNotice = document.createElement('div');
physicalProtectionNotice.style.display = enablePhysicalRename ? 'block' : 'none';
physicalProtectionNotice.style.marginTop = '8px';
physicalProtectionNotice.style.padding = '9px 11px';
physicalProtectionNotice.style.border = '1px solid rgba(251, 188, 4, 0.45)';
physicalProtectionNotice.style.borderRadius = '6px';
physicalProtectionNotice.style.background = 'rgba(251, 188, 4, 0.08)';
physicalProtectionNotice.style.color = '#fdd663';
physicalProtectionNotice.style.fontSize = '0.8em';
physicalProtectionNotice.style.lineHeight = '1.45';
physicalProtectionNotice.textContent = t('sidebarPhysicalRenameProtectedNotice');
pContainer.appendChild(physicalProtectionNotice);
dualChannelRow.appendChild(vContainer);
dualChannelRow.appendChild(pContainer);
const editSvg = '<svg style="width:20px;height:20px;vertical-align:middle;stroke:#dc143c;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>';
s2List.appendChild(createListRow(editSvg, 'sidebarNormalize', 'sidebarNormalizeDesc', createMaterialSwitch(enableRename, (s) => { enableRename = s; updateDualChannelUI(); })));
s2List.lastChild.style.borderBottom = 'none';
s2List.appendChild(dualChannelRow);
updateDualChannelUI();
section2.appendChild(s2List);
formGroup.appendChild(section2);
// === Step 3: Workflow Protection & Fix ===
const section3 = document.createElement('div');
const sec3Title = document.createElement('div');
sec3Title.textContent = t('sidebarStep3');
sec3Title.style.fontWeight = '500';
sec3Title.style.color = '#e5e7eb';
sec3Title.style.marginBottom = '8px';
sec3Title.style.fontSize = '0.95em';
section3.appendChild(sec3Title);
const s3List = document.createElement('div');
const isInject = localStorage.getItem('anomalous_inject_hash') !== 'false';
const cubeSvg = '<svg style="width:20px;height:20px;vertical-align:middle;stroke:#dc143c;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>';
const warnSvg = '<svg style="width:20px;height:20px;vertical-align:middle;stroke:#f59e0b;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
const wandSvg = '<svg style="width:20px;height:20px;vertical-align:middle;stroke:#8b5cf6;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>';
s3List.appendChild(createListRow(cubeSvg, 'sidebarProvenance', 'sidebarProvenanceDesc', createMaterialSwitch(isInject, (s) => localStorage.setItem('anomalous_inject_hash', s ? 'true' : 'false'))));
s3List.appendChild(createListRow(warnSvg, 'sidebarOverwrite', 'sidebarOverwriteDesc', createMaterialSwitch(enableForceOverwrite, (s) => enableForceOverwrite = s)));
s3List.appendChild(createListRow(wandSvg, 'sidebarSmartFix', 'sidebarSmartFixDesc', createMaterialSwitch(enableAutoCheck, (s) => enableAutoCheck = s)));
s3List.lastChild.style.borderBottom = 'none';
section3.appendChild(s3List);
formGroup.appendChild(section3);
content.appendChild(formGroup);
updateSections = () => {
if (scanMode === 'offline') {
section2.style.opacity = '0.3';
section2.style.pointerEvents = 'none';
section3.style.opacity = '0.3';
section3.style.pointerEvents = 'none';
} else {
section2.style.opacity = '1';
section2.style.pointerEvents = 'auto';
section3.style.opacity = '1';
section3.style.pointerEvents = 'auto';
}
};
updateSections();
// Execute Scan Logic
const doScan = async () => {
try {
// Offline greys out renaming and overwriting, so none of them may be sent.
const offline = scanMode === 'offline';
const reqBody = {
offline_only: offline,
skip_rename: offline || !enableRename,
virtual_rename: !offline && enableRename && enableVirtualRename,
physical_rename: !offline && enableRename && enablePhysicalRename,
force_overwrite: !offline && enableForceOverwrite
};
if (targetMode === 'custom') {
let hasAny = false;
for (const set of selectedForScan.values()) {
if (set.size > 0) hasAny = true;
}
if (!hasAny) {
alert(t('sidebarNoModelsSelected'));
return;
}
closeWizard();
const activeScanBtn = document.getElementById('anomalous-scan-btn');
if (activeScanBtn) {
setScanButtonState(activeScanBtn, true);
}
const customFolders = Array.from(selectedForScan.entries()).filter(([, files]) => files.size > 0);
let customFolderCurrent = 0;
updateScanProgress({
scanning: true,
phase: 'preparing',
folder_total: customFolders.length,
folder_current: 0,
});
// Sequential scan for multiple folders
for (const [folderKey, fileSet] of customFolders) {
customFolderCurrent += 1;
const parts = folderKey.split('|');
if (parts.length < 3) continue;
const type = parts[0], path_idx = parts[1], subfolder = parts.slice(2).join('|');
const params = new URLSearchParams({ type, path_idx, subfolder });
const targetUrl = '/anomalous/scan?' + params.toString();
const currentReqBody = { ...reqBody, target_files: Array.from(fileSet) };
try {
const res = await fetch(targetUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentReqBody)
});
const data = await res.json();
if (data.status === 'ok') {
// wait for this folder to finish scanning before starting next
await new Promise(resolve => {
const poll = setInterval(async () => {
try {
const statusUrl = '/anomalous/scan_status?' + params.toString();
const statusRes = await fetch(statusUrl);
const statusData = await statusRes.json();
updateScanProgress({
...statusData,
folder_total: customFolders.length,
folder_current: customFolderCurrent,
folder: subfolder,
});
if (!statusData.scanning) {
clearInterval(poll);
resolve();
}
} catch (err) {
clearInterval(poll);
resolve();
}
}, 2000);
});
} else {
console.error("Scan failed for folder: " + folderKey, data.message);
}
} catch(e) {
console.error("Request failed for folder: " + folderKey, e);
}
}
setActiveScanButtonState(false);
finishScanProgress();
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);
}
if (enableAutoCheck && window.anomalous_resolve_all_missing_nodes) {
window.anomalous_resolve_all_missing_nodes(true);
}
this.loadModels();
return;
}
// Existing logic for 'all' mode or single file
let finalTargetFiles = targetFiles;
let currentReqBody = { ...reqBody };
let targetUrl = '';
if (isGlobal && targetMode !== 'custom') {
targetUrl = '/anomalous/scan_all';
} else {
const params = new URLSearchParams({ type: this.currentType, path_idx: this.currentPathIdx, subfolder: this.currentSubfolder });
if (finalTargetFiles) {
currentReqBody.target_files = finalTargetFiles.split(',');
}
targetUrl = '/anomalous/scan?' + params.toString();
}
const res = await fetch(targetUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentReqBody)
});
const data = await res.json();
if (data.status === 'ok') {
updateScanProgress({ scanning: true, phase: 'preparing', recovered: data.recovered });
setActiveScanButtonState(true);
// Start polling
const poll = setInterval(async () => {
try {
let statusUrl = '/anomalous/global_scan_status';
if (!isGlobal) {
const params = new URLSearchParams({ type: this.currentType, path_idx: this.currentPathIdx, subfolder: this.currentSubfolder });
statusUrl = '/anomalous/scan_status?' + params.toString();
}
const statusRes = await fetch(statusUrl);
const statusData = await statusRes.json();
updateScanProgress(statusData);
if (!statusData.scanning) {
clearInterval(poll);
if (statusData.interrupted) failScanProgress(t('scanProgressInterrupted'));
else finishScanProgress();
setActiveScanButtonState(false);
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);
}
if (enableAutoCheck && window.anomalous_resolve_all_missing_nodes) {
window.anomalous_resolve_all_missing_nodes(true);
}
this.loadModels();
}
} catch (err) {
clearInterval(poll);
setActiveScanButtonState(false);
}
}, 2000);
closeWizard();
return; // return early so we don't remove wizard again below
} else {
failScanProgress(t('sidebarScanFailed') + data.message);
alert(t('sidebarScanFailed') + data.message);
}
} catch (e) {
failScanProgress(String(e));
alert("Error: " + e);
}
closeWizard();
};
const footer = document.createElement('div');
footer.style.marginTop = '16px';
footer.style.paddingTop = '16px';
footer.style.borderTop = '1px solid rgba(255,255,255,0.08)';
footer.style.display = 'flex';
footer.style.justifyContent = 'flex-end';
footer.style.gap = '8px';
footer.style.flexShrink = '0';
const closeBtn = document.createElement('button');
closeBtn.textContent = t('sidebarCancel');
closeBtn.style.padding = '8px 16px';
closeBtn.style.background = 'transparent';
closeBtn.style.color = '#9ca3af';
closeBtn.style.border = 'none';
closeBtn.style.borderRadius = '4px';
closeBtn.style.cursor = 'pointer';
closeBtn.style.fontSize = '0.95em';
closeBtn.style.fontWeight = '500';
closeBtn.style.textTransform = 'uppercase';
closeBtn.style.transition = 'background 0.2s';
closeBtn.onmouseover = () => closeBtn.style.background = 'rgba(255, 255, 255, 0.08)';
closeBtn.onmouseout = () => closeBtn.style.background = 'transparent';
closeBtn.onclick = closeWizard;
const startBtn = document.createElement('button');
startBtn.textContent = t('sidebarExecute');
startBtn.style.padding = '8px 24px';
startBtn.style.background = '#e5e7eb';
startBtn.style.color = '#111827';
startBtn.style.border = 'none';
startBtn.style.borderRadius = '4px';
startBtn.style.cursor = 'pointer';
startBtn.style.fontSize = '0.95em';
startBtn.style.fontWeight = '600';
startBtn.style.textTransform = 'uppercase';
startBtn.style.transition = 'background 0.2s, box-shadow 0.2s';
startBtn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.3)';
startBtn.onmouseover = () => {
startBtn.style.background = '#ffffff';
startBtn.style.boxShadow = '0 3px 8px rgba(0,0,0,0.4)';
};
startBtn.onmouseout = () => {
startBtn.style.background = '#e5e7eb';
startBtn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.3)';
};
startBtn.onclick = doScan;
footer.appendChild(closeBtn);
footer.appendChild(startBtn);
content.appendChild(footer);
wizard.appendChild(content);
document.body.appendChild(wizard);
}
export async function formatScanCompletionToast(model) {
const isZh = window.anomalous_browser_lang === 'zh';
const fallbackName = model.name || model.filename;
try {
const findRes = await fetch('/anomalous/find_model?search=' + encodeURIComponent(model.filename));
if (findRes.ok) {
const updated = await findRes.json();
const target = updated?.model || updated || model;
if (target && target.metadata) {
const meta = target.metadata;
const isCivitai = Boolean(
(meta.id && meta.id !== -1) ||
(meta.modelId && meta.modelId !== -1) ||
(meta.model_id && meta.model_id !== -1) ||
(meta.version_id && meta.version_id !== -1) ||
meta.civitai_url
);
const hasPreview = Boolean(target.preview_url);
const baseModel = meta.baseModel;
if (isCivitai && hasPreview) {
return isZh ? `✓ 已从 Civitai 获取封面与模型信息!` : `✓ Civitai cover & metadata fetched!`;
}
if (isCivitai && !hasPreview) {
return isZh ? `✓ 已匹配到 Civitai 信息(线上未提供封面)` : `✓ Civitai metadata matched (no cover online)`;
}
if (!isCivitai) {
if (baseModel) {
return isZh
? `ℹ️ 非 Civitai 模型:已识别底模为 [${baseModel}]`
: `ℹ️ Non-Civitai model: inferred base model [${baseModel}]`;
}
return isZh
? `ℹ️ 未在 Civitai 匹配到此模型`
: `ℹ️ No Civitai match found for this model`;
}
}
}
} catch {
// fallback to standard text
}
return isZh ? `✓ 模型 [${fallbackName}] 扫描完成!` : `✓ Model [${fallbackName}] scanned!`;
}
function pollDirectScanStatus(params, titleText, model, onComplete) {
const statusUrl = '/anomalous/scan_status?' + params.toString();
const poll = setInterval(async () => {
try {
const statusRes = await fetch(statusUrl);
const statusData = await statusRes.json();
updateScanProgress(statusData, titleText);
if (!statusData.scanning) {
clearInterval(poll);
if (statusData.interrupted) {
failScanProgress(t('scanProgressInterrupted'));
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '扫描被中断' : 'Scan interrupted');
} else {
const toastMsg = await formatScanCompletionToast(model);
finishScanProgress(toastMsg);
showWorkbenchToast(toastMsg);
}
onComplete(true);
}
} catch (err) {
clearInterval(poll);
failScanProgress(String(err));
onComplete(false);
}
}, 1200);
return poll;
}
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 isZh = window.anomalous_browser_lang === 'zh';
const titleText = isZh ? `精准扫描: ${modelLabel}` : `Scanning: ${modelLabel}`;
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 || '/',
});
const reqBody = {
target_files: [model.filename],
offline_only: false,
skip_rename: true,
virtual_rename: false,
physical_rename: false,
force_overwrite: false
};
const res = await fetch('/anomalous/scan?' + params.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqBody)
});
const data = await res.json();
if (data.status === 'ok') {
showWorkbenchToast(isZh ? `开始精准扫描: ${modelLabel}` : `Scanning model: ${modelLabel}`);
pollDirectScanStatus(params, titleText, model, async () => {
resetBtn();
if (typeof browser.loadModels === 'function') {
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);
}
});
} else {
resetBtn();
const errMsg = data.message || (isZh ? '扫描启动失败' : 'Failed to start scan');
failScanProgress(errMsg);
showWorkbenchToast(errMsg);
}
} catch (e) {
resetBtn();
failScanProgress(String(e));
showWorkbenchToast(String(e));
}
}