Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_scan_page.js
T
DemonGatanjieuandClaude Opus 5.5 45353ffea1 Scans name damaged model files instead of calling them unknown
A model Civitai has no record of now has its safetensors header checked (model_identity.
safetensors_damage: too short, header longer than the file, not JSON, or a tensor ending past
the file's end; only the header is read). Such files get the unmatched reason "damaged": a red
badge on the card, a line in the details, a warning on the scan page (the summary counts them)
and first place in the unmatched list, with advice to download the model again.

Tests: header cases, a scan telling a damaged file from a whole one; the scan report fixtures
are whole safetensors files now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:35:23 +08:00

333 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The scan page: how many models are matched on Civitai, unmatched or not scanned
* (GET /anomalous/scan_summary), one button to scan, how the next scan goes, progress in
* the page and the last scan in one card. Each count and the last scan open their list
* as a page of its own (ui_scan_lists.js), the settings card the settings page; each
* has a way back.
* Scans run through scan_runner.js; the progress box is scan_progress.js's panel,
* hosted here while the page is shown.
*/
import { translate as t } from './locales.js';
import { isScanRunning, startScan, targetsForItems } from './scan_runner.js';
import { setScanProgressHost } from './scan_progress.js';
import { openListedModel, renderLastScan, renderListPage } from './ui_scan_lists.js';
// Choices kept while the browser is open. The two risky ones go back off after each scan.
const options = {
scope: 'all', // 'all' | 'picked'
selection: new Map(),
offline: false,
virtualRename: true,
physicalRename: false,
forceOverwrite: false,
autoFix: true,
};
// The view shown: '' the page itself, 'settings', or a list: 'new' | 'unmatched' | 'skipped' | 'result'.
// `scroll`: where to put a list shown again after a model's Back.
const pageState = { view: '', scroll: 0 };
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;
}
function pickedCount() {
let count = 0;
for (const files of options.selection.values()) count += files.size;
return count;
}
async function fetchJson(url) {
try {
const res = await fetch(url);
return res.ok ? await res.json() : null;
} catch {
return null;
}
}
/** A count; with `onClick` it opens the list of those models. */
function stat(value, label, tone, onClick) {
const box = el(onClick ? 'button' : 'div', `anomalous-scan-stat${tone ? ` is-${tone}` : ''}`);
if (onClick) {
box.type = 'button';
box.onclick = onClick;
box.title = t('scanPageShowList');
}
box.append(el('strong', 'anomalous-scan-stat-value', value ?? '–'), el('span', 'anomalous-scan-stat-label', label));
return box;
}
/** One setting: what it is and does on the left, its switch on the right; `tag` warns beside the title. */
function toggle(key, titleKey, helpKey, { tag = '', onChange = null } = {}) {
const row = el('label', 'anomalous-scan-setting');
const copy = el('span', 'anomalous-scan-setting-copy');
const title = el('span', 'anomalous-scan-setting-title', t(titleKey));
if (tag) title.append(el('span', 'anomalous-scan-tag', t(tag)));
copy.append(title, el('small', 'anomalous-scan-setting-help', t(helpKey)));
const input = el('input', 'anomalous-scan-switch');
input.type = 'checkbox';
input.checked = Boolean(options[key]);
input.onchange = () => {
options[key] = input.checked;
onChange?.();
};
row.append(copy, input);
return row;
}
/** A titled card of the settings page. */
function group(title, ...children) {
const box = el('section', 'anomalous-scan-card anomalous-scan-group');
box.append(el('h3', 'anomalous-scan-group-title', title), ...children);
return box;
}
/** How the next scan will go, in short words; `true` marks the ones that change files or take long. */
function currentChoices(hasKey) {
const online = !options.offline;
return [
[options.scope === 'picked' ? t('scanChipPicked', { count: pickedCount() }) : t('scanPageScopeAll')],
[t(online ? 'scanChipOnline' : 'scanPageOffline')],
online && options.virtualRename && [t('scanChipDisplayName')],
online && options.physicalRename && [t('scanChipFileRename'), true],
online && options.forceOverwrite && [t('scanChipForce'), true],
options.autoFix && [t('scanChipAutoFix')],
[t(hasKey ? 'scanChipKeyOn' : 'scanChipKeyOff')],
].filter(Boolean);
}
/** The scan page's card for the settings: how the next scan goes, and a way to change it. */
function settingsCard(owner, panel, hasKey) {
const card = button('anomalous-scan-card anomalous-scan-settings-card', undefined, () => go(owner, panel, 'settings'));
const head = el('span', 'anomalous-scan-settings-head');
head.append(el('span', 'anomalous-scan-card-title', t('scanSettingsTitle')), el('span', 'anomalous-scan-settings-change', t('scanSettingsChange')));
const chips = el('span', 'anomalous-scan-chips');
for (const [text, warn] of currentChoices(hasKey)) chips.append(el('span', `anomalous-scan-chip${warn ? ' is-warn' : ''}`, text));
card.append(head, chips);
return card;
}
/** The settings as a page of their own, like the lists: back, then one card per question. */
function settingsPage(owner, panel, hasKey, progressHost) {
const refresh = () => go(owner, panel, 'settings');
const page = el('div', 'anomalous-scan-page');
page.append(button('anomalous-scan-back', t('scanBack'), () => go(owner, panel, '')),
el('h1', 'anomalous-scan-title', t('scanSettingsTitle')), el('p', 'anomalous-scan-lead', t('scanSettingsLead')), progressHost);
// Which models: two choices side by side; "picked" shows the picker.
const segment = el('div', 'anomalous-scan-segment');
segment.setAttribute('role', 'radiogroup');
for (const [value, label] of [['all', t('scanPageScopeAll')], ['picked', t('scanPageScopePicked')]]) {
const choice = button('anomalous-scan-segment-btn', label, () => {
options.scope = value;
refresh();
});
choice.setAttribute('role', 'radio');
choice.setAttribute('aria-checked', String(options.scope === value));
segment.append(choice);
}
let scopeNote = el('small', 'anomalous-scan-setting-help', t('scanPageScopeAllHelp'));
if (options.scope === 'picked') {
scopeNote = el('div', 'anomalous-scan-pick');
scopeNote.append(
button('anomalous-scan-secondary anomalous-scan-small-btn', t('scanPagePickModels'), () => owner._openAdvancedModelSelector(options.selection, (next) => {
options.selection = next;
refresh();
})),
el('span', 'anomalous-scan-muted', t('scanPagePickedCount', { count: pickedCount() })),
);
}
// What only an online scan does: under its own heading, faded while scanning offline.
const online = el('div', `anomalous-scan-online${options.offline ? ' is-disabled' : ''}`);
online.append(
el('div', 'anomalous-scan-subtitle', t(options.offline ? 'scanPageOnlineOff' : 'scanPageOnline')),
toggle('virtualRename', 'scanPageDisplayName', 'scanPageDisplayNameHelp'),
toggle('physicalRename', 'scanPageFileRename', 'scanPageFileRenameHelp', { tag: 'scanPageTagFiles' }),
toggle('forceOverwrite', 'scanPageForce', 'scanPageForceHelp', { tag: 'scanPageTagSlow' }),
);
const key = el('div', 'anomalous-scan-setting');
const keyCopy = el('span', 'anomalous-scan-setting-copy');
const keyTitle = el('span', 'anomalous-scan-setting-title', t('scanPageApiKey'));
keyTitle.append(el('span', `anomalous-scan-tag ${hasKey ? 'is-on' : 'is-off'}`, t(hasKey ? 'scanPageApiKeyOn' : 'scanPageApiKeyOff')));
keyCopy.append(keyTitle, el('small', 'anomalous-scan-setting-help', t('scanPageApiKeyHelp')));
key.append(keyCopy, button('anomalous-scan-secondary anomalous-scan-small-btn', t('scanPageApiKeySet'), async () => {
if (await saveApiKey()) renderScanPage(owner, panel);
}));
page.append(
group(t('scanPageScope'), segment, scopeNote),
group(t('scanPageHow'), toggle('offline', 'scanPageOffline', 'scanPageOfflineHelp', { onChange: refresh }), online),
group(t('scanPageAfter'), toggle('autoFix', 'scanPageAutoFix', 'scanPageAutoFixHelp')),
group(t('scanPageAccount'), key),
);
return page;
}
/** Asks for the Civitai key and saves it; true when saved (also Model Check's downloads). */
export async function saveApiKey() {
const value = prompt(t('scanPageApiKeyPrompt'), '');
if (value === null) return false;
try {
const res = await fetch('/anomalous/save_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: value.trim() }),
});
const data = await res.json();
if (data.status !== 'ok') throw new Error(data.message || `HTTP ${res.status}`);
alert(t(value.trim() ? 'scanPageApiKeySaved' : 'scanPageApiKeyCleared'));
return true;
} catch (error) {
alert(t('scanPageApiKeyFailed', { error: String(error.message || error) }));
return false;
}
}
/** A scan from this page. `extra`: { retryUnmatched } for every unmatched model, or
* { targets, retryUnmatched } for the models of a list row or a whole list. */
async function run(owner, panel, extra = {}) {
const whole = !extra.retryUnmatched && !extra.targets;
if (whole && options.scope === 'picked' && pickedCount() === 0) {
alert(t('scanPageNothingPicked'));
return;
}
if (whole && options.forceOverwrite && !confirm(t('scanPageForceConfirm'))) return;
const selection = whole && options.scope === 'picked' ? options.selection : null;
const started = startScan(owner, { ...options, ...extra, selection });
renderScanPage(owner, panel); // buttons show "scanning"
await started;
if (whole) {
options.physicalRename = false;
options.forceOverwrite = false;
}
if (panel.isConnected && panel.style.display !== 'none') renderScanPage(owner, panel);
}
/** To another view of the page ('' is the scan page itself), with what was read last. */
function go(owner, panel, view) {
pageState.view = view;
if (panel._scanData) paint(owner, panel, panel._scanData);
else renderScanPage(owner, panel);
}
/** What the list pages' buttons do. */
function listActions(owner, panel, busy) {
return {
busy,
offline: options.offline,
back: () => go(owner, panel, ''),
// The model's Back returns to this list where it was.
open: (item) => {
const view = pageState.view;
const scroll = panel.scrollTop;
openListedModel(owner, item, () => openScanPage(owner, view, scroll));
},
scan: (items, { retry = false } = {}) => run(owner, panel, { targets: targetsForItems(items), retryUnmatched: retry }),
retryAll: () => run(owner, panel, { retryUnmatched: true }),
};
}
/** The scan page itself: what a scan is, the counts (each opening its list), one button, the last scan. */
function mainPage(owner, panel, { summary, config, last }, busy, progressHost) {
const page = el('div', 'anomalous-scan-page');
page.append(el('h1', 'anomalous-scan-title', t('scanPageTitle')), el('p', 'anomalous-scan-lead', t('scanPageLead')));
const open = view => () => go(owner, panel, view);
const stats = el('div', 'anomalous-scan-stats');
stats.append(
stat(summary?.total, t('scanPageTotal')),
stat(summary?.matched, t('scanPageMatched'), 'ok'),
stat(summary?.unmatched, t('scanPageUnmatched'), summary?.unmatched ? 'warn' : '', summary?.unmatched ? open('unmatched') : null),
stat(summary?.new, t('scanPageNew'), summary?.new ? 'new' : '', summary?.new ? open('new') : null),
);
page.append(stats);
if (summary?.damaged) page.append(button('anomalous-scan-damaged-note', t('scanPageDamagedNote', { count: summary.damaged }), open('unmatched')));
if (summary?.skipped) page.append(button('anomalous-scan-skipped-note', t('scanPageSkippedNote', { count: summary.skipped }), open('skipped')));
if (!summary) page.append(el('p', 'anomalous-scan-muted', t('scanPageSummaryFailed')));
const actions = el('div', 'anomalous-scan-actions');
const picked = options.scope === 'picked';
// An online scan also looks up the models earlier scans could not ask Civitai about.
const waiting = (summary?.new || 0) + (options.offline ? 0 : summary?.pending || 0);
const label = busy ? t('scanPageScanning')
: picked ? t('scanPageScanPicked', { count: pickedCount() })
: waiting ? t(options.offline ? 'scanPageScanNewOffline' : 'scanPageScanNew', { count: waiting })
: t('scanPageScanAgain');
const primary = button('anomalous-scan-primary', label, () => run(owner, panel));
primary.disabled = busy;
actions.append(primary);
page.append(actions);
const hint = picked ? t('scanPageHintPicked') : t(options.offline ? 'scanPageHintOffline' : 'scanPageHint');
page.append(el('p', 'anomalous-scan-muted', hint), settingsCard(owner, panel, Boolean(config?.has_api_key)), progressHost);
const result = renderLastScan(last, open('result'));
if (result) page.append(result);
return page;
}
/** Puts the current view into `panel`; a view shown again keeps where it was scrolled. */
function paint(owner, panel, data) {
const view = pageState.view;
const top = pageState.scroll || (panel._scanView === view ? panel.scrollTop : 0);
pageState.scroll = 0;
const busy = isScanRunning();
const progressHost = el('div', 'anomalous-scan-progress-host');
const page = view === 'settings' ? settingsPage(owner, panel, Boolean(data.config?.has_api_key), progressHost)
: view ? renderListPage(view, data.summary, data.last, listActions(owner, panel, busy), progressHost)
: mainPage(owner, panel, data, busy, progressHost);
panel.replaceChildren(page);
panel._scanView = view;
panel.scrollTop = top;
setScanProgressHost(progressHost);
}
/** Renders the page into `panel` (again after each scan, so the counts are fresh). */
export async function renderScanPage(owner, panel) {
const token = (panel._scanRender = (panel._scanRender || 0) + 1);
const [summary, config, last] = await Promise.all([
fetchJson('/anomalous/scan_summary'), fetchJson('/anomalous/config'), fetchJson('/anomalous/last_scan'),
]);
if (token !== panel._scanRender) return;
// Closed or left meanwhile: the progress box keeps floating instead of hiding in the page.
if (panel.style.display === 'none' || !owner.modal?.classList.contains('visible')) return;
panel._scanData = { summary, config, last };
paint(owner, panel, panel._scanData);
// A scan that ends elsewhere (a card's radar, another tab) refreshes the counts too.
owner.onScanFinished = () => {
if (panel.isConnected && panel.style.display !== 'none') renderScanPage(owner, panel);
};
}
/**
* The rail's scan button, Home's "Scan model folders" and Model Check: the scan page itself.
* `view` opens one of its lists instead ('result' after a scan, a list on a model's Back),
* scrolled to `scroll`.
*/
export function openScanPage(owner, view = '', scroll = 0) {
pageState.view = view;
pageState.scroll = scroll;
owner.scanPanel._scanView = null; // opened afresh: from the top unless `scroll`
owner.enterToolPage?.('scan');
owner.hideAllPanels();
owner.scanPanel.style.display = 'flex';
renderScanPage(owner, owner.scanPanel);
}
/** Leaving the page: the progress box floats again. */
export function leaveScanPage() {
setScanProgressHost(null);
}