feat(ui): add searchable base model dropdown with filename inference in model modal

Replace native <select> with a searchable dropdown that:
- Filters options as the user types
- Shows filename-inferred suggestions at the top in a "Suggested" section
- Supports keyboard navigation (ArrowUp/Down/Enter/Escape)
- Allows typing custom values not in the list
- Removes dead .base-model-selector CSS

Adds 3 new i18n keys (baseModelSearchPlaceholder, baseModelSuggested,
baseModelNoMatch) with translations for all 9 locales.
This commit is contained in:
Will Miao
2026-07-01 14:31:08 +08:00
parent 8b344ea39f
commit fe90f7f9b1
12 changed files with 19747 additions and 19369 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1347,7 +1347,10 @@
"additionalNotes": "Additional Notes",
"notesHint": "Press Enter to save, Shift+Enter for new line",
"addNotesPlaceholder": "Add your notes here...",
"aboutThisVersion": "About this version"
"aboutThisVersion": "About this version",
"baseModelSearchPlaceholder": "Search base model…",
"baseModelSuggested": "Suggested",
"baseModelNoMatch": "No matching base models"
},
"notes": {
"saved": "Notes saved successfully",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -444,16 +444,161 @@
flex: 1;
}
.base-model-selector {
width: 100%;
padding: 3px 5px;
/* ── Base Model Search Dropdown ─────────────────────────────────────────── */
.base-model-search-wrapper {
position: relative;
flex: 1;
min-width: 0;
z-index: 100;
}
.base-model-search-input-wrapper {
display: flex;
align-items: center;
background: var(--bg-color);
border: 1px solid var(--lora-accent);
border-radius: var(--border-radius-xs);
padding: 0 6px;
gap: 4px;
}
.base-model-search-input-wrapper .search-icon {
color: var(--text-color);
opacity: 0.45;
font-size: 12px;
flex-shrink: 0;
pointer-events: none;
/* Reset global .search-icon rules from search-filter.css */
position: static;
right: auto;
top: auto;
transform: none;
}
.base-model-search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-color);
font-size: 0.9em;
outline: none;
margin-right: var(--space-1);
padding: 3px 0;
width: 100%;
min-width: 0;
}
.base-model-search-input::placeholder {
color: var(--text-color);
opacity: 0.35;
}
.base-model-dropdown {
position: absolute;
top: 100%;
left: -1px;
right: -1px;
max-height: 270px;
overflow-y: auto;
background: var(--bg-color);
border: 1px solid var(--lora-border);
border-top: none;
border-radius: 0 0 var(--border-radius-xs) var(--border-radius-xs);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22);
z-index: 101;
}
[data-theme="dark"] .base-model-dropdown {
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
}
/* Dropdown scrollbar styling */
.base-model-dropdown::-webkit-scrollbar {
width: 6px;
}
.base-model-dropdown::-webkit-scrollbar-thumb {
background: var(--lora-border);
border-radius: 3px;
}
.base-model-dropdown::-webkit-scrollbar-track {
background: transparent;
}
/* Section */
.base-model-dropdown-section {
border-bottom: 1px solid var(--lora-border);
}
.base-model-dropdown-section:last-child {
border-bottom: none;
}
/* Section header */
.base-model-dropdown-header {
padding: 5px 10px;
font-size: 0.72em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-color);
opacity: 0.5;
background: var(--surface-subtle);
position: sticky;
top: 0;
z-index: 1;
}
.base-model-dropdown-header.suggested-header {
color: var(--lora-accent);
opacity: 1;
background: oklch(from var(--lora-accent) l c h / 0.08);
}
.base-model-dropdown-header.suggested-header i {
margin-right: 4px;
font-size: 0.85em;
}
/* Dropdown items */
.base-model-dropdown-item {
padding: 5px 12px;
cursor: pointer;
font-size: 0.9em;
color: var(--text-color);
transition: background 0.1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.base-model-dropdown-item:hover {
background: oklch(from var(--lora-accent) l c h / 0.1);
}
.base-model-dropdown-item.active {
background: oklch(from var(--lora-accent) l c h / 0.16);
}
.base-model-dropdown-item.selected {
font-weight: 600;
}
.base-model-dropdown-item.selected::after {
content: '✓';
float: right;
color: var(--lora-accent);
margin-left: 8px;
}
/* Empty state */
.base-model-dropdown-empty {
padding: 18px 12px;
text-align: center;
color: var(--text-color);
opacity: 0.4;
font-size: 0.88em;
}
.size-wrapper {

View File

@@ -6,6 +6,72 @@
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;
}
/**
* Resolve the active file path for the currently open model modal.
@@ -226,7 +292,9 @@ export function setupModelNameEditing(filePath) {
}
/**
* Set up base model editing functionality
* Set up base model editing functionality with searchable dropdown
* Shows filename-inferred suggestions at the top, supports keyboard navigation,
* and allows typing custom values.
* @param {string} filePath - File path
*/
export function setupBaseModelEditing(filePath) {
@@ -257,116 +325,251 @@ export function setupBaseModelEditing(filePath) {
// Store the original value to check for changes later
const originalValue = baseModelContent.textContent.trim();
// Create dropdown selector to replace the base model content
const currentValue = originalValue;
const dropdown = document.createElement('select');
dropdown.className = 'base-model-selector';
// Flag to track if a change was made
let valueChanged = false;
// Add options from BASE_MODEL_CATEGORIES constants
const baseModelCategories = BASE_MODEL_CATEGORIES;
// ── Build the full option list ────────────────────────────────────────
const allModels = []; // { value, label, category }
const categorizedModels = new Set();
// Create option groups for better organization
Object.entries(baseModelCategories).forEach(([category, models]) => {
const group = document.createElement('optgroup');
group.label = category;
Object.entries(BASE_MODEL_CATEGORIES).forEach(([category, models]) => {
models.forEach(model => {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
if (model === currentValue) option.selected = true;
allModels.push({ value: model, label: model, category });
categorizedModels.add(model);
group.appendChild(option);
});
dropdown.appendChild(group);
});
// Check for dynamic base models from API that aren't in any category
const mergedModels = getMergedBaseModels();
const uncategorizedModels = mergedModels.filter(model => !categorizedModels.has(model));
if (uncategorizedModels.length > 0) {
const group = document.createElement('optgroup');
group.label = 'Other (API)';
uncategorizedModels.forEach(model => {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
if (model === currentValue) option.selected = true;
group.appendChild(option);
allModels.push({ value: model, label: model, category: 'Other (API)' });
});
dropdown.appendChild(group);
}
// Replace content with dropdown
baseModelContent.style.display = 'none';
baseModelDisplay.insertBefore(dropdown, editBtn);
// ── Filename-based inference ──────────────────────────────────────────
const fileName = (document.querySelector('.file-name-content')?.textContent || '') + ' ' +
(document.querySelector('.model-name-content')?.textContent || '');
const inferredModels = inferBaseModelsFromFilename(fileName);
const inferredSet = new Set(inferredModels);
// Hide edit button during editing
editBtn.style.display = 'none';
// ── Build search widget DOM ───────────────────────────────────────────
const wrapper = document.createElement('div');
wrapper.className = 'base-model-search-wrapper';
// Focus the dropdown
dropdown.focus();
// 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);
// Handle dropdown change
dropdown.addEventListener('change', function() {
const selectedModel = this.value;
baseModelContent.textContent = selectedModel;
// Dropdown list
const dropdown = document.createElement('div');
dropdown.className = 'base-model-dropdown';
wrapper.appendChild(dropdown);
// Mark that a change was made if the value differs from original
if (selectedModel !== originalValue) {
valueChanged = true;
} else {
valueChanged = false;
// ── 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
);
}
});
// Function to save changes and exit edit mode
const saveAndExit = function() {
// Check if dropdown still exists and remove it
if (dropdown && dropdown.parentNode === baseModelDisplay) {
baseModelDisplay.removeChild(dropdown);
}
// Replace content with search widget
baseModelContent.style.display = 'none';
editBtn.style.display = 'none';
baseModelDisplay.insertBefore(wrapper, editBtn);
searchInput.focus();
// Show the content and edit button
// ── Cleanup ───────────────────────────────────────────────────────────
function cleanup() {
if (wrapper.parentNode === baseModelDisplay) {
baseModelDisplay.removeChild(wrapper);
}
baseModelContent.style.display = '';
editBtn.style.display = '';
// Remove editing class
baseModelDisplay.classList.remove('editing');
// Only save if the value has actually changed
if (valueChanged || baseModelContent.textContent.trim() !== originalValue) {
const resolvedPath = getActiveModalFilePath(baseModelContent.dataset.filePath);
saveBaseModel(resolvedPath, originalValue);
}
// Remove this event listener
document.removeEventListener('click', outsideClickHandler);
};
}
// Handle outside clicks to save and exit
// Outside click save typed/custom value if any
const outsideClickHandler = function(e) {
// If click is outside the dropdown and base model display
if (!baseModelDisplay.contains(e.target)) {
saveAndExit();
if (wrapper.contains(e.target)) return;
// If user typed a custom value (not just empty), apply it
const typedValue = searchInput.value.trim();
if (typedValue) {
baseModelContent.textContent = typedValue;
}
cleanup();
const finalValue = baseModelContent.textContent.trim();
if (finalValue !== originalValue) {
saveBaseModel(
getActiveModalFilePath(baseModelContent.dataset.filePath),
originalValue
);
}
};
// Add delayed event listener for outside clicks
// Defer listener to avoid the opening click itself
setTimeout(() => {
document.addEventListener('click', outsideClickHandler);
}, 0);
// Also handle dropdown blur event
dropdown.addEventListener('blur', function(e) {
// Only save if the related target is not the edit button or inside the baseModelDisplay
if (!baseModelDisplay.contains(e.relatedTarget)) {
saveAndExit();
// 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();
baseModelContent.textContent = originalValue;
cleanup();
}
});
});