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:
@@ -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
|
||||
*/
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user