Every page in both languages, all themes and at 480: - Give the window its own text colour: text with none took ComfyUI's, white on the light theme (Tidy headings and file names, the site chip). - Keep a task's link chip in its row, its x in reach, and colour the next-task chip. - Let choices in Settings wrap, the Models search shrink, and a workflow's preset row go to two lines. - Add --amb-on-link for text on the accent: pale in the dark themes, so white was hard to read. - Lists and colons follow the language (listSeparator, wordColon); tests/words_in_language.mjs keeps it so. - Right plurals in English, "AI" on the rail, one line left in Chinese translated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
539 lines
30 KiB
JavaScript
539 lines
30 KiB
JavaScript
/** Node model replacement and insertion picker. */
|
||
|
||
import { app } from "../../../scripts/app.js";
|
||
import { translate } from "./locales.js";
|
||
import { placeNodes } from "./graph_splice.js";
|
||
import { insertLoras } from "./lora_insert.js";
|
||
import { isInstalled, loraNodeKind } from "./lora_nodes.js";
|
||
import { collectMainModelContextRequests, getBaseModelFamily, inferPickerModelType } from "./model_picker.js";
|
||
import { escapeHtml } from "./safe_dom.js";
|
||
import { recordCanvasStep } from "./canvas_history.js";
|
||
import { modelCardFace } from "./ui_grid.js";
|
||
import { createLoraNodeChoice } from "./ui_lora_node_choice.js";
|
||
|
||
const t = (key, params) => translate(key, params);
|
||
const LORA_NODE_KEY = 'anomalous_insert_lora_node'; // the LoRA node last chosen here
|
||
// A third-party LoRA node settles just after it appears: Easy-Use hides its spare rows on a timer, rgthree fills in on being drawn.
|
||
const nodesSettled = () => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(done, 100))));
|
||
|
||
export function getNativeWidgetValues(node, widget) {
|
||
const source = widget?.options?.values;
|
||
let values = source;
|
||
if (typeof source === 'function') {
|
||
try {
|
||
values = source(widget, node);
|
||
} catch (error) {
|
||
console.warn('[Anomalous] Failed to resolve native combo values:', error);
|
||
values = [];
|
||
}
|
||
}
|
||
return Array.isArray(values)
|
||
? [...new Set(values.filter(value => typeof value === 'string'))]
|
||
: [];
|
||
}
|
||
|
||
/** A crowded canvas can leave new nodes out of sight: the view goes to the first. */
|
||
function bringIntoView(node) {
|
||
const [left, top, width, height] = app.canvas.visible_area;
|
||
const [x, y] = node.pos;
|
||
if (x < left || y < top || x > left + width || y > top + height) app.canvas.centerOnNode(node);
|
||
}
|
||
|
||
/** Whether the LoRA ComfyUI lists as `name` trains the text encoder (api/lora_info.py); yes when unknown. */
|
||
async function loraTrainsTextEncoder(name) {
|
||
try {
|
||
const data = await (await fetch(`/anomalous/lora_info?name=${encodeURIComponent(name)}`)).json();
|
||
return data.text_encoder !== false;
|
||
} catch {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/** The node's drop-downs that list model files. */
|
||
export function modelComboWidgets(node) {
|
||
return (node?.widgets || []).filter(widget => {
|
||
if (widget?.type !== 'combo') return false;
|
||
const values = getNativeWidgetValues(node, widget);
|
||
return values.some(value => /\.(safetensors|ckpt|pt|bin|pth|sft|gguf)$/i.test(value));
|
||
});
|
||
}
|
||
|
||
export function findModelComboWidget(node) {
|
||
return modelComboWidgets(node)[0] || null;
|
||
}
|
||
|
||
export function setWidgetValue(node, widget, value) {
|
||
widget.value = value;
|
||
const widgetIndex = node?.widgets?.indexOf(widget) ?? -1;
|
||
if (widgetIndex >= 0) {
|
||
node.widgets_values = Array.isArray(node.widgets_values)
|
||
? node.widgets_values
|
||
: node.widgets.map(item => item?.value);
|
||
node.widgets_values[widgetIndex] = value;
|
||
}
|
||
}
|
||
|
||
export function _openGalleryReplacer(node, w, options = {}) {
|
||
const mode = options.mode === 'insert' ? 'insert' : 'replace';
|
||
const pickerType = inferPickerModelType(node, w, options);
|
||
const validPaths = getNativeWidgetValues(node, w);
|
||
if (!validPaths.length) {
|
||
alert(t('pickerNoCompatible'));
|
||
return;
|
||
}
|
||
|
||
const normalizePath = value => String(value || '').replace(/\\/g, '/');
|
||
const getName = value => normalizePath(value).split('/').pop() || normalizePath(value);
|
||
const getFolder = value => {
|
||
const normalized = normalizePath(value);
|
||
const splitAt = normalized.lastIndexOf('/');
|
||
return splitAt >= 0 ? normalized.slice(0, splitAt) : '';
|
||
};
|
||
const currentPath = mode === 'replace'
|
||
? validPaths.find(path => normalizePath(path) === normalizePath(w.value)) || null
|
||
: null;
|
||
const contextRequests = pickerType.isLora
|
||
? collectMainModelContextRequests(app.graph, options.anchorNode || node)
|
||
: [];
|
||
let chosen = currentPath ? [currentPath] : []; // in the order picked; only inserting takes more than one
|
||
let selectedFolder = '';
|
||
let previews = {};
|
||
let modelInfo = {};
|
||
let contextModels = {};
|
||
let selectedBaseFamily = '';
|
||
let renderGeneration = 0;
|
||
let applying = false;
|
||
|
||
const modal = document.createElement('div');
|
||
modal.className = 'anomalous-picker-overlay'; // the browser's Esc leaves it to the picker (ui_browser_navigation.js)
|
||
modal.style.cssText = 'position:fixed;inset:0;background:var(--amb-lt-page,radial-gradient(circle at 15% 0%,rgba(53,73,118,0.34),transparent 35%),rgba(7,8,12,0.965));z-index:999999;display:flex;flex-direction:column;padding:22px 24px;box-sizing:border-box;color:var(--amb-lt-text,#fff);font-family:Inter,Arial,sans-serif;';
|
||
const stopMedia = container => container?.querySelectorAll?.('video,audio').forEach(media => {
|
||
media.pause();
|
||
media.removeAttribute('src');
|
||
media.load?.();
|
||
});
|
||
// Esc closes the picker wherever the focus is, and only the picker (not the browser behind).
|
||
const onEscape = (event) => {
|
||
if (event.key !== 'Escape' || !modal.isConnected) return;
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
closeModal();
|
||
};
|
||
const closeModal = () => {
|
||
renderGeneration += 1;
|
||
window.removeEventListener('keydown', onEscape, true);
|
||
stopMedia(modal);
|
||
modal.remove();
|
||
if (!document.querySelector('.anomalous-picker-overlay')) document.documentElement.classList.remove('anomalous-picker-open');
|
||
};
|
||
|
||
const header = document.createElement('div');
|
||
header.style.cssText = 'display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-shrink:0;';
|
||
const headerIcon = document.createElement('span');
|
||
headerIcon.textContent = mode === 'insert' ? '+' : '⇄';
|
||
headerIcon.style.cssText = 'width:40px;height:40px;display:flex;align-items:center;justify-content:center;border-radius:12px;background:linear-gradient(135deg,#4776e6,#8e54e9);font-size:21px;font-weight:800;box-shadow:0 8px 22px rgba(78,91,220,0.35);';
|
||
const headerCopy = document.createElement('div');
|
||
headerCopy.style.cssText = 'display:flex;flex-direction:column;gap:3px;';
|
||
const eyebrow = document.createElement('div');
|
||
eyebrow.textContent = t('pickerEyebrow');
|
||
eyebrow.style.cssText = 'font-size:10px;color:var(--amb-lt-accent-text,#8fa8da);letter-spacing:0.12em;text-transform:uppercase;font-weight:750;';
|
||
const title = document.createElement('h2');
|
||
title.style.cssText = 'margin:0;font-size:19px;line-height:1.15;';
|
||
title.textContent = mode === 'insert'
|
||
? (options.direction === 'before' ? t('pickerInsertBeforeTitle') : t('pickerInsertAfterTitle'))
|
||
: t('pickerChangeTitle');
|
||
const typeBadge = document.createElement('span');
|
||
typeBadge.textContent = pickerType.label;
|
||
typeBadge.style.cssText = 'padding:5px 10px;border-radius:20px;background:color-mix(in srgb, var(--amb-link) 10%, transparent);border:1px solid color-mix(in srgb, var(--amb-link) 25%, transparent);color:var(--amb-link);font-size:10px;font-weight:750;';
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.textContent = '✕';
|
||
closeBtn.style.cssText = 'margin-left:auto;width:38px;height:38px;border-radius:10px;background:rgba(var(--amb-ink-rgb), 0.04);border:1px solid var(--amb-border);color:var(--amb-text-muted);font-size:20px;cursor:pointer;';
|
||
closeBtn.onclick = closeModal;
|
||
headerCopy.append(eyebrow, title);
|
||
header.append(headerIcon, headerCopy, typeBadge, closeBtn);
|
||
modal.appendChild(header);
|
||
|
||
const compatibilityHint = document.createElement('div');
|
||
compatibilityHint.style.cssText = 'display:none;align-items:center;gap:8px;margin:0 0 12px;padding:9px 12px;border-radius:10px;background:rgba(0,200,160,0.075);border:1px solid rgba(0,220,180,0.13);color:var(--amb-lt-ok,#8edfd0);font-size:11px;flex-shrink:0;';
|
||
modal.appendChild(compatibilityHint);
|
||
|
||
const body = document.createElement('div');
|
||
body.style.cssText = 'display:grid;grid-template-columns:minmax(190px,250px) minmax(0,1fr);gap:14px;min-height:0;flex:1;';
|
||
const folderPanel = document.createElement('aside');
|
||
folderPanel.style.cssText = 'background:var(--amb-lt-surface,rgba(20,22,29,0.92));border:1px solid var(--amb-border);border-radius:13px;overflow:auto;padding:10px;box-shadow:0 14px 35px rgba(0,0,0,0.18);';
|
||
const folderTitle = document.createElement('div');
|
||
folderTitle.textContent = t('pickerFolders');
|
||
folderTitle.style.cssText = 'font-size:13px;font-weight:700;color:var(--amb-text-soft);padding:8px 10px 10px;';
|
||
const folderList = document.createElement('div');
|
||
folderList.style.cssText = 'display:flex;flex-direction:column;gap:3px;';
|
||
folderPanel.append(folderTitle, folderList);
|
||
|
||
const content = document.createElement('section');
|
||
content.style.cssText = 'display:flex;flex-direction:column;min-width:0;min-height:0;background:var(--amb-lt-surface,rgba(18,20,27,0.92));border:1px solid var(--amb-border);border-radius:13px;overflow:hidden;box-shadow:0 14px 35px rgba(0,0,0,0.18);';
|
||
const toolbar = document.createElement('div');
|
||
toolbar.style.cssText = 'display:flex;gap:10px;padding:12px;border-bottom:1px solid var(--amb-border-strong);flex-wrap:wrap;align-items:center;';
|
||
const searchInput = document.createElement('input');
|
||
searchInput.type = 'search';
|
||
searchInput.placeholder = t('pickerSearch');
|
||
searchInput.style.cssText = 'flex:1;min-width:220px;padding:10px 12px;border-radius:9px;border:1px solid var(--amb-border);background:var(--amb-bg-card-hover);color:var(--amb-lt-text,#fff);font-size:13px;outline:none;';
|
||
const baseFilterSelect = document.createElement('select');
|
||
baseFilterSelect.style.cssText = `display:${pickerType.isLora ? 'block' : 'none'};padding:10px 12px;border-radius:9px;border:1px solid rgba(0,220,180,0.2);background:var(--amb-bg-card-hover);color:var(--amb-lt-ok,#9be8d9);font-size:12px;max-width:230px;`;
|
||
const allBaseOption = document.createElement('option');
|
||
allBaseOption.value = '';
|
||
allBaseOption.textContent = t('pickerMainAll');
|
||
baseFilterSelect.appendChild(allBaseOption);
|
||
const sortSelect = document.createElement('select');
|
||
sortSelect.style.cssText = 'padding:10px 12px;border-radius:9px;border:1px solid var(--amb-border);background:var(--amb-bg-card-hover);color:var(--amb-lt-text,#fff);font-size:12px;';
|
||
[
|
||
['name-asc', t('pickerNameAsc')],
|
||
['name-desc', t('pickerNameDesc')],
|
||
['folder-asc', t('pickerByFolder')],
|
||
].forEach(([value, label]) => {
|
||
const option = document.createElement('option');
|
||
option.value = value;
|
||
option.textContent = label;
|
||
sortSelect.appendChild(option);
|
||
});
|
||
const resultCount = document.createElement('span');
|
||
resultCount.style.cssText = 'color:var(--amb-text-muted);font-size:12px;white-space:nowrap;';
|
||
toolbar.append(searchInput, baseFilterSelect, sortSelect, resultCount);
|
||
content.appendChild(toolbar);
|
||
|
||
const loadingText = document.createElement('div');
|
||
loadingText.textContent = t('pickerLoadingCovers');
|
||
loadingText.style.cssText = 'color:var(--amb-text-muted);font-size:13px;padding:10px 14px 0;';
|
||
const gridScroll = document.createElement('div');
|
||
// clip-path: cards scrolled up under the toolbar must not take its clicks (lessons.md, 2026-10-12).
|
||
gridScroll.style.cssText = 'overflow:auto;min-height:0;flex:1;padding:14px;clip-path:inset(0);';
|
||
const grid = document.createElement('div');
|
||
grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:14px;align-content:start;';
|
||
gridScroll.appendChild(grid);
|
||
content.append(loadingText, gridScroll);
|
||
body.append(folderPanel, content);
|
||
modal.appendChild(body);
|
||
|
||
const footer = document.createElement('div');
|
||
footer.style.cssText = 'display:flex;align-items:center;gap:12px;margin-top:14px;flex-shrink:0;';
|
||
const selectionText = document.createElement('div');
|
||
selectionText.style.cssText = 'min-width:0;flex:1;color:var(--amb-text-muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
|
||
const cancelBtn = document.createElement('button');
|
||
cancelBtn.textContent = t('pickerCancel');
|
||
cancelBtn.style.cssText = 'padding:10px 18px;background:var(--amb-bg-card-hover);color:var(--amb-text-soft);border:1px solid var(--amb-border-strong);border-radius:7px;cursor:pointer;';
|
||
cancelBtn.onclick = closeModal;
|
||
const confirmBtn = document.createElement('button');
|
||
confirmBtn.textContent = mode === 'insert'
|
||
? t('pickerInsertConfirm')
|
||
: t('pickerReplaceConfirm');
|
||
confirmBtn.style.cssText = 'padding:10px 20px;background:#1976d2;color:#fff;border:none;border-radius:7px;cursor:pointer;font-weight:700;';
|
||
const loraNode = { loraNode: localStorage.getItem(LORA_NODE_KEY) };
|
||
if (!isInstalled(loraNodeKind(loraNode.loraNode))) loraNode.loraNode = 'core';
|
||
if (mode === 'insert') footer.append(selectionText, createLoraNodeChoice(loraNode, () => localStorage.setItem(LORA_NODE_KEY, loraNode.loraNode)));
|
||
else footer.append(selectionText);
|
||
footer.append(cancelBtn, confirmBtn);
|
||
modal.appendChild(footer);
|
||
// Opened from the canvas's right-click menu the browser is closed, so the floating button would sit on the picker's head.
|
||
document.documentElement.classList.add('anomalous-picker-open');
|
||
document.body.appendChild(modal);
|
||
|
||
let folderCounts = new Map();
|
||
const pathMatchesBaseFilter = path => {
|
||
if (!selectedBaseFamily) return true;
|
||
const baseModel = modelInfo[path]?.metadata?.baseModel;
|
||
return getBaseModelFamily(baseModel) === selectedBaseFamily;
|
||
};
|
||
const rebuildFolderCounts = () => {
|
||
const visibleByBase = validPaths.filter(pathMatchesBaseFilter);
|
||
folderCounts = new Map([['', visibleByBase.length]]);
|
||
visibleByBase.forEach(path => {
|
||
const parts = getFolder(path).split('/').filter(Boolean);
|
||
let accumulated = '';
|
||
parts.forEach(part => {
|
||
accumulated = accumulated ? `${accumulated}/${part}` : part;
|
||
folderCounts.set(accumulated, (folderCounts.get(accumulated) || 0) + 1);
|
||
});
|
||
});
|
||
};
|
||
rebuildFolderCounts();
|
||
|
||
// Replacing keeps a model only while the filter shows it; what is being gathered to insert stays.
|
||
const dropFiltered = () => {
|
||
if (mode === 'replace') chosen = chosen.filter(pathMatchesBaseFilter);
|
||
};
|
||
const updateSelection = () => {
|
||
const names = chosen.map(getName).join(t('listSeparator'));
|
||
if (mode === 'insert') {
|
||
selectionText.textContent = chosen.length ? t('pickerChosenLoras', { count: chosen.length, names }) : t('pickerChooseLoras');
|
||
selectionText.title = names;
|
||
confirmBtn.textContent = chosen.length > 1 ? t('pickerInsertConfirmMany', { count: chosen.length }) : t('pickerInsertConfirm');
|
||
} else {
|
||
selectionText.textContent = chosen.length ? t('pickerSelected', { path: normalizePath(chosen[0]) }) : t('pickerChooseModel');
|
||
}
|
||
confirmBtn.disabled = !chosen.length || applying;
|
||
confirmBtn.style.opacity = confirmBtn.disabled ? '0.45' : '1';
|
||
confirmBtn.style.cursor = confirmBtn.disabled ? 'not-allowed' : 'pointer';
|
||
};
|
||
|
||
let renderCards = () => {};
|
||
const configureBaseFilter = () => {
|
||
if (!pickerType.isLora) return;
|
||
const groups = new Map();
|
||
validPaths.forEach(path => {
|
||
const baseModel = modelInfo[path]?.metadata?.baseModel;
|
||
const family = getBaseModelFamily(baseModel);
|
||
if (!family) return;
|
||
const existing = groups.get(family) || { label: baseModel, count: 0 };
|
||
existing.count += 1;
|
||
if (String(baseModel).length < String(existing.label).length) existing.label = baseModel;
|
||
groups.set(family, existing);
|
||
});
|
||
|
||
baseFilterSelect.replaceChildren(allBaseOption);
|
||
[...groups.entries()]
|
||
.sort((a, b) => String(a[1].label).localeCompare(String(b[1].label), undefined, { numeric: true }))
|
||
.forEach(([family, group]) => {
|
||
const option = document.createElement('option');
|
||
option.value = family;
|
||
option.textContent = `${group.label} (${group.count})`;
|
||
baseFilterSelect.appendChild(option);
|
||
});
|
||
|
||
const mainBaseModel = Object.values(contextModels)
|
||
.map(item => item?.metadata?.baseModel)
|
||
.find(Boolean) || '';
|
||
const currentBaseModel = currentPath ? modelInfo[currentPath]?.metadata?.baseModel || '' : '';
|
||
const preferredBaseModel = mainBaseModel || currentBaseModel;
|
||
const preferredFamily = getBaseModelFamily(preferredBaseModel);
|
||
selectedBaseFamily = preferredFamily && groups.has(preferredFamily) ? preferredFamily : '';
|
||
baseFilterSelect.value = selectedBaseFamily;
|
||
dropFiltered();
|
||
rebuildFolderCounts();
|
||
|
||
compatibilityHint.style.display = 'flex';
|
||
if (mainBaseModel && selectedBaseFamily) {
|
||
compatibilityHint.textContent = t('pickerCompatibleMain', { name: mainBaseModel });
|
||
} else if (preferredBaseModel && selectedBaseFamily) {
|
||
compatibilityHint.textContent = t('pickerCompatibleTag', { name: preferredBaseModel });
|
||
} else if (mainBaseModel) {
|
||
compatibilityHint.textContent = t('pickerCompatibleNoTags', { name: mainBaseModel });
|
||
} else {
|
||
compatibilityHint.textContent = t('pickerCompatibleUnknown');
|
||
}
|
||
};
|
||
|
||
const renderFolders = () => {
|
||
folderList.replaceChildren();
|
||
const folders = ['', ...[...folderCounts.keys()].filter(Boolean).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))];
|
||
folders.forEach(folderPath => {
|
||
const button = document.createElement('button');
|
||
const depth = folderPath ? folderPath.split('/').length - 1 : 0;
|
||
const label = folderPath ? folderPath.split('/').pop() : t('pickerAllModels');
|
||
const iconSvg = folderPath
|
||
? '<svg style="width:13px;height:13px;vertical-align:-2px;margin-right:4px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>'
|
||
: '<svg style="width:13px;height:13px;vertical-align:-2px;margin-right:4px;" 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>';
|
||
button.innerHTML = `${iconSvg}${escapeHtml(label)} (${folderCounts.get(folderPath) || 0})`;
|
||
button.title = folderPath || label;
|
||
button.style.cssText = `text-align:left;padding:7px 8px 7px ${8 + depth * 14}px;border-radius:6px;border:none;cursor:pointer;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:${selectedFolder === folderPath ? 'var(--amb-lt-text,#fff)' : 'var(--amb-lt-muted,#aaa)'};background:${selectedFolder === folderPath ? 'rgba(25,118,210,0.45)' : 'transparent'};`;
|
||
button.onclick = () => {
|
||
selectedFolder = folderPath;
|
||
renderFolders();
|
||
renderCards();
|
||
};
|
||
folderList.appendChild(button);
|
||
});
|
||
};
|
||
|
||
// Marks the chosen card in place: redrawing the grid would empty it and scroll back to the top.
|
||
const paintCard = (card) => {
|
||
const path = card.dataset.path;
|
||
const isSelected = chosen.includes(path);
|
||
card.style.borderColor = isSelected ? '#6ea8ff' : path === currentPath ? '#e9b949' : '';
|
||
card.style.boxShadow = isSelected ? '0 0 0 2px rgba(88,151,255,0.35),0 14px 28px rgba(0,0,0,0.28)' : '';
|
||
card.setAttribute('aria-pressed', String(isSelected));
|
||
card.querySelector('.anomalous-picker-order')?.remove();
|
||
if (mode === 'insert' && isSelected) card.appendChild(Object.assign(document.createElement('span'), { className: 'anomalous-picker-order', textContent: chosen.indexOf(path) + 1 }));
|
||
};
|
||
// The models page's card face; a video cover shows its still and plays while pointed at.
|
||
const cardOwner = { energySaving: true, cardThumbnailMode: this.cardThumbnailMode };
|
||
renderCards = () => {
|
||
const term = searchInput.value.trim().toLowerCase();
|
||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||
const paths = validPaths.filter(path => {
|
||
const normalized = normalizePath(path);
|
||
const folderPath = getFolder(normalized);
|
||
const inFolder = !selectedFolder || folderPath === selectedFolder || folderPath.startsWith(`${selectedFolder}/`);
|
||
return pathMatchesBaseFilter(path) && inFolder && (!term || normalized.toLowerCase().includes(term));
|
||
});
|
||
if (sortSelect.value === 'name-desc') paths.sort((a, b) => collator.compare(getName(b), getName(a)));
|
||
else if (sortSelect.value === 'folder-asc') paths.sort((a, b) => collator.compare(normalizePath(a), normalizePath(b)));
|
||
else paths.sort((a, b) => collator.compare(getName(a), getName(b)));
|
||
|
||
resultCount.textContent = t('pickerResults', { count: paths.length });
|
||
stopMedia(grid);
|
||
grid.replaceChildren();
|
||
const generation = ++renderGeneration;
|
||
let index = 0;
|
||
const renderChunk = () => {
|
||
if (generation !== renderGeneration || !modal.isConnected) return;
|
||
const fragment = document.createDocumentFragment();
|
||
const end = Math.min(index + 40, paths.length);
|
||
for (; index < end; index += 1) {
|
||
const path = paths[index];
|
||
const info = modelInfo[path] || { filename: getName(path), metadata: {} };
|
||
const card = modelCardFace(cardOwner, { ...info, preview_url: info.preview_url || previews[path] || '' }, index);
|
||
card.classList.add('anomalous-picker-card');
|
||
card.tabIndex = 0;
|
||
card.setAttribute('role', 'button');
|
||
card.dataset.path = path;
|
||
card.title = normalizePath(path);
|
||
paintCard(card);
|
||
if (currentPath === path) card.appendChild(Object.assign(document.createElement('span'), { className: 'anomalous-picker-current', textContent: t('pickerCurrent') }));
|
||
const choose = () => {
|
||
if (mode === 'replace') chosen = [path];
|
||
else chosen = chosen.includes(path) ? chosen.filter(item => item !== path) : [...chosen, path];
|
||
updateSelection();
|
||
grid.querySelectorAll('[data-path]').forEach(paintCard);
|
||
};
|
||
card.onclick = choose;
|
||
card.onkeydown = event => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
choose();
|
||
}
|
||
};
|
||
fragment.appendChild(card);
|
||
}
|
||
grid.appendChild(fragment);
|
||
if (index < paths.length) requestAnimationFrame(renderChunk);
|
||
};
|
||
if (paths.length) requestAnimationFrame(renderChunk);
|
||
else {
|
||
const empty = document.createElement('div');
|
||
empty.textContent = t('pickerNoMatches');
|
||
empty.style.cssText = 'color:var(--amb-text-dim);padding:30px;text-align:center;grid-column:1/-1;';
|
||
grid.appendChild(empty);
|
||
}
|
||
};
|
||
|
||
confirmBtn.onclick = async () => {
|
||
if (!chosen.length || applying) return;
|
||
applying = true;
|
||
updateSelection();
|
||
const oldValue = w.value;
|
||
try {
|
||
if (mode === 'insert') {
|
||
// The CLIP line goes through the LoRAs only when a file trains the text encoder.
|
||
const trained = await Promise.all(chosen.map(loraTrainsTextEncoder));
|
||
if (!modal.isConnected) return;
|
||
const { anchorNode, direction } = options;
|
||
const { graph } = anchorNode;
|
||
const kind = loraNodeKind(loraNode.loraNode);
|
||
const inserted = insertLoras({
|
||
graph,
|
||
anchorNode,
|
||
direction,
|
||
kind,
|
||
loras: chosen,
|
||
textEncoder: trained.some(Boolean),
|
||
setPath: (item, path) => {
|
||
const loraWidget = findModelComboWidget(item);
|
||
setWidgetValue(item, loraWidget, path);
|
||
try {
|
||
loraWidget.callback?.(loraWidget.value, app.canvas, item, app.canvas?.graph_mouse, null);
|
||
} catch (error) {
|
||
console.warn('[Anomalous] LoRA widget callback failed:', error);
|
||
}
|
||
},
|
||
});
|
||
if (kind.head) {
|
||
await nodesSettled();
|
||
placeNodes(graph, anchorNode, inserted, direction);
|
||
}
|
||
recordCanvasStep(app);
|
||
closeModal();
|
||
app.canvas.selectNodes(inserted);
|
||
bringIntoView(inserted[0]);
|
||
return;
|
||
} else {
|
||
app.graph?.beforeChange?.(node);
|
||
try {
|
||
setWidgetValue(node, w, chosen[0]);
|
||
if (typeof w.callback === 'function') w.callback(w.value, app.canvas, node, app.canvas?.graph_mouse, null);
|
||
// ComfyUI clears its own missing-model mark through this hook.
|
||
node.onWidgetChanged?.(w.name, w.value, oldValue, w);
|
||
app.graph?.afterChange?.(node);
|
||
} catch (error) {
|
||
setWidgetValue(node, w, oldValue);
|
||
app.graph?.afterChange?.(node);
|
||
throw error;
|
||
}
|
||
app.graph?.change?.();
|
||
app.graph?.setDirtyCanvas?.(true, true);
|
||
}
|
||
delete node.color;
|
||
delete node.bgcolor;
|
||
node.has_errors = false;
|
||
if (app.lastNodeErrors?.[node.id]) delete app.lastNodeErrors[node.id];
|
||
if (typeof app.clearErrors === 'function') app.clearErrors();
|
||
try { window.dispatchEvent(new CustomEvent('graphChanged')); } catch (error) {}
|
||
recordCanvasStep(app);
|
||
closeModal();
|
||
if (options.onApplied) options.onApplied();
|
||
else this.diagnoseNode(node);
|
||
} catch (error) {
|
||
setWidgetValue(node, w, oldValue);
|
||
applying = false;
|
||
updateSelection();
|
||
console.error('[Anomalous] Failed to apply model choice:', error);
|
||
alert(t('pickerOperationFailed') + error.message);
|
||
}
|
||
};
|
||
|
||
searchInput.oninput = renderCards;
|
||
baseFilterSelect.onchange = () => {
|
||
selectedBaseFamily = baseFilterSelect.value;
|
||
selectedFolder = '';
|
||
dropFiltered();
|
||
rebuildFolderCounts();
|
||
renderFolders();
|
||
updateSelection();
|
||
renderCards();
|
||
};
|
||
sortSelect.onchange = renderCards;
|
||
window.addEventListener('keydown', onEscape, true);
|
||
renderFolders();
|
||
updateSelection();
|
||
renderCards();
|
||
setTimeout(() => searchInput.focus(), 0);
|
||
|
||
const requestPayload = {
|
||
paths: validPaths,
|
||
context_requests: contextRequests,
|
||
};
|
||
if (pickerType.folderTypes.length) requestPayload.folder_types = pickerType.folderTypes;
|
||
fetch('/anomalous/resolve_paths_to_previews', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(requestPayload),
|
||
}).then(response => {
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
return response.json();
|
||
}).then(data => {
|
||
if (!modal.isConnected) return;
|
||
previews = data.previews || {};
|
||
modelInfo = data.models || {};
|
||
contextModels = data.context_models || {};
|
||
loadingText.style.display = 'none';
|
||
configureBaseFilter();
|
||
renderFolders();
|
||
updateSelection();
|
||
renderCards();
|
||
}).catch(error => {
|
||
if (!modal.isConnected) return;
|
||
console.error('[Anomalous] Failed to load model previews:', error);
|
||
loadingText.textContent = t('pickerCoversFailed');
|
||
configureBaseFilter();
|
||
renderFolders();
|
||
});
|
||
}
|
||
|