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", "additionalNotes": "Additional Notes",
"notesHint": "Press Enter to save, Shift+Enter for new line", "notesHint": "Press Enter to save, Shift+Enter for new line",
"addNotesPlaceholder": "Add your notes here...", "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": { "notes": {
"saved": "Notes saved successfully", "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; flex: 1;
} }
.base-model-selector { /* ── Base Model Search Dropdown ─────────────────────────────────────────── */
width: 100%;
padding: 3px 5px; .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); background: var(--bg-color);
border: 1px solid var(--lora-accent); border: 1px solid var(--lora-accent);
border-radius: var(--border-radius-xs); 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); color: var(--text-color);
font-size: 0.9em; font-size: 0.9em;
outline: none; padding: 3px 0;
margin-right: var(--space-1); 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 { .size-wrapper {

View File

@@ -6,6 +6,72 @@
import { BASE_MODEL_CATEGORIES, getMergedBaseModels } from '../../utils/constants.js'; import { BASE_MODEL_CATEGORIES, getMergedBaseModels } from '../../utils/constants.js';
import { showToast } from '../../utils/uiHelpers.js'; import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.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. * 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 * @param {string} filePath - File path
*/ */
export function setupBaseModelEditing(filePath) { export function setupBaseModelEditing(filePath) {
@@ -257,116 +325,251 @@ export function setupBaseModelEditing(filePath) {
// Store the original value to check for changes later // Store the original value to check for changes later
const originalValue = baseModelContent.textContent.trim(); const originalValue = baseModelContent.textContent.trim();
// Create dropdown selector to replace the base model content // ── Build the full option list ────────────────────────────────────────
const currentValue = originalValue; const allModels = []; // { value, label, category }
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;
const categorizedModels = new Set(); const categorizedModels = new Set();
// Create option groups for better organization Object.entries(BASE_MODEL_CATEGORIES).forEach(([category, models]) => {
Object.entries(baseModelCategories).forEach(([category, models]) => {
const group = document.createElement('optgroup');
group.label = category;
models.forEach(model => { models.forEach(model => {
const option = document.createElement('option'); allModels.push({ value: model, label: model, category });
option.value = model;
option.textContent = model;
if (model === currentValue) option.selected = true;
categorizedModels.add(model); 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 mergedModels = getMergedBaseModels();
const uncategorizedModels = mergedModels.filter(model => !categorizedModels.has(model)); const uncategorizedModels = mergedModels.filter(model => !categorizedModels.has(model));
if (uncategorizedModels.length > 0) { if (uncategorizedModels.length > 0) {
const group = document.createElement('optgroup');
group.label = 'Other (API)';
uncategorizedModels.forEach(model => { uncategorizedModels.forEach(model => {
const option = document.createElement('option'); allModels.push({ value: model, label: model, category: 'Other (API)' });
option.value = model;
option.textContent = model;
if (model === currentValue) option.selected = true;
group.appendChild(option);
}); });
dropdown.appendChild(group);
} }
// Replace content with dropdown // ── Filename-based inference ──────────────────────────────────────────
baseModelContent.style.display = 'none'; const fileName = (document.querySelector('.file-name-content')?.textContent || '') + ' ' +
baseModelDisplay.insertBefore(dropdown, editBtn); (document.querySelector('.model-name-content')?.textContent || '');
const inferredModels = inferBaseModelsFromFilename(fileName);
const inferredSet = new Set(inferredModels);
// Hide edit button during editing // ── Build search widget DOM ───────────────────────────────────────────
editBtn.style.display = 'none'; const wrapper = document.createElement('div');
wrapper.className = 'base-model-search-wrapper';
// Focus the dropdown // Search input row
dropdown.focus(); 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 list
dropdown.addEventListener('change', function() { const dropdown = document.createElement('div');
const selectedModel = this.value; dropdown.className = 'base-model-dropdown';
baseModelContent.textContent = selectedModel; wrapper.appendChild(dropdown);
// ── Render ────────────────────────────────────────────────────────────
function renderDropdown(filterText) {
const lowerFilter = (filterText || '').toLowerCase().trim();
dropdown.innerHTML = '';
let hasVisibleItems = false;
const fragment = document.createDocumentFragment();
// Mark that a change was made if the value differs from original // 1. Suggested section (filename-inferred, filtered by search)
if (selectedModel !== originalValue) { let suggestedToShow = inferredModels;
valueChanged = true; if (lowerFilter) {
} else { suggestedToShow = inferredModels.filter(m =>
valueChanged = false; 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 // Replace content with search widget
const saveAndExit = function() { baseModelContent.style.display = 'none';
// Check if dropdown still exists and remove it editBtn.style.display = 'none';
if (dropdown && dropdown.parentNode === baseModelDisplay) { baseModelDisplay.insertBefore(wrapper, editBtn);
baseModelDisplay.removeChild(dropdown); searchInput.focus();
// ── Cleanup ───────────────────────────────────────────────────────────
function cleanup() {
if (wrapper.parentNode === baseModelDisplay) {
baseModelDisplay.removeChild(wrapper);
} }
// Show the content and edit button
baseModelContent.style.display = ''; baseModelContent.style.display = '';
editBtn.style.display = ''; editBtn.style.display = '';
// Remove editing class
baseModelDisplay.classList.remove('editing'); 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); document.removeEventListener('click', outsideClickHandler);
}; }
// Handle outside clicks to save and exit // Outside click save typed/custom value if any
const outsideClickHandler = function(e) { const outsideClickHandler = function(e) {
// If click is outside the dropdown and base model display if (wrapper.contains(e.target)) return;
if (!baseModelDisplay.contains(e.target)) {
saveAndExit(); // 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(() => { setTimeout(() => {
document.addEventListener('click', outsideClickHandler); document.addEventListener('click', outsideClickHandler);
}, 0); }, 0);
// Also handle dropdown blur event // Keyboard navigation
dropdown.addEventListener('blur', function(e) { searchInput.addEventListener('keydown', function onKeydown(e) {
// Only save if the related target is not the edit button or inside the baseModelDisplay const items = Array.from(dropdown.querySelectorAll('.base-model-dropdown-item'));
if (!baseModelDisplay.contains(e.relatedTarget)) { const activeIdx = items.findIndex(el => el.classList.contains('active'));
saveAndExit();
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();
} }
}); });
}); });