Files
DemonGatanjieuandClaude Opus 5.5 3f36ebee89 Fix AMB not loading: the viewer's size words pulled in a cycle
ui_gallery_viewer.js (8c044d0) imported formatSize from
ui_model_download.js, which leads through the settings page and
browser.js back to ui_ai_workbench.js; with ui_ai_images.js ->
ui_gallery_detail.js -> ui_gallery_viewer.js at the start of that chain,
ui_ai_workbench.js called setMarker before ui_ai_images.js had run, and
the whole plugin failed to load ("Cannot access 'marker' before
initialization"). formatSize now lives in file_size.js, which imports
nothing, and every module takes it from there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:26:36 +08:00

361 lines
16 KiB
JavaScript

/**
* The scan settings' model picker (GET /anomalous/scan_pickable): every model a scan reads, as
* a list or as covers, each with how the last scan left it (the scan page's words: not scanned
* yet, not found, damaged, found, set to "don't scan"), its folder, base model and size. The
* folders on the left, the state chips, the search and "picked only" narrow what is shown;
* "pick these" picks all of it, a click one model, a shift-click the run since the last click.
* OK hands the picks to the settings page as items { type, path_idx, rel, filename, name, size };
* Cancel, ✕, Esc and the backdrop leave them as they were. Long lists are drawn a screenful at
* a time.
*/
import { translate as t } from './locales.js';
import { createViewScope } from './ui_lifecycle.js';
import { typeLabel } from './ui_model_types.js';
import { formatSize } from './file_size.js';
const STATES = ['new', 'unmatched', 'damaged', 'matched', 'skip'];
const STATE_KEYS = {
new: 'scanPageNew', unmatched: 'scanPageUnmatched', damaged: 'modelSourceDamagedShort',
matched: 'scanPickFound', skip: 'modelSourceSkipped',
};
const STEP = 60; // rows drawn at a time
const VIEW_KEY = 'anomalous_scan_picker_view';
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 stored() {
try {
return localStorage.getItem(VIEW_KEY) === 'covers' ? 'covers' : 'list';
} catch {
return 'list';
}
}
function store(view) {
try {
localStorage.setItem(VIEW_KEY, view);
} catch { /* the view is only a convenience */ }
}
/** How the last scan left a model, from the metadata its card badge reads too. */
export function scanState(meta) {
if (meta.scan_choice === 'skip') return 'skip';
if (meta.info_source === 'civitai') return 'matched';
if (meta.info_source === 'local') return meta.unmatched_reason === 'damaged' ? 'damaged' : 'unmatched';
return 'new';
}
export const pickKey = item => `${item.type}|${item.path_idx}|${item.rel}`;
/** The picks' count and size, as the settings page and the picker's footer say them. */
export function picksLine(items) {
if (!items.length) return t('scanPickNone');
return t('scanPickSummary', { count: items.length, size: formatSize(items.reduce((sum, item) => sum + (item.size || 0), 0)) });
}
/**
* The listed models as rows to show (their key, state, folder and what a search matches), in
* folder order, and the models folders with their labels: the type, and where the folder is
* when it is not named after it or the type has two.
*/
function readModels(data) {
const perType = new Map();
for (const root of data.roots || []) perType.set(root.type, (perType.get(root.type) || 0) + 1);
const roots = new Map((data.roots || []).map((root) => {
const where = perType.get(root.type) > 1 ? `${root.parent}/${root.name}` : root.name !== root.type ? root.name : '';
const name = typeLabel({ type: root.type });
return [`${root.type}|${root.path_idx}`, { ...root, name, label: name + (where ? ` · ${where}` : '') }];
}).sort(([, a], [, b]) => a.name.localeCompare(b.name) || a.path_idx - b.path_idx));
const order = new Map([...roots.keys()].map((key, index) => [key, index]));
const models = (data.models || []).map((model) => {
const meta = model.metadata || {};
const cut = model.rel.lastIndexOf('/');
const root = `${model.type}|${model.path_idx}`;
const name = meta.custom_name || model.filename.replace(/\.safetensors$/i, '');
return {
...model, meta, name, root, dir: cut < 0 ? '' : model.rel.slice(0, cut),
key: pickKey(model), state: scanState(meta),
base: meta.baseModel || (meta.base_guess ? `≈ ${meta.base_guess}` : ''),
path: [roots.get(root)?.label || model.type, ...model.rel.split('/')].join(' / '),
text: `${name}\n${model.rel}`.toLowerCase(),
};
}).sort((a, b) => order.get(a.root) - order.get(b.root) || a.rel.localeCompare(b.rel, undefined, { sensitivity: 'base' }));
return { models, roots };
}
/** The item kept for a picked model (what the settings page lists and the scan needs). */
const asItem = model => ({
type: model.type, path_idx: model.path_idx, rel: model.rel, filename: model.filename,
name: model.name, size: model.size,
});
/** Opens the picker over the page with `picked` (items) picked; `onConfirm(items)` on OK. */
export function openScanPicker(picked, onConfirm) {
const scope = createViewScope();
const picks = new Map(picked.map(item => [pickKey(item), item]));
const filter = { folder: '', state: '', query: '', onlyPicked: false };
let view = stored();
let all = [];
let roots = new Map();
let shown = [];
let drawn = 0;
let lastClicked = -1;
const overlay = el('div', 'anomalous-pick-overlay');
const dialog = el('div', 'anomalous-pick-dialog');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
const close = () => scope.dispose();
const head = el('div', 'anomalous-pick-head');
const search = el('input', 'anomalous-pick-search');
search.type = 'search';
search.placeholder = t('scanPickSearch');
const views = el('div', 'anomalous-pick-views');
head.append(el('h2', 'anomalous-pick-title', t('scanPickTitle')), search, views, button('anomalous-pick-close', '✕', close));
const folders = el('nav', 'anomalous-pick-folders');
const chips = el('div', 'anomalous-pick-chips');
const bar = el('div', 'anomalous-pick-bar');
const list = el('div', 'anomalous-pick-list');
const main = el('div', 'anomalous-pick-main');
main.append(chips, bar, list);
const body = el('div', 'anomalous-pick-body');
body.append(folders, main);
const summary = el('span', 'anomalous-pick-summary');
const foot = el('div', 'anomalous-pick-foot');
foot.append(summary, el('span', 'anomalous-pick-hint', t('scanPickRangeHint')),
button('anomalous-scan-secondary anomalous-scan-small-btn', t('dialogCancel'), close),
button('anomalous-scan-primary anomalous-scan-small-btn', t('dialogOk'), () => {
onConfirm([...picks.values()]);
close();
}));
dialog.append(head, body, foot);
overlay.append(dialog);
document.body.append(overlay);
scope.onDispose(() => overlay.remove());
scope.listen(overlay, 'mousedown', (event) => { if (event.target === overlay) close(); });
scope.listen(window, 'keydown', (event) => {
if (event.key !== 'Escape') return;
event.stopPropagation(); // the scan page behind stays open
close();
}, true);
// More rows when the end of the list comes near.
const more = el('div', 'anomalous-pick-more');
const watcher = new IntersectionObserver((entries) => {
if (entries.some(entry => entry.isIntersecting)) drawMore();
}, { root: list, rootMargin: '300px' });
scope.onDispose(() => watcher.disconnect());
// A folder: '' all, "type|path_idx" a models folder, "type|path_idx|dir" a folder in it (subfolders too).
const inFolder = (model, folder = filter.folder) => !folder || folder === model.root
|| (folder.startsWith(`${model.root}|`) && `${model.dir}/`.startsWith(`${folder.slice(model.root.length + 1)}/`));
const matches = model => (!filter.query || model.text.includes(filter.query)) && (!filter.onlyPicked || picks.has(model.key));
function paintSummary() {
summary.textContent = picksLine([...picks.values()]);
}
function paintViews() {
views.replaceChildren(...[['list', 'scanPickViewList'], ['covers', 'scanPickViewCovers']].map(([value, key]) => {
const node = button(`anomalous-pick-view${view === value ? ' is-on' : ''}`, t(key), () => {
view = value;
store(view);
paintViews();
paintList();
});
node.setAttribute('aria-pressed', String(view === value));
return node;
}));
}
function paintFolders() {
const rows = [['', t('scanPickAllFolders'), 0, all]];
for (const [root, info] of roots) {
const inRoot = all.filter(model => model.root === root);
if (!inRoot.length) continue;
rows.push([root, info.label, 0, inRoot]);
const dirs = [...new Set(inRoot.flatMap(model => model.dir ? model.dir.split('/').map((_, i, parts) => parts.slice(0, i + 1).join('/')) : []))].sort();
for (const dir of dirs) {
const key = `${root}|${dir}`;
rows.push([key, dir.split('/').pop(), dir.split('/').length, inRoot.filter(model => inFolder(model, key))]);
}
}
folders.replaceChildren(...rows.map(([key, label, depth, models]) => {
const row = button(`anomalous-pick-folder${key === filter.folder ? ' is-on' : ''}${depth ? '' : ' is-root'}`, undefined, () => {
filter.folder = key;
lastClicked = -1;
paintFolders();
paintChips();
paintList();
});
row.style.paddingLeft = `${10 + depth * 14}px`;
const count = models.filter(model => picks.has(model.key)).length;
row.append(el('span', 'anomalous-pick-folder-name', label), el('span', 'anomalous-pick-folder-count', String(models.length)));
if (count) row.append(el('span', 'anomalous-pick-folder-picked', String(count)));
row.title = label;
return row;
}));
}
function paintChips() {
const here = all.filter(model => inFolder(model) && matches(model));
const counts = Object.fromEntries(STATES.map(state => [state, here.filter(model => model.state === state).length]));
const chip = (state, label, count) => {
const node = button(`anomalous-pick-chip${filter.state === state ? ' is-on' : ''}${state ? ` is-${state}` : ''}`, undefined, () => {
filter.state = state;
lastClicked = -1;
paintChips();
paintList();
});
node.append(el('span', '', label), el('strong', '', String(count)));
node.setAttribute('aria-pressed', String(filter.state === state));
return node;
};
chips.replaceChildren(chip('', t('scanPickStateAll'), here.length),
...STATES.filter(state => counts[state] || filter.state === state).map(state => chip(state, t(STATE_KEYS[state]), counts[state])));
}
function paintBar() {
const fresh = shown.filter(model => !picks.has(model.key)).length;
const pick = button('anomalous-scan-secondary anomalous-scan-small-btn', t('scanPickShown', { count: shown.length }), () => {
for (const model of shown) picks.set(model.key, asItem(model));
refresh();
});
pick.disabled = !fresh;
const unpick = button('anomalous-scan-secondary anomalous-scan-small-btn', t('scanPickUnpickShown'), () => {
for (const model of shown) picks.delete(model.key);
refresh();
});
unpick.disabled = fresh === shown.length;
const only = el('label', 'anomalous-pick-only');
const box = el('input');
box.type = 'checkbox';
box.checked = filter.onlyPicked;
box.onchange = () => {
filter.onlyPicked = box.checked;
refresh();
};
only.append(box, el('span', '', t('scanPickOnlyPicked')));
const clear = button('anomalous-pick-link', t('scanPickClear'), () => {
picks.clear();
refresh();
});
clear.disabled = !picks.size;
bar.replaceChildren(pick, unpick, only, clear);
}
function paintList() {
shown = all.filter(model => inFolder(model) && matches(model) && (!filter.state || model.state === filter.state));
drawn = 0;
list.className = `anomalous-pick-list is-${view}`;
list.replaceChildren();
if (!shown.length) list.append(el('p', 'anomalous-pick-empty', t('scanPickEmpty')));
drawMore();
paintBar();
}
function drawMore() {
if (drawn >= shown.length) return;
more.remove();
const end = Math.min(shown.length, drawn + STEP);
for (let index = drawn; index < end; index += 1) list.append(row(shown[index], index));
drawn = end;
if (drawn < shown.length) {
list.append(more);
watcher.observe(more);
}
}
/** One model: a click picks or unpicks it, a shift-click the models since the last click as well. */
function row(model, index) {
const node = button(`anomalous-pick-row${picks.has(model.key) ? ' is-picked' : ''}`, undefined, (event) => {
const pick = !picks.has(model.key);
const from = event.shiftKey && lastClicked >= 0 ? Math.min(lastClicked, index) : index;
const to = event.shiftKey && lastClicked >= 0 ? Math.max(lastClicked, index) : index;
for (const each of shown.slice(from, to + 1)) {
if (pick) picks.set(each.key, asItem(each));
else picks.delete(each.key);
}
lastClicked = index;
if (filter.onlyPicked && !pick) refresh();
else {
for (const each of list.querySelectorAll('.anomalous-pick-row')) each.classList.toggle('is-picked', picks.has(each.dataset.key));
paintFolders();
paintBar();
paintSummary();
}
});
node.dataset.key = model.key;
node.setAttribute('role', 'checkbox');
node.setAttribute('aria-checked', String(picks.has(model.key)));
const cover = el('span', 'anomalous-pick-cover');
if (model.preview_url) {
const img = el('img');
img.loading = 'lazy';
img.alt = '';
img.src = `${model.preview_url}&variant=${/\.(mp4|webm|mov)(?:&|$)/i.test(model.preview_url) ? 'poster' : 'card'}`;
img.onerror = () => img.remove();
cover.append(img);
}
const names = el('span', 'anomalous-pick-names');
names.append(el('strong', 'anomalous-pick-name', model.name), el('small', 'anomalous-pick-path', model.path));
const facts = el('span', 'anomalous-pick-facts');
facts.append(el('span', `anomalous-pick-state is-${model.state}`, t(STATE_KEYS[model.state])));
facts.append(el('span', 'anomalous-pick-base', model.base));
facts.append(el('span', 'anomalous-pick-size', formatSize(model.size)));
node.append(el('span', 'anomalous-pick-check', '✓'), cover, names, facts);
node.title = model.state === 'skip' ? `${model.rel}\n${t('modelSourceSkippedHint')}` : model.rel;
return node;
}
function refresh() {
paintFolders();
paintChips();
paintList();
paintSummary();
}
scope.listen(search, 'input', (event) => {
if (event.isComposing) return;
filter.query = search.value.trim().toLowerCase();
lastClicked = -1;
refresh();
});
scope.listen(search, 'compositionend', () => search.dispatchEvent(new Event('input')));
paintViews();
paintSummary();
list.append(el('p', 'anomalous-pick-empty', t('scanPickLoading')));
(async () => {
try {
const res = await fetch('/anomalous/scan_pickable', { signal: scope.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (scope.signal.aborted) return;
({ models: all, roots } = readModels(data));
if (data.total > all.length) main.insertBefore(el('p', 'anomalous-pick-note', t('scanPickTruncated', { shown: all.length, total: data.total })), list);
refresh();
search.focus();
} catch (error) {
if (scope.signal.aborted) return;
list.replaceChildren(el('p', 'anomalous-pick-empty', t('scanPickFailed', { error: String(error.message || error) })));
}
})();
}