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
@@ -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();
});
}