Files
DemonGatanjieuandClaude Opus 5.5 98148481bc Models page tabs, base model filter, Tidy tab redone with covers
- The models page has tabs: All models, Tidy (with its count) and Sources;
  the type bar keeps the type chips and Import.
- Base model chips under the type chips (All, Illustrious, SDXL, Unknown...),
  one per family the listed models have (model_bases.js; SDXL 1.0 = SDXL);
  models not scanned are listed by their header's guess (metadata base_guess).
- Tidy is cards with the model's cover and name in two steps: models in a
  folder whose loader cannot read them (from -> to, one plain reason, Move
  there, Change place), and identical files side by side with the copy to
  keep marked (the one in the right folder; click another to change) and one
  button for the extra copies. Models that load anyway and extra copies of a
  well-placed file are not in step 1. Same-size files up to 256 MB are
  compared at once; larger ones on Compare.

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

251 lines
12 KiB
JavaScript

/**
* The Models page's "Sources" view: where each model can be downloaded, for the open
* workflow's models or every model here. Each row's link can be typed or pasted (any site),
* found automatically, opened, and saved (model_source_links.js keeps it in the model's
* information and, for the workflow's models, in the workflow). The workflow's list can go
* on the canvas as a note or to the clipboard. It is drawn in the models grid under the
* page's tabs while `owner.modelView === 'sources'`; `owner.modelSources` keeps scope, filter
* and search across redraws.
*/
import { translate as t } from './locales.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { typeLabel } from './ui_model_types.js';
import { foundationModelType, partitionSourceModels } from './model_source_data.js';
import {
autoDetectModelSource, collectWorkflowModels, copySourcesSummary, createCanvasNoteNode, detectPlatform,
fetchAllLibraryModels, keepAllInWorkflow, normalizeUrl, openExternalUrl, resolveWorkflowModelsMetadata, saveModelSource,
} from './model_source_links.js';
const COMPONENT_LABELS = {
clip: 'modelSourcesTypeTextEncoder', text_encoders: 'modelSourcesTypeTextEncoder',
vae: 'modelSourcesTypeVae', vae_approx: 'modelSourcesTypeVaeApprox', clip_vision: 'modelSourcesTypeVisionEncoder',
};
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 viewState(owner) {
owner.modelSources ||= { scope: 'library', filter: 'all', search: '', workflow: [], library: [], libraryStatus: 'idle', componentsOpen: false, controller: null };
return owner.modelSources;
}
/** Opens the view on the Models page; `scope`: 'workflow' (the open workflow) or 'library'. */
export function showModelSources(owner, scope = 'library') {
const state = viewState(owner);
Object.assign(state, { scope, filter: 'all', search: '' }); // a fresh look, not the last search
state.libraryStatus = 'idle'; // scans and edits may have changed it since
owner.modelView = 'sources';
owner.goTo?.('models');
owner.loadModels();
}
function paintSite(badge, url) {
const site = detectPlatform(url);
badge.hidden = !site;
if (!site) return;
badge.textContent = site.name;
Object.assign(badge.style, { color: site.color, background: site.bg, borderColor: site.border });
}
/** One model: name, where it is, its link and what can be done with it. */
function renderRow(model) {
const row = el('div', 'anomalous-msrc-row');
const info = el('div', 'anomalous-msrc-info');
const name = el('strong', 'anomalous-msrc-name', model.basename || model.filename);
name.title = model.filename;
const where = model.nodeId != null
? `${model.nodeTitle} #${model.nodeId}`
: [model.type ? typeLabel({ type: model.type }) : '', model.subfolder].filter(Boolean).join(' · ');
info.append(name, el('small', 'anomalous-msrc-where', where));
const component = foundationModelType(model);
if (component) info.append(el('span', 'anomalous-msrc-tag', `${t(COMPONENT_LABELS[component] || 'modelSourcesTypeVae')} · ${t('modelSourcesStatusUnfilledOptional')}`));
if (model.isMissing) info.append(el('span', 'anomalous-msrc-tag is-missing', t('modelSourcesStatusMissingOnDisk')));
const link = el('div', 'anomalous-msrc-link');
const site = el('span', 'anomalous-msrc-site');
const input = el('input', 'anomalous-msrc-input');
input.type = 'url';
input.spellcheck = false;
input.placeholder = t('modelSourcesUrlPlaceholder');
input.value = model.url || '';
const open = button('anomalous-msrc-btn', t('modelSourcesOpen'), () => openExternalUrl(input.value));
const find = button('anomalous-msrc-btn', t('modelSourcesAutoDetect'), null);
const save = button('anomalous-msrc-btn is-primary', t('modelSourcesSave'), null);
link.append(site, input, open, find, save);
row.append(info, link);
const sync = () => {
const dirty = normalizeUrl(input.value) !== normalizeUrl(model.initialUrl || '');
row.classList.toggle('is-dirty', dirty);
row.classList.toggle('is-empty', !input.value.trim());
save.hidden = !dirty;
open.disabled = !input.value.trim();
find.hidden = Boolean(input.value.trim());
paintSite(site, input.value);
};
const commit = async () => {
save.disabled = true;
try {
const kept = await saveModelSource(model, input.value);
input.value = model.url;
showWorkbenchToast(t(kept.local && kept.workflow ? 'modelSourcesSavedBoth' : kept.local ? 'modelSourcesSavedLocal' : 'modelSourcesSavedWorkflow'));
} catch (error) {
showWorkbenchToast(t('modelSourcesSaveFailed', { error: error.message }));
} finally {
save.disabled = false;
sync();
}
};
input.oninput = () => { model.url = input.value.trim(); sync(); };
input.onkeydown = (event) => {
if (event.key === 'Enter' && !save.hidden) commit();
if (event.key === 'Escape') {
event.stopPropagation(); // the panel stays open
input.value = model.initialUrl || '';
model.url = input.value;
sync();
}
};
save.onclick = commit;
find.onclick = async () => {
find.disabled = true;
try {
const found = await autoDetectModelSource(model);
if (found) {
input.value = found;
model.url = found;
}
showWorkbenchToast(t(found ? 'modelSourcesFound' : 'modelSourcesNotFound'));
} finally {
find.disabled = false;
sync();
}
};
sync();
return row;
}
async function loadLibrary(state, signal, draw) {
if (state.libraryStatus !== 'idle') return;
state.libraryStatus = 'loading';
draw();
try {
state.library = await fetchAllLibraryModels(signal);
state.libraryStatus = 'ready';
} catch (error) {
if (signal.aborted) return;
console.warn('[AMB] Model sources: the library did not load.', error);
state.libraryStatus = 'error';
}
draw();
}
function renderBar(state, draw, setScope) {
const bar = el('div', 'anomalous-msrc-bar');
const scopes = el('div', 'anomalous-msrc-pills');
const libraryCount = state.libraryStatus === 'ready' ? state.library.length : '…';
for (const [id, label] of [['workflow', `${t('modelSourcesScopeWorkflow')} · ${state.workflow.length}`], ['library', `${t('modelSourcesScopeLibrary')} · ${libraryCount}`]]) {
scopes.append(button(`anomalous-msrc-pill${state.scope === id ? ' is-active' : ''}`, label, () => setScope(id)));
}
const filters = el('div', 'anomalous-msrc-pills');
for (const [id, key] of [['all', 'modelSourcesFilterAll'], ['resolved', 'modelSourcesFilterResolved'], ['unresolved', 'modelSourcesFilterUnresolved']]) {
filters.append(button(`anomalous-msrc-pill${state.filter === id ? ' is-active' : ''}`, t(key), () => { state.filter = id; draw(); }));
}
const search = el('input', 'anomalous-msrc-search');
search.type = 'search';
search.placeholder = t('modelSourcesSearchPlaceholder');
search.value = state.search;
search.oninput = () => { state.search = search.value.trim().toLowerCase(); draw({ keepSearchFocus: true }); };
bar.append(scopes, filters, search);
return bar;
}
function renderFoot(state, shown, main) {
const foot = el('div', 'anomalous-msrc-foot');
const done = main.filter(model => model.url).length;
foot.append(el('span', 'anomalous-msrc-stats', t('modelSourcesStats', { done, total: main.length })));
const actions = el('div', 'anomalous-msrc-actions');
actions.append(button('anomalous-msrc-btn', t('modelSourcesCopySummary'), async () => {
await copySourcesSummary(shown);
showWorkbenchToast(t('modelSourcesCopied'));
}));
if (state.scope === 'workflow') {
actions.append(button('anomalous-msrc-btn', t('modelSourcesGenerateNoteNode'), () => {
if (createCanvasNoteNode(state.workflow)) showWorkbenchToast(t('modelSourcesNoteCreated'));
}), button('anomalous-msrc-btn', t('modelSourcesKeepAll'), () => {
showWorkbenchToast(t('modelSourcesKeptAll', { count: keepAllInWorkflow(state.workflow) }));
}));
}
foot.append(actions);
return foot;
}
function renderList(state, draw) {
const list = el('div', 'anomalous-msrc-list');
if (state.scope === 'library' && state.libraryStatus !== 'ready') {
list.append(el('p', 'anomalous-msrc-empty', t(state.libraryStatus === 'error' ? 'modelSourcesLibraryLoadFailed' : 'modelSourcesLibraryLoading')));
if (state.libraryStatus === 'error') list.append(button('anomalous-msrc-btn', t('modelSourcesRetry'), () => { state.libraryStatus = 'idle'; draw({ reload: true }); }));
return { list, shown: [], main: [] };
}
const models = state.scope === 'workflow' ? state.workflow : state.library;
const group = partitionSourceModels(models, state.filter, state.search);
list.append(...group.mainModels.map(renderRow));
if (group.allComponentCount) {
const toggle = button('anomalous-msrc-components', `${state.componentsOpen ? '▾' : '▸'} ${t('modelSourcesComponentsAdvanced', { count: group.componentModels.length })} · ${t('modelSourcesComponentsOptionalHint')}`,
() => { state.componentsOpen = !state.componentsOpen; draw(); });
toggle.setAttribute('aria-expanded', String(state.componentsOpen));
list.append(toggle);
if (state.componentsOpen) list.append(...group.componentModels.map(renderRow));
}
if (!group.mainModels.length && !group.allComponentCount) {
list.append(el('p', 'anomalous-msrc-empty', t(state.scope === 'workflow' && !models.length ? 'modelSourcesWorkflowEmpty' : 'modelSourcesNoModelsFound')));
}
const shown = [...group.mainModels, ...(state.componentsOpen ? group.componentModels : [])];
return { list, shown, main: models.filter(model => !foundationModelType(model)) };
}
/** Draws the view into the models grid (after the type bar). */
export function renderModelSourcesView(owner, grid) {
const state = viewState(owner);
state.controller?.abort();
const controller = new AbortController();
state.controller = controller;
state.workflow = collectWorkflowModels();
const view = el('section', 'anomalous-msrc');
grid.appendChild(view);
const draw = ({ keepSearchFocus = false, reload = false } = {}) => {
if (controller.signal.aborted || !view.isConnected) return;
if (reload) loadLibrary(state, controller.signal, draw);
const { list, shown, main } = renderList(state, draw);
const head = el('div', 'anomalous-msrc-head');
head.append(el('h2', 'anomalous-msrc-title', t('modelSourcesTitle')), el('p', 'anomalous-msrc-lead', t('modelSourcesLead')));
const bar = renderBar(state, draw, (scope) => {
state.scope = scope;
if (scope === 'library') loadLibrary(state, controller.signal, draw);
draw();
});
view.replaceChildren(head, bar, list, renderFoot(state, shown, main));
if (keepSearchFocus) {
const search = bar.querySelector('.anomalous-msrc-search');
search.focus();
search.setSelectionRange(search.value.length, search.value.length);
}
};
draw();
resolveWorkflowModelsMetadata(state.workflow, controller.signal).then(changed => { if (changed) draw(); });
if (state.scope === 'library') loadLibrary(state, controller.signal, draw);
}