diff --git a/static/css/components/bulk.css b/static/css/components/bulk.css index 4f809ba6..35d01a7c 100644 --- a/static/css/components/bulk.css +++ b/static/css/components/bulk.css @@ -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); } \ No newline at end of file diff --git a/static/js/components/shared/BaseModelPicker.js b/static/js/components/shared/BaseModelPicker.js new file mode 100644 index 00000000..964ec91b --- /dev/null +++ b/static/js/components/shared/BaseModelPicker.js @@ -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 = ' ' + + 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 }; +} diff --git a/static/js/components/shared/ModelMetadata.js b/static/js/components/shared/ModelMetadata.js index b8b60486..1a957c47 100644 --- a/static/js/components/shared/ModelMetadata.js +++ b/static/js/components/shared/ModelMetadata.js @@ -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 = ' ' + - 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(); }); } diff --git a/static/js/core.js b/static/js/core.js index a1221802..f7a901f8 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -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) { diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index e67393aa..7bdc6286 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -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 = ''; } } diff --git a/static/js/utils/constants.js b/static/js/utils/constants.js index 97f83b17..f29f7522 100644 --- a/static/js/utils/constants.js +++ b/static/js/utils/constants.js @@ -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'; diff --git a/templates/components/modals/bulk_base_model_modal.html b/templates/components/modals/bulk_base_model_modal.html index 1395b93b..14ec82b4 100644 --- a/templates/components/modals/bulk_base_model_modal.html +++ b/templates/components/modals/bulk_base_model_modal.html @@ -1,5 +1,5 @@