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:
Will Miao
2026-08-27 18:07:28 +08:00
parent df34efafbc
commit 2bfb987312
9 changed files with 1228 additions and 382 deletions
+100 -21
View File
@@ -49,34 +49,113 @@
-ms-user-select: none;
}
/* Remove bulk base model modal specific styles - now using shared components */
/* Use shared metadata editing styles instead */
/* ── Bulk base model modal — dedicated inline-list layout ───────────────
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 */
.bulk-base-model-select {
#bulkBaseModelModal .modal-content {
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%;
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: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 0.95em;
height: 32px;
box-shadow: none;
z-index: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: var(--lora-border) transparent;
}
.bulk-base-model-select:focus {
border-color: var(--lora-accent);
outline: none;
#bulkBaseModelModal .base-model-dropdown::-webkit-scrollbar {
width: 8px;
}
/* Dark theme support for bulk base model select */
[data-theme="dark"] .bulk-base-model-select {
background-color: rgba(30, 30, 30, 0.9);
color: var(--text-color);
#bulkBaseModelModal .base-model-dropdown::-webkit-scrollbar-thumb {
background: var(--lora-border);
border-radius: 4px;
}
[data-theme="dark"] .bulk-base-model-select option {
background-color: #2d2d2d;
color: var(--text-color);
#bulkBaseModelModal .base-model-dropdown::-webkit-scrollbar-track {
background: transparent;
}
/* 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 };
}
+36 -302
View File
@@ -3,75 +3,9 @@
* Handles model metadata editing functionality - General version
*/
import { BASE_MODEL_CATEGORIES, getMergedBaseModels } from '../../utils/constants.js';
import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.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.
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;
}
import { inferBaseModelsFromFilename, createBaseModelPicker } from './BaseModelPicker.js';
/**
* Resolve the active file path for the currently open model modal.
@@ -321,199 +255,16 @@ export function setupBaseModelEditing(filePath) {
// Handle edit button click
editBtn.addEventListener('click', () => {
baseModelDisplay.classList.add('editing');
// Store the original value to check for changes later
const originalValue = baseModelContent.textContent.trim();
// ── Build the full option list ────────────────────────────────────────
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 ──────────────────────────────────────────
// Filename-based inference for the Suggested section
const fileName = (document.querySelector('.file-name-content')?.textContent || '') + ' ' +
(document.querySelector('.model-name-content')?.textContent || '');
const inferredModels = inferBaseModelsFromFilename(fileName);
const inferredSet = new Set(inferredModels);
// ── Build search widget DOM ───────────────────────────────────────────
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 saveIfChanged = () => {
const finalValue = baseModelContent.textContent.trim();
if (finalValue !== originalValue) {
saveBaseModel(
@@ -522,56 +273,39 @@ export function setupBaseModelEditing(filePath) {
);
}
};
// Defer listener to avoid the opening click itself
setTimeout(() => {
document.addEventListener('click', outsideClickHandler);
}, 0);
// Keyboard navigation
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();
const finalValue = baseModelContent.textContent.trim();
if (finalValue !== originalValue) {
saveBaseModel(
getActiveModalFilePath(baseModelContent.dataset.filePath),
originalValue
);
}
}
} else if (e.key === 'Escape') {
e.preventDefault();
const picker = createBaseModelPicker({
suggestions: inferredModels,
initialValue: originalValue,
mode: 'commit',
onCommit: (value) => {
baseModelContent.textContent = value;
cleanup();
saveIfChanged();
},
onDismiss: () => {
// Escape or empty outside click: restore the original value
baseModelContent.textContent = originalValue;
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
View File
@@ -20,7 +20,7 @@ import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js';
import { createPageContextMenu, createGlobalContextMenu } from './components/ContextMenu/index.js';
import { initializeEventManagement } from './utils/eventManagementInit.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
export class AppCore {
@@ -134,6 +134,7 @@ export class AppCore {
const result = await civitaiBaseModelApi.getBaseModels();
if (result && result.models) {
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)`);
}
} catch (error) {
+30 -33
View File
@@ -6,7 +6,7 @@ import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.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 { eventManager } from '../utils/EventManager.js';
import { translate } from '../utils/i18nHelpers.js';
@@ -30,6 +30,10 @@ export class BulkManager {
// toggleCardSelection, cleared in clearSelection.
this.bulkAnchorFilepath = null;
// Bulk base model picker state
this.bulkBaseModelPicker = null;
this.bulkBaseModelValue = '';
// Drag detection properties
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
@@ -1830,47 +1834,35 @@ export class BulkManager {
* Initialize bulk base model interface
*/
initializeBulkBaseModelInterface() {
const select = document.getElementById('bulkBaseModelSelect');
if (!select) return;
const container = document.getElementById('bulkBaseModelPicker');
if (!container) return;
// Clear existing options
select.innerHTML = '';
// Reset any previous picker instance
this.cleanupBulkBaseModelModal();
container.innerHTML = '';
// Add placeholder option
const placeholderOption = document.createElement('option');
placeholderOption.value = '';
placeholderOption.textContent = 'Select a base model...';
placeholderOption.disabled = true;
placeholderOption.selected = true;
select.appendChild(placeholderOption);
// 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);
const suggestions = inferBaseModelsFromFilepaths(Array.from(state.selectedModels));
this.bulkBaseModelValue = '';
this.bulkBaseModelPicker = createBaseModelPicker({
suggestions,
mode: 'change',
onChange: (value) => {
this.bulkBaseModelValue = value;
},
});
container.appendChild(this.bulkBaseModelPicker.element);
this.bulkBaseModelPicker.element.querySelector('.base-model-search-input')?.focus();
}
/**
* Save bulk base model changes
*/
async saveBulkBaseModel() {
const select = document.getElementById('bulkBaseModelSelect');
if (!select || !select.value) {
const newBaseModel = (this.bulkBaseModelValue || this.bulkBaseModelPicker?.getValue() || '').trim();
if (!newBaseModel) {
showToast('toast.models.baseModelNotSelected', {}, 'warning');
return;
}
const newBaseModel = select.value;
const selectedCount = state.selectedModels.size;
if (selectedCount === 0) {
@@ -1938,9 +1930,14 @@ export class BulkManager {
* Cleanup bulk base model modal
*/
cleanupBulkBaseModelModal() {
const select = document.getElementById('bulkBaseModelSelect');
if (select) {
select.innerHTML = '';
if (this.bulkBaseModelPicker) {
this.bulkBaseModelPicker.destroy();
this.bulkBaseModelPicker = null;
}
this.bulkBaseModelValue = '';
const container = document.getElementById('bulkBaseModelPicker');
if (container) {
container.innerHTML = '';
}
}
+4
View File
@@ -87,6 +87,10 @@ export const BASE_MODELS = {
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).
// 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';
@@ -1,5 +1,5 @@
<div id="bulkBaseModelModal" class="modal" style="display: none;">
<div class="modal-content modal-content">
<div class="modal-content">
<div class="modal-header">
<h2>{{ t('modals.bulkBaseModel.title') }}</h2>
<span class="close" onclick="modalManager.closeModal('bulkBaseModelModal')">&times;</span>
@@ -8,31 +8,18 @@
<div class="bulk-add-tags-info">
<p>{{ t('modals.bulkBaseModel.description') }} <span id="bulkBaseModelCount">0</span> {{ t('modals.bulkBaseModel.models') }}</p>
</div>
<div class="model-tags-container bulk-tags-container edit-mode">
<div class="metadata-edit-container" style="display: block;">
<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 class="metadata-edit-controls">
<button class="metadata-save-btn bulk-save-base-model-btn" onclick="bulkManager.saveBulkBaseModel()">
<i class="fas fa-save"></i> {{ t('modals.bulkBaseModel.save') }}
</button>
<button class="btn btn-secondary" onclick="modalManager.closeModal('bulkBaseModelModal')">
{{ t('modals.bulkBaseModel.cancel') }}
</button>
</div>
</div>
</div>
<label class="bulk-base-model-label">{{ t('modals.bulkBaseModel.selectBaseModel') }}</label>
<div id="bulkBaseModelPicker" class="bulk-base-model-picker">
<!-- Picker will be initialized dynamically -->
</div>
</div>
<div class="modal-actions bulk-base-model-footer">
<button class="cancel-btn" onclick="modalManager.closeModal('bulkBaseModelModal')">
{{ t('modals.bulkBaseModel.cancel') }}
</button>
<button class="primary-btn bulk-save-base-model-btn" onclick="bulkManager.saveBulkBaseModel()">
<i class="fas fa-save"></i> {{ t('modals.bulkBaseModel.save') }}
</button>
</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('');
});
});