mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-28 08:21:27 -03:00
feat(models): add shared searchable base model picker and overhaul bulk base model modal
- Extract a shared BaseModelPicker (search, keyboard navigation, filename-based suggestions, dynamic API models such as MiniMax H3 under 'Other (API)') used by both the single-model metadata modal and the bulk base model modal - Rework the bulk base model modal into a dedicated inline-list layout: fixed modal size, sticky-free footer with app-standard modal-actions/primary-btn/cancel-btn buttons, and an inline option list that scrolls itself instead of an overlay dropdown covering the footer - Selecting an option in change mode now filters the list to the selection instead of resetting and scroll-jumping to it - Restore opaque sticky section headers in the bulk modal so scrolled items no longer bleed through
This commit is contained in:
+100
-21
@@ -49,34 +49,113 @@
|
|||||||
-ms-user-select: none;
|
-ms-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove bulk base model modal specific styles - now using shared components */
|
/* ── Bulk base model modal — dedicated inline-list layout ───────────────
|
||||||
/* Use shared metadata editing styles instead */
|
Unlike the single-model modal (overlay dropdown), the bulk modal renders
|
||||||
|
the option list inline so it never covers the footer buttons and only the
|
||||||
|
list itself scrolls. Dropdown internals reuse the shared .base-model-*
|
||||||
|
styles from lora-modal.css. */
|
||||||
|
|
||||||
/* Override for bulk base model select to ensure proper width */
|
#bulkBaseModelModal .modal-content {
|
||||||
.bulk-base-model-select {
|
width: min(720px, calc(100vw - 2rem));
|
||||||
|
height: min(640px, calc(100vh - var(--header-height, 48px) - 5.5rem));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulkBaseModelModal .modal-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulkBaseModelModal .modal-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulkBaseModelModal .bulk-add-tags-info {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulkBaseModelModal .bulk-base-model-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-base-model-picker {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
}
|
||||||
padding: 6px 10px;
|
|
||||||
|
#bulkBaseModelModal .bulk-base-model-picker {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulkBaseModelModal .bulk-base-model-picker .base-model-search-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
z-index: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulkBaseModelModal .base-model-search-input-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline list instead of overlay dropdown */
|
||||||
|
#bulkBaseModelModal .base-model-dropdown {
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
right: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
border: 1px solid var(--border-color);
|
box-shadow: none;
|
||||||
background-color: var(--lora-surface);
|
z-index: auto;
|
||||||
color: var(--text-color);
|
overscroll-behavior: contain;
|
||||||
font-size: 0.95em;
|
scrollbar-gutter: stable;
|
||||||
height: 32px;
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--lora-border) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulk-base-model-select:focus {
|
#bulkBaseModelModal .base-model-dropdown::-webkit-scrollbar {
|
||||||
border-color: var(--lora-accent);
|
width: 8px;
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark theme support for bulk base model select */
|
#bulkBaseModelModal .base-model-dropdown::-webkit-scrollbar-thumb {
|
||||||
[data-theme="dark"] .bulk-base-model-select {
|
background: var(--lora-border);
|
||||||
background-color: rgba(30, 30, 30, 0.9);
|
border-radius: 4px;
|
||||||
color: var(--text-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .bulk-base-model-select option {
|
#bulkBaseModelModal .base-model-dropdown::-webkit-scrollbar-track {
|
||||||
background-color: #2d2d2d;
|
background: transparent;
|
||||||
color: var(--text-color);
|
}
|
||||||
|
|
||||||
|
/* The shared dropdown header uses opacity for its muted look, which makes the
|
||||||
|
sticky background translucent — scrolled items bleed through. Keep the
|
||||||
|
muted text color but restore a fully opaque background (bulk scope only). */
|
||||||
|
#bulkBaseModelModal .base-model-dropdown-header {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--text-muted, var(--text-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulkBaseModelModal .bulk-base-model-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
border-top: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
/**
|
||||||
|
* BaseModelPicker.js
|
||||||
|
* Shared searchable base model picker used by the single-model metadata modal
|
||||||
|
* (commit mode) and the bulk base model modal (change mode).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BASE_MODEL_CATEGORIES, getMergedBaseModels, BASE_MODELS_UPDATED_EVENT } from '../../utils/constants.js';
|
||||||
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
|
|
||||||
|
// ── Filename-based base model inference ──────────────────────────────────────
|
||||||
|
// Rules are ordered by specificity — first match wins for dedup.
|
||||||
|
// Each rule checks the filename (lowercased) for a regex pattern and suggests
|
||||||
|
// the associated base model values.
|
||||||
|
|
||||||
|
export const BASE_MODEL_FILENAME_RULES = [
|
||||||
|
{ pattern: /flux\.?\s*2\s*klein/i, models: ['Flux.2 Klein 9B', 'Flux.2 Klein 9B-base', 'Flux.2 Klein 4B', 'Flux.2 Klein 4B-base'] },
|
||||||
|
{ pattern: /flux\.?\s*2/i, models: ['Flux.2 D', 'Flux.2 Klein 9B', 'Flux.2 Klein 4B'] },
|
||||||
|
{ pattern: /flux\.?\s*1\s*(dev|d)\b/i, models: ['Flux.1 D'] },
|
||||||
|
{ pattern: /flux\.?\s*1\s*(schnell|s)\b/i, models: ['Flux.1 S'] },
|
||||||
|
{ pattern: /flux/i, models: ['Flux.1 D', 'Flux.1 S', 'Flux.2 D'] },
|
||||||
|
{ pattern: /sdxl/i, models: ['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper'] },
|
||||||
|
{ pattern: /sd\s*1[._-\s]?5/i, models: ['SD 1.5'] },
|
||||||
|
{ pattern: /sd\s*1[._-\s]?4/i, models: ['SD 1.4'] },
|
||||||
|
{ pattern: /sd\s*1/i, models: ['SD 1.5', 'SD 1.4', 'SD 1.5 LCM', 'SD 1.5 Hyper'] },
|
||||||
|
{ pattern: /sd\s*3[._-\s]?5/i, models: ['SD 3.5', 'SD 3.5 Medium', 'SD 3.5 Large', 'SD 3.5 Large Turbo'] },
|
||||||
|
{ pattern: /sd\s*3/i, models: ['SD 3', 'SD 3.5'] },
|
||||||
|
{ pattern: /wan\s*\.?\s*video/i, models: ['Wan Video', 'Wan Video 1.3B t2v', 'Wan Video 14B t2v', 'Wan Video 14B i2v 480p', 'Wan Video 14B i2v 720p'] },
|
||||||
|
{ pattern: /hunyuan\s*\.?\s*video/i, models: ['Hunyuan Video'] },
|
||||||
|
{ pattern: /ltxv/i, models: ['LTXV', 'LTXV2', 'LTXV 2.3'] },
|
||||||
|
{ pattern: /cogvideo/i, models: ['CogVideoX'] },
|
||||||
|
{ pattern: /pony/i, models: ['Pony', 'Pony V7'] },
|
||||||
|
{ pattern: /illustrious/i, models: ['Illustrious'] },
|
||||||
|
{ pattern: /noobai/i, models: ['NoobAI'] },
|
||||||
|
{ pattern: /pixart/i, models: ['PixArt a', 'PixArt E'] },
|
||||||
|
{ pattern: /aura\s*\.?\s*flow/i, models: ['AuraFlow'] },
|
||||||
|
{ pattern: /kolors/i, models: ['Kolors'] },
|
||||||
|
{ pattern: /hunyuan\s*1/i, models: ['Hunyuan 1'] },
|
||||||
|
{ pattern: /lumina/i, models: ['Lumina'] },
|
||||||
|
{ pattern: /hidream/i, models: ['HiDream'] },
|
||||||
|
{ pattern: /qwen/i, models: ['Qwen'] },
|
||||||
|
{ pattern: /chroma/i, models: ['Chroma'] },
|
||||||
|
{ pattern: /anima/i, models: ['Anima'] },
|
||||||
|
{ pattern: /sd\s*2[._-\s]?[01]/i, models: ['SD 2.0', 'SD 2.1'] },
|
||||||
|
{ pattern: /mochi/i, models: ['Mochi'] },
|
||||||
|
{ pattern: /svd/i, models: ['SVD'] },
|
||||||
|
{ pattern: /zimage/i, models: ['ZImageTurbo', 'ZImageBase'] },
|
||||||
|
{ pattern: /nucleus/i, models: ['Nucleus'] },
|
||||||
|
{ pattern: /krea/i, models: ['Flux.1 Krea', 'Krea 2'] },
|
||||||
|
{ pattern: /ernie/i, models: ['Ernie', 'Ernie Turbo'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer likely base model(s) from a filename + model name string.
|
||||||
|
* Returns a deduplicated array in match-priority order.
|
||||||
|
* @param {string} filename
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function inferBaseModelsFromFilename(filename) {
|
||||||
|
if (!filename || typeof filename !== 'string') return [];
|
||||||
|
const seen = new Set();
|
||||||
|
const results = [];
|
||||||
|
for (const rule of BASE_MODEL_FILENAME_RULES) {
|
||||||
|
if (rule.pattern.test(filename)) {
|
||||||
|
for (const model of rule.models) {
|
||||||
|
if (!seen.has(model)) {
|
||||||
|
seen.add(model);
|
||||||
|
results.push(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer likely base model(s) from a set of file paths (bulk selection).
|
||||||
|
* Each path contributes its basename to the inference; models are deduplicated
|
||||||
|
* and sorted by how many selected paths matched them (most matches first).
|
||||||
|
* Returns an empty array when nothing matches.
|
||||||
|
* @param {string[]} filePaths
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function inferBaseModelsFromFilepaths(filePaths) {
|
||||||
|
if (!Array.isArray(filePaths) || filePaths.length === 0) return [];
|
||||||
|
const hitCounts = new Map(); // model -> number of paths that matched it
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
if (!filePath || typeof filePath !== 'string') continue;
|
||||||
|
const basename = filePath.split(/[\\/]/).pop();
|
||||||
|
for (const model of inferBaseModelsFromFilename(basename)) {
|
||||||
|
hitCounts.set(model, (hitCounts.get(model) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(hitCounts.keys())
|
||||||
|
.sort((a, b) => hitCounts.get(b) - hitCounts.get(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full categorized option list. Reads BASE_MODEL_CATEGORIES and
|
||||||
|
* getMergedBaseModels() fresh on every call so late-arriving dynamic models
|
||||||
|
* are picked up; uncategorized dynamic entries land in "Other (API)".
|
||||||
|
* @returns {Array<{value: string, label: string, category: string}>}
|
||||||
|
*/
|
||||||
|
function buildCategorizedOptions() {
|
||||||
|
const allModels = [];
|
||||||
|
const categorizedModels = new Set();
|
||||||
|
|
||||||
|
Object.entries(BASE_MODEL_CATEGORIES).forEach(([category, models]) => {
|
||||||
|
models.forEach(model => {
|
||||||
|
allModels.push({ value: model, label: model, category });
|
||||||
|
categorizedModels.add(model);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const uncategorizedModels = getMergedBaseModels().filter(model => !categorizedModels.has(model));
|
||||||
|
uncategorizedModels.forEach(model => {
|
||||||
|
allModels.push({ value: model, label: model, category: 'Other (API)' });
|
||||||
|
});
|
||||||
|
|
||||||
|
return allModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a searchable base model picker.
|
||||||
|
*
|
||||||
|
* Two commit semantics are supported:
|
||||||
|
* - 'commit' (default): selecting an item immediately calls onCommit(value).
|
||||||
|
* Escape or an outside click calls onDismiss().
|
||||||
|
* - 'change': selecting an item updates the internal value and calls
|
||||||
|
* onChange(value); the caller owns when the selected value is persisted.
|
||||||
|
* Typed text doubles as a custom value unless allowCustomValue is false,
|
||||||
|
* in which case it is search-only.
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string[]} [options.suggestions] - Models shown in the Suggested section
|
||||||
|
* @param {string} [options.initialValue] - Initially selected value
|
||||||
|
* @param {'commit'|'change'} [options.mode] - Commit semantics
|
||||||
|
* @param {boolean} [options.allowCustomValue=true] - Accept typed text as a custom value
|
||||||
|
* @param {(value: string) => void} [options.onCommit] - Commit-mode commit callback
|
||||||
|
* @param {(value: string) => void} [options.onChange] - Called whenever the value changes
|
||||||
|
* @param {() => void} [options.onDismiss] - Commit-mode dismiss callback (Escape/outside click)
|
||||||
|
* @returns {{ element: HTMLElement, getValue: Function, setValue: Function, refreshOptions: Function, destroy: Function }}
|
||||||
|
*/
|
||||||
|
export function createBaseModelPicker(options = {}) {
|
||||||
|
const {
|
||||||
|
suggestions = [],
|
||||||
|
initialValue = '',
|
||||||
|
mode = 'commit',
|
||||||
|
allowCustomValue = true,
|
||||||
|
onCommit = null,
|
||||||
|
onChange = null,
|
||||||
|
onDismiss = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const isCommitMode = mode !== 'change';
|
||||||
|
let currentValue = initialValue || '';
|
||||||
|
let currentFilter = '';
|
||||||
|
let destroyed = false;
|
||||||
|
|
||||||
|
// ── Build widget DOM ────────────────────────────────────────────────────
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'base-model-search-wrapper';
|
||||||
|
|
||||||
|
const inputWrapper = document.createElement('div');
|
||||||
|
inputWrapper.className = 'base-model-search-input-wrapper';
|
||||||
|
const searchIcon = document.createElement('i');
|
||||||
|
searchIcon.className = 'fas fa-search search-icon';
|
||||||
|
searchIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
inputWrapper.appendChild(searchIcon);
|
||||||
|
const searchInput = document.createElement('input');
|
||||||
|
searchInput.type = 'text';
|
||||||
|
searchInput.className = 'base-model-search-input';
|
||||||
|
searchInput.placeholder = translate('modals.model.metadata.baseModelSearchPlaceholder', {}, 'Search base model…');
|
||||||
|
searchInput.autocomplete = 'off';
|
||||||
|
searchInput.spellcheck = false;
|
||||||
|
inputWrapper.appendChild(searchInput);
|
||||||
|
wrapper.appendChild(inputWrapper);
|
||||||
|
|
||||||
|
const dropdown = document.createElement('div');
|
||||||
|
dropdown.className = 'base-model-dropdown';
|
||||||
|
wrapper.appendChild(dropdown);
|
||||||
|
|
||||||
|
// ── Render ──────────────────────────────────────────────────────────────
|
||||||
|
function renderDropdown(filterText) {
|
||||||
|
currentFilter = filterText || '';
|
||||||
|
const lowerFilter = currentFilter.toLowerCase().trim();
|
||||||
|
const allModels = buildCategorizedOptions();
|
||||||
|
const suggestedSet = new Set(suggestions);
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
let hasVisibleItems = false;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
// 1. Suggested section (filtered by search)
|
||||||
|
const suggestedToShow = lowerFilter
|
||||||
|
? suggestions.filter(m => m.toLowerCase().includes(lowerFilter))
|
||||||
|
: suggestions;
|
||||||
|
|
||||||
|
if (suggestedToShow.length > 0) {
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'base-model-dropdown-section';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'base-model-dropdown-header suggested-header';
|
||||||
|
header.innerHTML = '<i class="fas fa-star" aria-hidden="true"></i> ' +
|
||||||
|
translate('modals.model.metadata.baseModelSuggested', {}, 'Suggested');
|
||||||
|
section.appendChild(header);
|
||||||
|
|
||||||
|
suggestedToShow.forEach(model => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'base-model-dropdown-item';
|
||||||
|
if (model === currentValue) item.classList.add('selected');
|
||||||
|
item.dataset.value = model;
|
||||||
|
item.textContent = model;
|
||||||
|
section.appendChild(item);
|
||||||
|
hasVisibleItems = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
fragment.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Categorized options (deduplicated against suggestions)
|
||||||
|
const categoryMap = {};
|
||||||
|
allModels.forEach(m => {
|
||||||
|
if (suggestedSet.has(m.value)) return; // already shown in Suggested
|
||||||
|
if (lowerFilter && !m.label.toLowerCase().includes(lowerFilter)) return;
|
||||||
|
if (!categoryMap[m.category]) categoryMap[m.category] = [];
|
||||||
|
categoryMap[m.category].push(m);
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.entries(categoryMap).forEach(([category, items]) => {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'base-model-dropdown-section';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'base-model-dropdown-header';
|
||||||
|
header.textContent = category;
|
||||||
|
section.appendChild(header);
|
||||||
|
|
||||||
|
items.forEach(m => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'base-model-dropdown-item';
|
||||||
|
if (m.value === currentValue) item.classList.add('selected');
|
||||||
|
item.dataset.value = m.value;
|
||||||
|
item.textContent = m.label;
|
||||||
|
section.appendChild(item);
|
||||||
|
hasVisibleItems = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
fragment.appendChild(section);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Empty state
|
||||||
|
if (!hasVisibleItems) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'base-model-dropdown-empty';
|
||||||
|
empty.textContent = translate('modals.model.metadata.baseModelNoMatch', {}, 'No matching base models');
|
||||||
|
fragment.appendChild(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdown.appendChild(fragment);
|
||||||
|
|
||||||
|
// Scroll the selected item into view
|
||||||
|
const selected = dropdown.querySelector('.base-model-dropdown-item.selected');
|
||||||
|
if (selected) {
|
||||||
|
selected.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial render — show everything
|
||||||
|
renderDropdown('');
|
||||||
|
|
||||||
|
// ── Value handling ──────────────────────────────────────────────────────
|
||||||
|
function applySelection(value) {
|
||||||
|
currentValue = value;
|
||||||
|
if (isCommitMode) {
|
||||||
|
if (typeof onCommit === 'function') onCommit(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Change mode: mirror the selection into the input and notify only.
|
||||||
|
searchInput.value = value;
|
||||||
|
// Filter the list down to the selected item instead of resetting to
|
||||||
|
// the full list (which scroll-jumps to the selection). Custom values
|
||||||
|
// that are not in the option list keep the full list visible.
|
||||||
|
const isKnownValue = suggestions.includes(value) ||
|
||||||
|
buildCategorizedOptions().some(m => m.value === value);
|
||||||
|
renderDropdown(isKnownValue ? value : '');
|
||||||
|
if (typeof onChange === 'function') onChange(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Events ──────────────────────────────────────────────────────────────
|
||||||
|
let filterTimeout;
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(filterTimeout);
|
||||||
|
filterTimeout = setTimeout(() => {
|
||||||
|
renderDropdown(searchInput.value);
|
||||||
|
// Change mode with custom values: typed text is the live value.
|
||||||
|
if (!isCommitMode && allowCustomValue) {
|
||||||
|
currentValue = searchInput.value;
|
||||||
|
if (typeof onChange === 'function') onChange(currentValue);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click to select
|
||||||
|
dropdown.addEventListener('click', (e) => {
|
||||||
|
const item = e.target.closest('.base-model-dropdown-item');
|
||||||
|
if (!item) return;
|
||||||
|
applySelection(item.dataset.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
searchInput.addEventListener('keydown', (e) => {
|
||||||
|
const items = Array.from(dropdown.querySelectorAll('.base-model-dropdown-item'));
|
||||||
|
const activeIdx = items.findIndex(el => el.classList.contains('active'));
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
items.forEach(el => el.classList.remove('active'));
|
||||||
|
const next = Math.min(activeIdx + 1, items.length - 1);
|
||||||
|
if (items[next]) {
|
||||||
|
items[next].classList.add('active');
|
||||||
|
items[next].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
items.forEach(el => el.classList.remove('active'));
|
||||||
|
const prev = Math.max(activeIdx - 1, 0);
|
||||||
|
if (items[prev]) {
|
||||||
|
items[prev].classList.add('active');
|
||||||
|
items[prev].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const activeItem = items.find(el => el.classList.contains('active'));
|
||||||
|
if (activeItem) {
|
||||||
|
applySelection(activeItem.dataset.value);
|
||||||
|
} else if (allowCustomValue && searchInput.value.trim()) {
|
||||||
|
applySelection(searchInput.value.trim());
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isCommitMode && typeof onDismiss === 'function') {
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Commit mode: outside click commits typed text (when custom values are
|
||||||
|
// allowed) or dismisses. Deferred to avoid the opening click itself.
|
||||||
|
const outsideClickHandler = (e) => {
|
||||||
|
if (wrapper.contains(e.target)) return;
|
||||||
|
const typedValue = searchInput.value.trim();
|
||||||
|
if (allowCustomValue && typedValue) {
|
||||||
|
applySelection(typedValue);
|
||||||
|
} else if (typeof onDismiss === 'function') {
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let outsideClickTimer = null;
|
||||||
|
if (isCommitMode) {
|
||||||
|
outsideClickTimer = setTimeout(() => {
|
||||||
|
outsideClickTimer = null;
|
||||||
|
if (!destroyed) {
|
||||||
|
document.addEventListener('click', outsideClickHandler);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh when dynamic base models arrive late; keeps the current search text.
|
||||||
|
const handleBaseModelsUpdated = () => {
|
||||||
|
if (destroyed) return;
|
||||||
|
refreshOptions();
|
||||||
|
};
|
||||||
|
window.addEventListener(BASE_MODELS_UPDATED_EVENT, handleBaseModelsUpdated);
|
||||||
|
|
||||||
|
// ── Public API ──────────────────────────────────────────────────────────
|
||||||
|
function getValue() {
|
||||||
|
return currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValue(value) {
|
||||||
|
currentValue = value || '';
|
||||||
|
searchInput.value = currentValue;
|
||||||
|
renderDropdown('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshOptions() {
|
||||||
|
renderDropdown(currentFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
clearTimeout(filterTimeout);
|
||||||
|
if (outsideClickTimer) {
|
||||||
|
clearTimeout(outsideClickTimer);
|
||||||
|
outsideClickTimer = null;
|
||||||
|
}
|
||||||
|
document.removeEventListener('click', outsideClickHandler);
|
||||||
|
window.removeEventListener(BASE_MODELS_UPDATED_EVENT, handleBaseModelsUpdated);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { element: wrapper, getValue, setValue, refreshOptions, destroy };
|
||||||
|
}
|
||||||
@@ -3,75 +3,9 @@
|
|||||||
* Handles model metadata editing functionality - General version
|
* Handles model metadata editing functionality - General version
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BASE_MODEL_CATEGORIES, getMergedBaseModels } from '../../utils/constants.js';
|
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
import { translate } from '../../utils/i18nHelpers.js';
|
import { inferBaseModelsFromFilename, createBaseModelPicker } from './BaseModelPicker.js';
|
||||||
|
|
||||||
// ── Filename-based base model inference ──────────────────────────────────────
|
|
||||||
// Rules are ordered by specificity — first match wins for dedup.
|
|
||||||
// Each rule checks the filename (lowercased) for a regex pattern and suggests
|
|
||||||
// the associated base model values.
|
|
||||||
|
|
||||||
const BASE_MODEL_FILENAME_RULES = [
|
|
||||||
{ pattern: /flux\.?\s*2\s*klein/i, models: ['Flux.2 Klein 9B', 'Flux.2 Klein 9B-base', 'Flux.2 Klein 4B', 'Flux.2 Klein 4B-base'] },
|
|
||||||
{ pattern: /flux\.?\s*2/i, models: ['Flux.2 D', 'Flux.2 Klein 9B', 'Flux.2 Klein 4B'] },
|
|
||||||
{ pattern: /flux\.?\s*1\s*(dev|d)\b/i, models: ['Flux.1 D'] },
|
|
||||||
{ pattern: /flux\.?\s*1\s*(schnell|s)\b/i, models: ['Flux.1 S'] },
|
|
||||||
{ pattern: /flux/i, models: ['Flux.1 D', 'Flux.1 S', 'Flux.2 D'] },
|
|
||||||
{ pattern: /sdxl/i, models: ['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper'] },
|
|
||||||
{ pattern: /sd\s*1[._-\s]?5/i, models: ['SD 1.5'] },
|
|
||||||
{ pattern: /sd\s*1[._-\s]?4/i, models: ['SD 1.4'] },
|
|
||||||
{ pattern: /sd\s*1/i, models: ['SD 1.5', 'SD 1.4', 'SD 1.5 LCM', 'SD 1.5 Hyper'] },
|
|
||||||
{ pattern: /sd\s*3[._-\s]?5/i, models: ['SD 3.5', 'SD 3.5 Medium', 'SD 3.5 Large', 'SD 3.5 Large Turbo'] },
|
|
||||||
{ pattern: /sd\s*3/i, models: ['SD 3', 'SD 3.5'] },
|
|
||||||
{ pattern: /wan\s*\.?\s*video/i, models: ['Wan Video', 'Wan Video 1.3B t2v', 'Wan Video 14B t2v', 'Wan Video 14B i2v 480p', 'Wan Video 14B i2v 720p'] },
|
|
||||||
{ pattern: /hunyuan\s*\.?\s*video/i, models: ['Hunyuan Video'] },
|
|
||||||
{ pattern: /ltxv/i, models: ['LTXV', 'LTXV2', 'LTXV 2.3'] },
|
|
||||||
{ pattern: /cogvideo/i, models: ['CogVideoX'] },
|
|
||||||
{ pattern: /pony/i, models: ['Pony', 'Pony V7'] },
|
|
||||||
{ pattern: /illustrious/i, models: ['Illustrious'] },
|
|
||||||
{ pattern: /noobai/i, models: ['NoobAI'] },
|
|
||||||
{ pattern: /pixart/i, models: ['PixArt a', 'PixArt E'] },
|
|
||||||
{ pattern: /aura\s*\.?\s*flow/i, models: ['AuraFlow'] },
|
|
||||||
{ pattern: /kolors/i, models: ['Kolors'] },
|
|
||||||
{ pattern: /hunyuan\s*1/i, models: ['Hunyuan 1'] },
|
|
||||||
{ pattern: /lumina/i, models: ['Lumina'] },
|
|
||||||
{ pattern: /hidream/i, models: ['HiDream'] },
|
|
||||||
{ pattern: /qwen/i, models: ['Qwen'] },
|
|
||||||
{ pattern: /chroma/i, models: ['Chroma'] },
|
|
||||||
{ pattern: /anima/i, models: ['Anima'] },
|
|
||||||
{ pattern: /sd\s*2[._-\s]?[01]/i, models: ['SD 2.0', 'SD 2.1'] },
|
|
||||||
{ pattern: /mochi/i, models: ['Mochi'] },
|
|
||||||
{ pattern: /svd/i, models: ['SVD'] },
|
|
||||||
{ pattern: /zimage/i, models: ['ZImageTurbo', 'ZImageBase'] },
|
|
||||||
{ pattern: /nucleus/i, models: ['Nucleus'] },
|
|
||||||
{ pattern: /krea/i, models: ['Flux.1 Krea', 'Krea 2'] },
|
|
||||||
{ pattern: /ernie/i, models: ['Ernie', 'Ernie Turbo'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Infer likely base model(s) from a filename + model name string.
|
|
||||||
* Returns a deduplicated array in match-priority order.
|
|
||||||
* @param {string} filename
|
|
||||||
* @returns {string[]}
|
|
||||||
*/
|
|
||||||
function inferBaseModelsFromFilename(filename) {
|
|
||||||
if (!filename || typeof filename !== 'string') return [];
|
|
||||||
const seen = new Set();
|
|
||||||
const results = [];
|
|
||||||
for (const rule of BASE_MODEL_FILENAME_RULES) {
|
|
||||||
if (rule.pattern.test(filename)) {
|
|
||||||
for (const model of rule.models) {
|
|
||||||
if (!seen.has(model)) {
|
|
||||||
seen.add(model);
|
|
||||||
results.push(model);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the active file path for the currently open model modal.
|
* Resolve the active file path for the currently open model modal.
|
||||||
@@ -325,195 +259,12 @@ export function setupBaseModelEditing(filePath) {
|
|||||||
// Store the original value to check for changes later
|
// Store the original value to check for changes later
|
||||||
const originalValue = baseModelContent.textContent.trim();
|
const originalValue = baseModelContent.textContent.trim();
|
||||||
|
|
||||||
// ── Build the full option list ────────────────────────────────────────
|
// Filename-based inference for the Suggested section
|
||||||
const allModels = []; // { value, label, category }
|
|
||||||
const categorizedModels = new Set();
|
|
||||||
|
|
||||||
Object.entries(BASE_MODEL_CATEGORIES).forEach(([category, models]) => {
|
|
||||||
models.forEach(model => {
|
|
||||||
allModels.push({ value: model, label: model, category });
|
|
||||||
categorizedModels.add(model);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const mergedModels = getMergedBaseModels();
|
|
||||||
const uncategorizedModels = mergedModels.filter(model => !categorizedModels.has(model));
|
|
||||||
if (uncategorizedModels.length > 0) {
|
|
||||||
uncategorizedModels.forEach(model => {
|
|
||||||
allModels.push({ value: model, label: model, category: 'Other (API)' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Filename-based inference ──────────────────────────────────────────
|
|
||||||
const fileName = (document.querySelector('.file-name-content')?.textContent || '') + ' ' +
|
const fileName = (document.querySelector('.file-name-content')?.textContent || '') + ' ' +
|
||||||
(document.querySelector('.model-name-content')?.textContent || '');
|
(document.querySelector('.model-name-content')?.textContent || '');
|
||||||
const inferredModels = inferBaseModelsFromFilename(fileName);
|
const inferredModels = inferBaseModelsFromFilename(fileName);
|
||||||
const inferredSet = new Set(inferredModels);
|
|
||||||
|
|
||||||
// ── Build search widget DOM ───────────────────────────────────────────
|
const saveIfChanged = () => {
|
||||||
const wrapper = document.createElement('div');
|
|
||||||
wrapper.className = 'base-model-search-wrapper';
|
|
||||||
|
|
||||||
// Search input row
|
|
||||||
const inputWrapper = document.createElement('div');
|
|
||||||
inputWrapper.className = 'base-model-search-input-wrapper';
|
|
||||||
const searchIcon = document.createElement('i');
|
|
||||||
searchIcon.className = 'fas fa-search search-icon';
|
|
||||||
searchIcon.setAttribute('aria-hidden', 'true');
|
|
||||||
inputWrapper.appendChild(searchIcon);
|
|
||||||
const searchInput = document.createElement('input');
|
|
||||||
searchInput.type = 'text';
|
|
||||||
searchInput.className = 'base-model-search-input';
|
|
||||||
searchInput.placeholder = translate('modals.model.metadata.baseModelSearchPlaceholder', {}, 'Search base model…');
|
|
||||||
searchInput.autocomplete = 'off';
|
|
||||||
searchInput.spellcheck = false;
|
|
||||||
inputWrapper.appendChild(searchInput);
|
|
||||||
wrapper.appendChild(inputWrapper);
|
|
||||||
|
|
||||||
// Dropdown list
|
|
||||||
const dropdown = document.createElement('div');
|
|
||||||
dropdown.className = 'base-model-dropdown';
|
|
||||||
wrapper.appendChild(dropdown);
|
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────
|
|
||||||
function renderDropdown(filterText) {
|
|
||||||
const lowerFilter = (filterText || '').toLowerCase().trim();
|
|
||||||
dropdown.innerHTML = '';
|
|
||||||
let hasVisibleItems = false;
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
|
|
||||||
// 1. Suggested section (filename-inferred, filtered by search)
|
|
||||||
let suggestedToShow = inferredModels;
|
|
||||||
if (lowerFilter) {
|
|
||||||
suggestedToShow = inferredModels.filter(m =>
|
|
||||||
m.toLowerCase().includes(lowerFilter)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (suggestedToShow.length > 0) {
|
|
||||||
const section = document.createElement('div');
|
|
||||||
section.className = 'base-model-dropdown-section';
|
|
||||||
|
|
||||||
const header = document.createElement('div');
|
|
||||||
header.className = 'base-model-dropdown-header suggested-header';
|
|
||||||
header.innerHTML = '<i class="fas fa-star" aria-hidden="true"></i> ' +
|
|
||||||
translate('modals.model.metadata.baseModelSuggested', {}, 'Suggested');
|
|
||||||
section.appendChild(header);
|
|
||||||
|
|
||||||
suggestedToShow.forEach(model => {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'base-model-dropdown-item';
|
|
||||||
if (model === originalValue) item.classList.add('selected');
|
|
||||||
item.dataset.value = model;
|
|
||||||
item.textContent = model;
|
|
||||||
section.appendChild(item);
|
|
||||||
hasVisibleItems = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
fragment.appendChild(section);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Categorized options (deduplicated against suggestions)
|
|
||||||
const categoryMap = {};
|
|
||||||
allModels.forEach(m => {
|
|
||||||
if (inferredSet.has(m.value)) return; // already shown in Suggested
|
|
||||||
if (lowerFilter && !m.label.toLowerCase().includes(lowerFilter)) return;
|
|
||||||
if (!categoryMap[m.category]) categoryMap[m.category] = [];
|
|
||||||
categoryMap[m.category].push(m);
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.entries(categoryMap).forEach(([category, items]) => {
|
|
||||||
if (items.length === 0) return;
|
|
||||||
const section = document.createElement('div');
|
|
||||||
section.className = 'base-model-dropdown-section';
|
|
||||||
|
|
||||||
const header = document.createElement('div');
|
|
||||||
header.className = 'base-model-dropdown-header';
|
|
||||||
header.textContent = category;
|
|
||||||
section.appendChild(header);
|
|
||||||
|
|
||||||
items.forEach(m => {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'base-model-dropdown-item';
|
|
||||||
if (m.value === originalValue) item.classList.add('selected');
|
|
||||||
item.dataset.value = m.value;
|
|
||||||
item.textContent = m.label;
|
|
||||||
section.appendChild(item);
|
|
||||||
hasVisibleItems = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
fragment.appendChild(section);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Empty state
|
|
||||||
if (!hasVisibleItems) {
|
|
||||||
const empty = document.createElement('div');
|
|
||||||
empty.className = 'base-model-dropdown-empty';
|
|
||||||
empty.textContent = translate('modals.model.metadata.baseModelNoMatch', {}, 'No matching base models');
|
|
||||||
fragment.appendChild(empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
dropdown.appendChild(fragment);
|
|
||||||
|
|
||||||
// Scroll the selected item into view
|
|
||||||
const selected = dropdown.querySelector('.base-model-dropdown-item.selected');
|
|
||||||
if (selected) {
|
|
||||||
selected.scrollIntoView({ block: 'nearest' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial render — show everything
|
|
||||||
renderDropdown('');
|
|
||||||
|
|
||||||
// ── Events ────────────────────────────────────────────────────────────
|
|
||||||
let filterTimeout;
|
|
||||||
searchInput.addEventListener('input', () => {
|
|
||||||
clearTimeout(filterTimeout);
|
|
||||||
filterTimeout = setTimeout(() => renderDropdown(searchInput.value), 50);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click to select
|
|
||||||
dropdown.addEventListener('click', (e) => {
|
|
||||||
const item = e.target.closest('.base-model-dropdown-item');
|
|
||||||
if (!item) return;
|
|
||||||
baseModelContent.textContent = item.dataset.value;
|
|
||||||
cleanup();
|
|
||||||
const finalValue = baseModelContent.textContent.trim();
|
|
||||||
if (finalValue !== originalValue) {
|
|
||||||
saveBaseModel(
|
|
||||||
getActiveModalFilePath(baseModelContent.dataset.filePath),
|
|
||||||
originalValue
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Replace content with search widget
|
|
||||||
baseModelContent.style.display = 'none';
|
|
||||||
editBtn.style.display = 'none';
|
|
||||||
baseModelDisplay.insertBefore(wrapper, editBtn);
|
|
||||||
searchInput.focus();
|
|
||||||
|
|
||||||
// ── Cleanup ───────────────────────────────────────────────────────────
|
|
||||||
function cleanup() {
|
|
||||||
if (wrapper.parentNode === baseModelDisplay) {
|
|
||||||
baseModelDisplay.removeChild(wrapper);
|
|
||||||
}
|
|
||||||
baseModelContent.style.display = '';
|
|
||||||
editBtn.style.display = '';
|
|
||||||
baseModelDisplay.classList.remove('editing');
|
|
||||||
document.removeEventListener('click', outsideClickHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outside click → save typed/custom value if any
|
|
||||||
const outsideClickHandler = function(e) {
|
|
||||||
if (wrapper.contains(e.target)) return;
|
|
||||||
|
|
||||||
// If user typed a custom value (not just empty), apply it
|
|
||||||
const typedValue = searchInput.value.trim();
|
|
||||||
if (typedValue) {
|
|
||||||
baseModelContent.textContent = typedValue;
|
|
||||||
}
|
|
||||||
cleanup();
|
|
||||||
const finalValue = baseModelContent.textContent.trim();
|
const finalValue = baseModelContent.textContent.trim();
|
||||||
if (finalValue !== originalValue) {
|
if (finalValue !== originalValue) {
|
||||||
saveBaseModel(
|
saveBaseModel(
|
||||||
@@ -523,55 +274,38 @@ export function setupBaseModelEditing(filePath) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Defer listener to avoid the opening click itself
|
const picker = createBaseModelPicker({
|
||||||
setTimeout(() => {
|
suggestions: inferredModels,
|
||||||
document.addEventListener('click', outsideClickHandler);
|
initialValue: originalValue,
|
||||||
}, 0);
|
mode: 'commit',
|
||||||
|
onCommit: (value) => {
|
||||||
// Keyboard navigation
|
baseModelContent.textContent = value;
|
||||||
searchInput.addEventListener('keydown', function onKeydown(e) {
|
|
||||||
const items = Array.from(dropdown.querySelectorAll('.base-model-dropdown-item'));
|
|
||||||
const activeIdx = items.findIndex(el => el.classList.contains('active'));
|
|
||||||
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
items.forEach(el => el.classList.remove('active'));
|
|
||||||
const next = Math.min(activeIdx + 1, items.length - 1);
|
|
||||||
if (items[next]) {
|
|
||||||
items[next].classList.add('active');
|
|
||||||
items[next].scrollIntoView({ block: 'nearest' });
|
|
||||||
}
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
items.forEach(el => el.classList.remove('active'));
|
|
||||||
const prev = Math.max(activeIdx - 1, 0);
|
|
||||||
if (items[prev]) {
|
|
||||||
items[prev].classList.add('active');
|
|
||||||
items[prev].scrollIntoView({ block: 'nearest' });
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
const activeItem = items.find(el => el.classList.contains('active'));
|
|
||||||
if (activeItem) {
|
|
||||||
activeItem.click();
|
|
||||||
} else if (searchInput.value.trim()) {
|
|
||||||
// Custom value typed
|
|
||||||
baseModelContent.textContent = searchInput.value.trim();
|
|
||||||
cleanup();
|
cleanup();
|
||||||
const finalValue = baseModelContent.textContent.trim();
|
saveIfChanged();
|
||||||
if (finalValue !== originalValue) {
|
},
|
||||||
saveBaseModel(
|
onDismiss: () => {
|
||||||
getActiveModalFilePath(baseModelContent.dataset.filePath),
|
// Escape or empty outside click: restore the original value
|
||||||
originalValue
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
baseModelContent.textContent = originalValue;
|
baseModelContent.textContent = originalValue;
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
picker.destroy();
|
||||||
|
if (picker.element.parentNode === baseModelDisplay) {
|
||||||
|
baseModelDisplay.removeChild(picker.element);
|
||||||
|
}
|
||||||
|
baseModelContent.style.display = '';
|
||||||
|
editBtn.style.display = '';
|
||||||
|
baseModelDisplay.classList.remove('editing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace content with search widget
|
||||||
|
baseModelContent.style.display = 'none';
|
||||||
|
editBtn.style.display = 'none';
|
||||||
|
baseModelDisplay.insertBefore(picker.element, editBtn);
|
||||||
|
const searchInput = picker.element.querySelector('.base-model-search-input');
|
||||||
|
if (searchInput) searchInput.focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -20,7 +20,7 @@ import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js';
|
|||||||
import { createPageContextMenu, createGlobalContextMenu } from './components/ContextMenu/index.js';
|
import { createPageContextMenu, createGlobalContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { initializeEventManagement } from './utils/eventManagementInit.js';
|
import { initializeEventManagement } from './utils/eventManagementInit.js';
|
||||||
import { civitaiBaseModelApi } from './api/civitaiBaseModelApi.js';
|
import { civitaiBaseModelApi } from './api/civitaiBaseModelApi.js';
|
||||||
import { setDynamicBaseModels } from './utils/constants.js';
|
import { setDynamicBaseModels, BASE_MODELS_UPDATED_EVENT } from './utils/constants.js';
|
||||||
|
|
||||||
// Core application class
|
// Core application class
|
||||||
export class AppCore {
|
export class AppCore {
|
||||||
@@ -134,6 +134,7 @@ export class AppCore {
|
|||||||
const result = await civitaiBaseModelApi.getBaseModels();
|
const result = await civitaiBaseModelApi.getBaseModels();
|
||||||
if (result && result.models) {
|
if (result && result.models) {
|
||||||
setDynamicBaseModels(result.models, result.last_updated);
|
setDynamicBaseModels(result.models, result.last_updated);
|
||||||
|
window.dispatchEvent(new CustomEvent(BASE_MODELS_UPDATED_EVENT));
|
||||||
console.log(`AppCore: Loaded ${result.merged_count} base models (${result.hardcoded_count} hardcoded + ${result.remote_count} remote)`);
|
console.log(`AppCore: Loaded ${result.merged_count} base models (${result.hardcoded_count} hardcoded + ${result.remote_count} remote)`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { modalManager } from './ModalManager.js';
|
|||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
|
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
|
||||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||||
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
import { createBaseModelPicker, inferBaseModelsFromFilepaths } from '../components/shared/BaseModelPicker.js';
|
||||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||||
import { eventManager } from '../utils/EventManager.js';
|
import { eventManager } from '../utils/EventManager.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
@@ -30,6 +30,10 @@ export class BulkManager {
|
|||||||
// toggleCardSelection, cleared in clearSelection.
|
// toggleCardSelection, cleared in clearSelection.
|
||||||
this.bulkAnchorFilepath = null;
|
this.bulkAnchorFilepath = null;
|
||||||
|
|
||||||
|
// Bulk base model picker state
|
||||||
|
this.bulkBaseModelPicker = null;
|
||||||
|
this.bulkBaseModelValue = '';
|
||||||
|
|
||||||
// Drag detection properties
|
// Drag detection properties
|
||||||
this.dragThreshold = 5; // Pixels to move before considering it a drag
|
this.dragThreshold = 5; // Pixels to move before considering it a drag
|
||||||
this.dragDelayMs = 100; // Minimum hold time before a drag is treated as a marquee
|
this.dragDelayMs = 100; // Minimum hold time before a drag is treated as a marquee
|
||||||
@@ -1830,47 +1834,35 @@ export class BulkManager {
|
|||||||
* Initialize bulk base model interface
|
* Initialize bulk base model interface
|
||||||
*/
|
*/
|
||||||
initializeBulkBaseModelInterface() {
|
initializeBulkBaseModelInterface() {
|
||||||
const select = document.getElementById('bulkBaseModelSelect');
|
const container = document.getElementById('bulkBaseModelPicker');
|
||||||
if (!select) return;
|
if (!container) return;
|
||||||
|
|
||||||
// Clear existing options
|
// Reset any previous picker instance
|
||||||
select.innerHTML = '';
|
this.cleanupBulkBaseModelModal();
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
// Add placeholder option
|
const suggestions = inferBaseModelsFromFilepaths(Array.from(state.selectedModels));
|
||||||
const placeholderOption = document.createElement('option');
|
this.bulkBaseModelValue = '';
|
||||||
placeholderOption.value = '';
|
this.bulkBaseModelPicker = createBaseModelPicker({
|
||||||
placeholderOption.textContent = 'Select a base model...';
|
suggestions,
|
||||||
placeholderOption.disabled = true;
|
mode: 'change',
|
||||||
placeholderOption.selected = true;
|
onChange: (value) => {
|
||||||
select.appendChild(placeholderOption);
|
this.bulkBaseModelValue = value;
|
||||||
|
},
|
||||||
// Create option groups for better organization
|
|
||||||
Object.entries(BASE_MODEL_CATEGORIES).forEach(([category, models]) => {
|
|
||||||
const optgroup = document.createElement('optgroup');
|
|
||||||
optgroup.label = category;
|
|
||||||
|
|
||||||
models.forEach(model => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = model;
|
|
||||||
option.textContent = model;
|
|
||||||
optgroup.appendChild(option);
|
|
||||||
});
|
|
||||||
|
|
||||||
select.appendChild(optgroup);
|
|
||||||
});
|
});
|
||||||
|
container.appendChild(this.bulkBaseModelPicker.element);
|
||||||
|
this.bulkBaseModelPicker.element.querySelector('.base-model-search-input')?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save bulk base model changes
|
* Save bulk base model changes
|
||||||
*/
|
*/
|
||||||
async saveBulkBaseModel() {
|
async saveBulkBaseModel() {
|
||||||
const select = document.getElementById('bulkBaseModelSelect');
|
const newBaseModel = (this.bulkBaseModelValue || this.bulkBaseModelPicker?.getValue() || '').trim();
|
||||||
if (!select || !select.value) {
|
if (!newBaseModel) {
|
||||||
showToast('toast.models.baseModelNotSelected', {}, 'warning');
|
showToast('toast.models.baseModelNotSelected', {}, 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newBaseModel = select.value;
|
|
||||||
const selectedCount = state.selectedModels.size;
|
const selectedCount = state.selectedModels.size;
|
||||||
|
|
||||||
if (selectedCount === 0) {
|
if (selectedCount === 0) {
|
||||||
@@ -1938,9 +1930,14 @@ export class BulkManager {
|
|||||||
* Cleanup bulk base model modal
|
* Cleanup bulk base model modal
|
||||||
*/
|
*/
|
||||||
cleanupBulkBaseModelModal() {
|
cleanupBulkBaseModelModal() {
|
||||||
const select = document.getElementById('bulkBaseModelSelect');
|
if (this.bulkBaseModelPicker) {
|
||||||
if (select) {
|
this.bulkBaseModelPicker.destroy();
|
||||||
select.innerHTML = '';
|
this.bulkBaseModelPicker = null;
|
||||||
|
}
|
||||||
|
this.bulkBaseModelValue = '';
|
||||||
|
const container = document.getElementById('bulkBaseModelPicker');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ export const BASE_MODELS = {
|
|||||||
UNKNOWN: "Other"
|
UNKNOWN: "Other"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Window event dispatched after dynamic base models are (re)loaded from the API.
|
||||||
|
// Pickers listen for it to refresh their option lists when data arrives late.
|
||||||
|
export const BASE_MODELS_UPDATED_EVENT = 'lora-manager:base-models-updated';
|
||||||
|
|
||||||
// Custom dataTransfer MIME type tagging internal model-card drags (move-to-folder).
|
// Custom dataTransfer MIME type tagging internal model-card drags (move-to-folder).
|
||||||
// Preview-drop handlers use it to ignore drags that did not come from the OS file system.
|
// Preview-drop handlers use it to ignore drags that did not come from the OS file system.
|
||||||
export const MODEL_CARD_DRAG_MIME_TYPE = 'application/x-lora-manager-model-card';
|
export const MODEL_CARD_DRAG_MIME_TYPE = 'application/x-lora-manager-model-card';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div id="bulkBaseModelModal" class="modal" style="display: none;">
|
<div id="bulkBaseModelModal" class="modal" style="display: none;">
|
||||||
<div class="modal-content modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>{{ t('modals.bulkBaseModel.title') }}</h2>
|
<h2>{{ t('modals.bulkBaseModel.title') }}</h2>
|
||||||
<span class="close" onclick="modalManager.closeModal('bulkBaseModelModal')">×</span>
|
<span class="close" onclick="modalManager.closeModal('bulkBaseModelModal')">×</span>
|
||||||
@@ -8,31 +8,18 @@
|
|||||||
<div class="bulk-add-tags-info">
|
<div class="bulk-add-tags-info">
|
||||||
<p>{{ t('modals.bulkBaseModel.description') }} <span id="bulkBaseModelCount">0</span> {{ t('modals.bulkBaseModel.models') }}</p>
|
<p>{{ t('modals.bulkBaseModel.description') }} <span id="bulkBaseModelCount">0</span> {{ t('modals.bulkBaseModel.models') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="bulk-base-model-label">{{ t('modals.bulkBaseModel.selectBaseModel') }}</label>
|
||||||
<div class="model-tags-container bulk-tags-container edit-mode">
|
<div id="bulkBaseModelPicker" class="bulk-base-model-picker">
|
||||||
<div class="metadata-edit-container" style="display: block;">
|
<!-- Picker will be initialized dynamically -->
|
||||||
<div class="metadata-edit-content">
|
|
||||||
<div class="metadata-edit-header">
|
|
||||||
<label>{{ t('modals.bulkBaseModel.selectBaseModel') }}</label>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control">
|
|
||||||
<div class="select-control">
|
|
||||||
<select id="bulkBaseModelSelect" class="bulk-base-model-select">
|
|
||||||
<!-- Options will be populated dynamically -->
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-edit-controls">
|
<div class="modal-actions bulk-base-model-footer">
|
||||||
<button class="metadata-save-btn bulk-save-base-model-btn" onclick="bulkManager.saveBulkBaseModel()">
|
<button class="cancel-btn" onclick="modalManager.closeModal('bulkBaseModelModal')">
|
||||||
<i class="fas fa-save"></i> {{ t('modals.bulkBaseModel.save') }}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" onclick="modalManager.closeModal('bulkBaseModelModal')">
|
|
||||||
{{ t('modals.bulkBaseModel.cancel') }}
|
{{ t('modals.bulkBaseModel.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button class="primary-btn bulk-save-base-model-btn" onclick="bulkManager.saveBulkBaseModel()">
|
||||||
</div>
|
<i class="fas fa-save"></i> {{ t('modals.bulkBaseModel.save') }}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createBaseModelPicker,
|
||||||
|
inferBaseModelsFromFilename,
|
||||||
|
inferBaseModelsFromFilepaths,
|
||||||
|
} from '../../../static/js/components/shared/BaseModelPicker.js';
|
||||||
|
import {
|
||||||
|
setDynamicBaseModels,
|
||||||
|
clearDynamicBaseModels,
|
||||||
|
BASE_MODELS_UPDATED_EVENT,
|
||||||
|
} from '../../../static/js/utils/constants.js';
|
||||||
|
|
||||||
|
// jsdom does not implement scrollIntoView
|
||||||
|
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || vi.fn();
|
||||||
|
|
||||||
|
const flushDebounce = () => new Promise((resolve) => setTimeout(resolve, 70));
|
||||||
|
|
||||||
|
function mountPicker(options = {}) {
|
||||||
|
const picker = createBaseModelPicker(options);
|
||||||
|
document.body.appendChild(picker.element);
|
||||||
|
return picker;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInput(picker) {
|
||||||
|
return picker.element.querySelector('.base-model-search-input');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDropdown(picker) {
|
||||||
|
return picker.element.querySelector('.base-model-dropdown');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemValues(picker) {
|
||||||
|
return Array.from(picker.element.querySelectorAll('.base-model-dropdown-item'))
|
||||||
|
.map((el) => el.dataset.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('inferBaseModelsFromFilepaths', () => {
|
||||||
|
it('returns an empty array for empty or invalid input', () => {
|
||||||
|
expect(inferBaseModelsFromFilepaths([])).toEqual([]);
|
||||||
|
expect(inferBaseModelsFromFilepaths(null)).toEqual([]);
|
||||||
|
expect(inferBaseModelsFromFilepaths(['/models/zzz_unknown.safetensors'])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates and sorts by hit count across selected paths', () => {
|
||||||
|
const result = inferBaseModelsFromFilepaths([
|
||||||
|
'/loras/flux1_dev_alpha.safetensors',
|
||||||
|
'/loras/another_flux_model.safetensors',
|
||||||
|
'C:\\models\\sdxl_style.safetensors',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Flux.1 D matched two paths, so it ranks first; entries are deduplicated
|
||||||
|
expect(result[0]).toBe('Flux.1 D');
|
||||||
|
expect(new Set(result).size).toBe(result.length);
|
||||||
|
expect(result).toContain('SDXL 1.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers base models from a single filename', () => {
|
||||||
|
expect(inferBaseModelsFromFilename('my_pony_lora.safetensors')).toContain('Pony');
|
||||||
|
expect(inferBaseModelsFromFilename('')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createBaseModelPicker', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearDynamicBaseModels();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearDynamicBaseModels();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups uncategorized dynamic models under "Other (API)"', () => {
|
||||||
|
setDynamicBaseModels(['MiniMax H3'], new Date().toISOString());
|
||||||
|
const picker = mountPicker();
|
||||||
|
|
||||||
|
const headers = Array.from(picker.element.querySelectorAll('.base-model-dropdown-header'));
|
||||||
|
const otherHeader = headers.find((el) => el.textContent === 'Other (API)');
|
||||||
|
expect(otherHeader).toBeTruthy();
|
||||||
|
|
||||||
|
const section = otherHeader.closest('.base-model-dropdown-section');
|
||||||
|
const values = Array.from(section.querySelectorAll('.base-model-dropdown-item'))
|
||||||
|
.map((el) => el.dataset.value);
|
||||||
|
expect(values).toContain('MiniMax H3');
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters options case-insensitively after the debounce', async () => {
|
||||||
|
setDynamicBaseModels(['MiniMax H3'], new Date().toISOString());
|
||||||
|
const picker = mountPicker();
|
||||||
|
|
||||||
|
const input = getInput(picker);
|
||||||
|
input.value = 'MINIMAX';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await flushDebounce();
|
||||||
|
|
||||||
|
expect(getItemValues(picker)).toEqual(['MiniMax H3']);
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the empty state when nothing matches', async () => {
|
||||||
|
const picker = mountPicker();
|
||||||
|
|
||||||
|
const input = getInput(picker);
|
||||||
|
input.value = 'no-such-model-xyz';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await flushDebounce();
|
||||||
|
|
||||||
|
expect(getItemValues(picker)).toEqual([]);
|
||||||
|
expect(getDropdown(picker).querySelector('.base-model-dropdown-empty')).toBeTruthy();
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commits immediately on item click in commit mode', () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const picker = mountPicker({ onCommit });
|
||||||
|
|
||||||
|
const item = Array.from(picker.element.querySelectorAll('.base-model-dropdown-item'))
|
||||||
|
.find((el) => el.dataset.value === 'SDXL 1.0');
|
||||||
|
item.click();
|
||||||
|
|
||||||
|
expect(onCommit).toHaveBeenCalledWith('SDXL 1.0');
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports keyboard navigation and Enter to commit the active item', () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const picker = mountPicker({ onCommit });
|
||||||
|
const input = getInput(picker);
|
||||||
|
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
||||||
|
|
||||||
|
const active = picker.element.querySelector('.base-model-dropdown-item.active');
|
||||||
|
expect(active).toBeTruthy();
|
||||||
|
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
expect(onCommit).toHaveBeenCalledWith(active.dataset.value);
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commits a custom typed value on Enter', () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const picker = mountPicker({ onCommit });
|
||||||
|
const input = getInput(picker);
|
||||||
|
|
||||||
|
input.value = 'My Custom Model';
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
|
||||||
|
expect(onCommit).toHaveBeenCalledWith('My Custom Model');
|
||||||
|
expect(picker.getValue()).toBe('My Custom Model');
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps typed text search-only on Enter when allowCustomValue is false', () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const picker = mountPicker({ onCommit, allowCustomValue: false });
|
||||||
|
const input = getInput(picker);
|
||||||
|
|
||||||
|
input.value = 'My Custom Model';
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
|
||||||
|
expect(onCommit).not.toHaveBeenCalled();
|
||||||
|
expect(picker.getValue()).toBe('');
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commits the typed custom value on outside click', async () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
const picker = mountPicker({ onCommit, onDismiss, initialValue: 'SD 1.5' });
|
||||||
|
const input = getInput(picker);
|
||||||
|
|
||||||
|
input.value = 'My Custom Model';
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
document.body.click();
|
||||||
|
|
||||||
|
expect(onCommit).toHaveBeenCalledWith('My Custom Model');
|
||||||
|
expect(onDismiss).not.toHaveBeenCalled();
|
||||||
|
expect(picker.getValue()).toBe('My Custom Model');
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses without committing typed search text on outside click when allowCustomValue is false', async () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
const picker = mountPicker({ onCommit, onDismiss, initialValue: 'SD 1.5', allowCustomValue: false });
|
||||||
|
const input = getInput(picker);
|
||||||
|
|
||||||
|
input.value = 'My Custom Model';
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
document.body.click();
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommit).not.toHaveBeenCalled();
|
||||||
|
expect(picker.getValue()).toBe('SD 1.5');
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses without committing on Escape', () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
const picker = mountPicker({ onCommit, onDismiss, initialValue: 'SD 1.5' });
|
||||||
|
const input = getInput(picker);
|
||||||
|
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommit).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes options when dynamic models arrive late and keeps the search text', async () => {
|
||||||
|
const picker = mountPicker();
|
||||||
|
const input = getInput(picker);
|
||||||
|
|
||||||
|
input.value = 'minimax';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await flushDebounce();
|
||||||
|
expect(getItemValues(picker)).toEqual([]);
|
||||||
|
|
||||||
|
// Dynamic models arrive after the picker is already open
|
||||||
|
setDynamicBaseModels(['MiniMax H3'], new Date().toISOString());
|
||||||
|
window.dispatchEvent(new CustomEvent(BASE_MODELS_UPDATED_EVENT));
|
||||||
|
|
||||||
|
expect(input.value).toBe('minimax');
|
||||||
|
expect(getItemValues(picker)).toEqual(['MiniMax H3']);
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops reacting to updates after destroy', () => {
|
||||||
|
const picker = mountPicker();
|
||||||
|
picker.destroy();
|
||||||
|
|
||||||
|
setDynamicBaseModels(['MiniMax H3'], new Date().toISOString());
|
||||||
|
window.dispatchEvent(new CustomEvent(BASE_MODELS_UPDATED_EVENT));
|
||||||
|
|
||||||
|
expect(getItemValues(picker)).not.toContain('MiniMax H3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders filename-based suggestions in a Suggested section', () => {
|
||||||
|
const suggestions = inferBaseModelsFromFilename('flux1_dev_model.safetensors');
|
||||||
|
const picker = mountPicker({ suggestions });
|
||||||
|
|
||||||
|
const suggestedHeader = picker.element.querySelector('.base-model-dropdown-header.suggested-header');
|
||||||
|
expect(suggestedHeader).toBeTruthy();
|
||||||
|
|
||||||
|
const section = suggestedHeader.closest('.base-model-dropdown-section');
|
||||||
|
const values = Array.from(section.querySelectorAll('.base-model-dropdown-item'))
|
||||||
|
.map((el) => el.dataset.value);
|
||||||
|
expect(values).toContain('Flux.1 D');
|
||||||
|
|
||||||
|
// Suggested entries are deduplicated out of the categorized sections
|
||||||
|
expect(getItemValues(picker).filter((v) => v === 'Flux.1 D')).toHaveLength(1);
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('change mode only notifies via onChange and tracks the value', () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const picker = mountPicker({ mode: 'change', onCommit, onChange });
|
||||||
|
|
||||||
|
const item = Array.from(picker.element.querySelectorAll('.base-model-dropdown-item'))
|
||||||
|
.find((el) => el.dataset.value === 'SDXL 1.0');
|
||||||
|
item.click();
|
||||||
|
|
||||||
|
expect(onCommit).not.toHaveBeenCalled();
|
||||||
|
expect(onChange).toHaveBeenCalledWith('SDXL 1.0');
|
||||||
|
expect(picker.getValue()).toBe('SDXL 1.0');
|
||||||
|
|
||||||
|
// The list collapses to the selected item instead of resetting to the
|
||||||
|
// full list (avoids a scroll jump in the bulk modal's inline list)
|
||||||
|
expect(getItemValues(picker)).toEqual(['SDXL 1.0']);
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats typed text as the live value in change mode', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const picker = mountPicker({ mode: 'change', onChange });
|
||||||
|
const input = getInput(picker);
|
||||||
|
|
||||||
|
input.value = 'Typed Custom';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await flushDebounce();
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith('Typed Custom');
|
||||||
|
expect(picker.getValue()).toBe('Typed Custom');
|
||||||
|
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
expect(picker.getValue()).toBe('Typed Custom');
|
||||||
|
// Custom values are not in the option list — the full list stays visible
|
||||||
|
expect(getItemValues(picker).length).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps typed text as search-only in change mode when allowCustomValue is false', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const picker = mountPicker({ mode: 'change', onChange, allowCustomValue: false });
|
||||||
|
const input = getInput(picker);
|
||||||
|
|
||||||
|
input.value = 'Typed Custom';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await flushDebounce();
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
expect(picker.getValue()).toBe('');
|
||||||
|
expect(getDropdown(picker).querySelector('.base-model-dropdown-empty')).toBeTruthy();
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setValue updates the input and selected marker', () => {
|
||||||
|
const picker = mountPicker({ mode: 'change' });
|
||||||
|
|
||||||
|
picker.setValue('SD 3.5');
|
||||||
|
|
||||||
|
expect(picker.getValue()).toBe('SD 3.5');
|
||||||
|
const selected = picker.element.querySelector('.base-model-dropdown-item.selected');
|
||||||
|
expect(selected?.dataset.value).toBe('SD 3.5');
|
||||||
|
|
||||||
|
picker.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
import { renderTemplate } from '../utils/domFixtures.js';
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
|
||||||
|
|
||||||
|
const loadingManagerStub = {
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
showCancelButton: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateStub = {
|
||||||
|
currentPageType: 'loras',
|
||||||
|
bulkMode: false,
|
||||||
|
selectedModels: new Set(),
|
||||||
|
loadingManager: loadingManagerStub,
|
||||||
|
virtualScroller: { updateSingleItem: vi.fn() },
|
||||||
|
global: { settings: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveModelMetadataMock = vi.fn();
|
||||||
|
const getModelApiClientMock = vi.fn(() => ({ saveModelMetadata: saveModelMetadataMock }));
|
||||||
|
const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true }));
|
||||||
|
const showModalMock = vi.fn();
|
||||||
|
const closeModalMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => ({
|
||||||
|
state: stateStub,
|
||||||
|
getCurrentPageState: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
sendLoraToWorkflow: vi.fn(),
|
||||||
|
sendEmbeddingToWorkflow: vi.fn(),
|
||||||
|
buildLoraSyntax: vi.fn(),
|
||||||
|
getNSFWLevelName: vi.fn(() => 'Unknown'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||||
|
getModelApiClient: getModelApiClientMock,
|
||||||
|
resetAndReload: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
|
RecipeSidebarApiClient: class {},
|
||||||
|
updateRecipeMetadata: updateRecipeMetadataMock,
|
||||||
|
extractRecipeId: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||||
|
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
|
||||||
|
MODEL_CONFIG: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||||
|
modalManager: { showModal: showModalMock, closeModal: closeModalMock },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
||||||
|
updateCardsForBulkMode: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: translateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/priorityTagHelpers.js', () => ({
|
||||||
|
getPriorityTagSuggestions: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({
|
||||||
|
getNsfwLevelSelector: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
setDynamicBaseModels,
|
||||||
|
clearDynamicBaseModels,
|
||||||
|
} from '../../../static/js/utils/constants.js';
|
||||||
|
|
||||||
|
// jsdom does not implement scrollIntoView
|
||||||
|
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || vi.fn();
|
||||||
|
|
||||||
|
function getPickerContainer() {
|
||||||
|
return document.getElementById('bulkBaseModelPicker');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickDropdownItem(value) {
|
||||||
|
const item = Array.from(document.querySelectorAll('.base-model-dropdown-item'))
|
||||||
|
.find((el) => el.dataset.value === value);
|
||||||
|
expect(item, `dropdown item for "${value}"`).toBeTruthy();
|
||||||
|
item.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BulkManager bulk base model', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
clearDynamicBaseModels();
|
||||||
|
stateStub.currentPageType = 'loras';
|
||||||
|
stateStub.bulkMode = false;
|
||||||
|
stateStub.selectedModels.clear();
|
||||||
|
saveModelMetadataMock.mockResolvedValue(undefined);
|
||||||
|
updateRecipeMetadataMock.mockResolvedValue({ success: true });
|
||||||
|
renderTemplate('components/modals/bulk_base_model_modal.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearDynamicBaseModels();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createBulkManager() {
|
||||||
|
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
|
||||||
|
return new BulkManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('warns when opening the modal without a selection', async () => {
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
bulk.showBulkBaseModelModal();
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.models.noModelsSelected', {}, 'warning');
|
||||||
|
expect(showModalMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes the picker inside the modal container', async () => {
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
stateStub.selectedModels.add('/models/sdxl_a.safetensors');
|
||||||
|
stateStub.selectedModels.add('/models/sdxl_b.safetensors');
|
||||||
|
|
||||||
|
bulk.showBulkBaseModelModal();
|
||||||
|
|
||||||
|
expect(document.getElementById('bulkBaseModelCount').textContent).toBe('2');
|
||||||
|
const container = getPickerContainer();
|
||||||
|
expect(container.querySelector('.base-model-search-wrapper')).toBeTruthy();
|
||||||
|
expect(bulk.bulkBaseModelPicker).toBeTruthy();
|
||||||
|
|
||||||
|
// Filename-based suggestions from the selected paths
|
||||||
|
const suggestedHeader = container.querySelector('.base-model-dropdown-header.suggested-header');
|
||||||
|
expect(suggestedHeader).toBeTruthy();
|
||||||
|
const suggestedSection = suggestedHeader.closest('.base-model-dropdown-section');
|
||||||
|
expect(suggestedSection.querySelector('.base-model-dropdown-item')?.dataset.value).toBe('SDXL 1.0');
|
||||||
|
|
||||||
|
bulk.cleanupBulkBaseModelModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers dynamic models such as MiniMax H3 under "Other (API)"', async () => {
|
||||||
|
setDynamicBaseModels(['MiniMax H3'], new Date().toISOString());
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
stateStub.selectedModels.add('/models/test.safetensors');
|
||||||
|
|
||||||
|
bulk.showBulkBaseModelModal();
|
||||||
|
|
||||||
|
const headers = Array.from(document.querySelectorAll('.base-model-dropdown-header'));
|
||||||
|
const otherHeader = headers.find((el) => el.textContent === 'Other (API)');
|
||||||
|
expect(otherHeader).toBeTruthy();
|
||||||
|
const section = otherHeader.closest('.base-model-dropdown-section');
|
||||||
|
const values = Array.from(section.querySelectorAll('.base-model-dropdown-item'))
|
||||||
|
.map((el) => el.dataset.value);
|
||||||
|
expect(values).toContain('MiniMax H3');
|
||||||
|
|
||||||
|
bulk.cleanupBulkBaseModelModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves a dynamic base model through the model API on model pages', async () => {
|
||||||
|
setDynamicBaseModels(['MiniMax H3'], new Date().toISOString());
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
stateStub.currentPageType = 'loras';
|
||||||
|
stateStub.selectedModels.add('/models/a.safetensors');
|
||||||
|
stateStub.selectedModels.add('/models/b.safetensors');
|
||||||
|
|
||||||
|
bulk.showBulkBaseModelModal();
|
||||||
|
clickDropdownItem('MiniMax H3');
|
||||||
|
expect(bulk.bulkBaseModelValue).toBe('MiniMax H3');
|
||||||
|
|
||||||
|
await bulk.saveBulkBaseModel();
|
||||||
|
|
||||||
|
expect(closeModalMock).toHaveBeenCalledWith('bulkBaseModelModal');
|
||||||
|
expect(saveModelMetadataMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(saveModelMetadataMock).toHaveBeenCalledWith('/models/a.safetensors', { base_model: 'MiniMax H3' });
|
||||||
|
expect(saveModelMetadataMock).toHaveBeenCalledWith('/models/b.safetensors', { base_model: 'MiniMax H3' });
|
||||||
|
expect(updateRecipeMetadataMock).not.toHaveBeenCalled();
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.models.bulkBaseModelUpdateSuccess',
|
||||||
|
{ count: 2 },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves through the recipe API when on the recipes page', async () => {
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
stateStub.currentPageType = 'recipes';
|
||||||
|
stateStub.selectedModels.add('/recipes/test.webp');
|
||||||
|
|
||||||
|
bulk.showBulkBaseModelModal();
|
||||||
|
clickDropdownItem('SD 1.5');
|
||||||
|
|
||||||
|
await bulk.saveBulkBaseModel();
|
||||||
|
|
||||||
|
expect(updateRecipeMetadataMock).toHaveBeenCalledWith('/recipes/test.webp', { base_model: 'SD 1.5' });
|
||||||
|
expect(updateRecipeMetadataMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(saveModelMetadataMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns and skips saving when no base model is selected', async () => {
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
stateStub.selectedModels.add('/models/a.safetensors');
|
||||||
|
|
||||||
|
bulk.showBulkBaseModelModal();
|
||||||
|
await bulk.saveBulkBaseModel();
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.models.baseModelNotSelected', {}, 'warning');
|
||||||
|
expect(saveModelMetadataMock).not.toHaveBeenCalled();
|
||||||
|
expect(closeModalMock).not.toHaveBeenCalledWith('bulkBaseModelModal');
|
||||||
|
|
||||||
|
bulk.cleanupBulkBaseModelModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts arbitrary typed values in bulk mode, matching the single-model modal', async () => {
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
stateStub.selectedModels.add('/models/a.safetensors');
|
||||||
|
|
||||||
|
bulk.showBulkBaseModelModal();
|
||||||
|
const input = document.querySelector('#bulkBaseModelPicker .base-model-search-input');
|
||||||
|
input.value = 'Not A Listed Model';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 70));
|
||||||
|
|
||||||
|
expect(bulk.bulkBaseModelValue).toBe('Not A Listed Model');
|
||||||
|
|
||||||
|
await bulk.saveBulkBaseModel();
|
||||||
|
|
||||||
|
expect(saveModelMetadataMock).toHaveBeenCalledWith('/models/a.safetensors', { base_model: 'Not A Listed Model' });
|
||||||
|
expect(closeModalMock).toHaveBeenCalledWith('bulkBaseModelModal');
|
||||||
|
|
||||||
|
bulk.cleanupBulkBaseModelModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a dedicated layout without the settings-page control wrapper', () => {
|
||||||
|
const modal = document.getElementById('bulkBaseModelModal');
|
||||||
|
expect(modal).toBeTruthy();
|
||||||
|
expect(modal.querySelector('.setting-control')).toBeNull();
|
||||||
|
expect(modal.querySelector('.metadata-edit-container')).toBeNull();
|
||||||
|
const footer = modal.querySelector('.bulk-base-model-footer');
|
||||||
|
expect(footer).toBeTruthy();
|
||||||
|
// Buttons follow the app-wide modal-actions convention
|
||||||
|
expect(footer.classList.contains('modal-actions')).toBe(true);
|
||||||
|
expect(footer.querySelector('.primary-btn.bulk-save-base-model-btn')).toBeTruthy();
|
||||||
|
expect(footer.querySelector('.cancel-btn')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses the search input when the modal opens', async () => {
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
stateStub.selectedModels.add('/models/a.safetensors');
|
||||||
|
|
||||||
|
bulk.showBulkBaseModelModal();
|
||||||
|
|
||||||
|
const input = document.querySelector('#bulkBaseModelPicker .base-model-search-input');
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
|
||||||
|
bulk.cleanupBulkBaseModelModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports partial failures', async () => {
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
stateStub.selectedModels.add('/models/ok.safetensors');
|
||||||
|
stateStub.selectedModels.add('/models/fail.safetensors');
|
||||||
|
saveModelMetadataMock
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
|
||||||
|
bulk.showBulkBaseModelModal();
|
||||||
|
clickDropdownItem('SDXL 1.0');
|
||||||
|
await bulk.saveBulkBaseModel();
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.models.bulkBaseModelUpdatePartial',
|
||||||
|
{ success: 1, failed: 1 },
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroys the picker and clears the staged value on cleanup', async () => {
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
stateStub.selectedModels.add('/models/a.safetensors');
|
||||||
|
|
||||||
|
bulk.showBulkBaseModelModal();
|
||||||
|
clickDropdownItem('SDXL 1.0');
|
||||||
|
expect(bulk.bulkBaseModelValue).toBe('SDXL 1.0');
|
||||||
|
|
||||||
|
bulk.cleanupBulkBaseModelModal();
|
||||||
|
|
||||||
|
expect(bulk.bulkBaseModelPicker).toBeNull();
|
||||||
|
expect(bulk.bulkBaseModelValue).toBe('');
|
||||||
|
expect(getPickerContainer().innerHTML).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user