feat(ui): searchable model picker with provider group headers — v0.50.86 (PR #659 by @mmartial)
- Live search input in model dropdown (filter by name or ID) - Provider group headers preserved in filtered view - Clear button, Escape-to-close, No models found empty state - i18n EN/ES/zh-CN strings - CSS uses var(--accent) consistent with current theme system - zh-CN double-escape fix included - Provider headers regression fix included - 1423 tests pass Co-authored-by: mmartial <mmartial@users.noreply.github.com>
This commit is contained in:
@@ -58,8 +58,9 @@ const LOCALES = {
|
||||
provider_mismatch_label: 'Provider mismatch',
|
||||
model_custom_label: 'Custom model ID',
|
||||
model_custom_placeholder: 'e.g. openai/gpt-5.4',
|
||||
model_search_placeholder: 'Search models…',
|
||||
model_search_no_results: 'No models found',
|
||||
// commands.js
|
||||
cmd_help: 'List available commands',
|
||||
cmd_clear: 'Clear conversation messages',
|
||||
cmd_compress: 'Manually compress conversation context (usage: /compress [focus topic])',
|
||||
cmd_compact_alias: 'Legacy alias for /compress',
|
||||
@@ -481,6 +482,8 @@ const LOCALES = {
|
||||
provider_mismatch_label: 'Proveedor incompatible',
|
||||
model_custom_label: 'ID de modelo personalizado',
|
||||
model_custom_placeholder: 'p. ej. openai/gpt-5.4',
|
||||
model_search_placeholder: 'Buscar modelos…',
|
||||
model_search_no_results: 'No se encontraron modelos',
|
||||
// commands.js
|
||||
cmd_help: 'Listar los comandos disponibles',
|
||||
cmd_clear: 'Borrar los mensajes de la conversación',
|
||||
@@ -1115,6 +1118,8 @@ const LOCALES = {
|
||||
provider_mismatch_label: '\u63d0\u4f9b\u5546\u4e0d\u5339\u914d',
|
||||
model_custom_label: '\u81ea\u5b9a\u4e49\u6a21\u578b ID',
|
||||
model_custom_placeholder: '\u4f8b\u5982 openai/gpt-5.4',
|
||||
model_search_placeholder: '\u641c\u7d22\u6a21\u578b\u2026',
|
||||
model_search_no_results: '\u672a\u627e\u5230\u6a21\u578b',
|
||||
// commands.js
|
||||
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
|
||||
cmd_clear: '\u6e05\u7a7a\u5f53\u524d\u5bf9\u8bdd\u6d88\u606f',
|
||||
|
||||
@@ -592,7 +592,7 @@
|
||||
<div class="settings-section-title">System</div>
|
||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||
</div>
|
||||
<span class="settings-version-badge">v0.50.85</span>
|
||||
<span class="settings-version-badge">v0.50.86</span>
|
||||
</div>
|
||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||
|
||||
@@ -775,6 +775,11 @@
|
||||
.model-custom-sep{padding-top:4px;border-top:1px solid var(--border);margin-top:4px;}
|
||||
.model-custom-row{display:flex;align-items:center;gap:6px;padding:6px 10px 8px;}
|
||||
.model-custom-input{flex:1;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;font-family:inherit;min-width:0;}
|
||||
.model-search-row{display:flex;align-items:center;gap:6px;padding:8px 10px 10px;}
|
||||
.model-search-input{flex:1;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:6px 8px;font-size:12px;outline:none;font-family:inherit;min-width:0;}
|
||||
.model-search-input:focus{border-color:var(--accent);}
|
||||
.model-search-clear{flex-shrink:0;width:22px;height:22px;border:1px solid var(--border2);border-radius:50%;background:transparent;color:var(--muted);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:color .12s,border-color .12s;font-size:10px;}
|
||||
.model-search-clear:hover{color:var(--text);border-color:var(--border);}
|
||||
.model-custom-input:focus{border-color:var(--accent);}
|
||||
.model-custom-btn{flex-shrink:0;width:24px;height:24px;border:1px solid var(--border2);border-radius:6px;background:transparent;color:var(--muted);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:color .12s,border-color .12s;}
|
||||
.model-custom-btn:hover{color:var(--accent-text);border-color:var(--accent-bg);}
|
||||
|
||||
90
static/ui.js
90
static/ui.js
@@ -222,45 +222,99 @@ function renderModelDropdown(){
|
||||
const dd=$('composerModelDropdown');
|
||||
const sel=$('modelSelect');
|
||||
if(!dd||!sel) return;
|
||||
dd.innerHTML='';
|
||||
// Store model data for filtering
|
||||
const _modelData=[];
|
||||
for(const child of Array.from(sel.children)){
|
||||
if(child.tagName==='OPTGROUP'){
|
||||
const heading=document.createElement('div');
|
||||
heading.className='model-group';
|
||||
heading.textContent=child.label||'Models';
|
||||
dd.appendChild(heading);
|
||||
for(const opt of Array.from(child.children)){
|
||||
const row=document.createElement('div');
|
||||
row.className='model-opt'+(opt.value===sel.value?' active':'');
|
||||
row.innerHTML=`<span class="model-opt-name">${esc(opt.textContent||getModelLabel(opt.value))}</span><span class="model-opt-id">${esc(opt.value)}</span>`;
|
||||
row.onclick=()=>selectModelFromDropdown(opt.value);
|
||||
dd.appendChild(row);
|
||||
_modelData.push({value:opt.value,name:esc(opt.textContent||getModelLabel(opt.value)),id:esc(opt.value),group:child.label||''});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if(child.tagName==='OPTION'){
|
||||
const row=document.createElement('div');
|
||||
row.className='model-opt'+(child.value===sel.value?' active':'');
|
||||
row.innerHTML=`<span class="model-opt-name">${esc(child.textContent||getModelLabel(child.value))}</span><span class="model-opt-id">${esc(child.value)}</span>`;
|
||||
row.onclick=()=>selectModelFromDropdown(child.value);
|
||||
dd.appendChild(row);
|
||||
_modelData.push({value:child.value,name:esc(child.textContent||getModelLabel(child.value)),id:esc(child.value),group:''});
|
||||
}
|
||||
}
|
||||
// Custom model ID input — lets users type any model not in the curated list
|
||||
// Create search input FIRST before filterModels definition
|
||||
const _searchRow=document.createElement('div');
|
||||
_searchRow.className='model-search-row';
|
||||
_searchRow.innerHTML=`<input class="model-search-input" type="text" placeholder="${esc(t('model_search_placeholder')||'Search models…')}" spellcheck="false" autocomplete="off"><button class="model-search-clear" title="Clear search">${li('x',10)}</button>`;
|
||||
const _si=_searchRow.querySelector('.model-search-input');
|
||||
const _sc=_searchRow.querySelector('.model-search-clear');
|
||||
// Create custom model section elements
|
||||
const _custSep=document.createElement('div');
|
||||
_custSep.className='model-group model-custom-sep';
|
||||
_custSep.textContent=t('model_custom_label')||'Custom model ID';
|
||||
dd.appendChild(_custSep);
|
||||
const _custRow=document.createElement('div');
|
||||
_custRow.className='model-custom-row';
|
||||
_custRow.innerHTML=`<input class="model-custom-input" type="text" placeholder="${esc(t('model_custom_placeholder')||'e.g. openai/gpt-5.4')}" spellcheck="false" autocomplete="off"><button class="model-custom-btn" title="Use this model">${li('plus',12)}</button>`;
|
||||
const _ci=_custRow.querySelector('.model-custom-input');
|
||||
const _cb=_custRow.querySelector('.model-custom-btn');
|
||||
// Filter function (defined AFTER _searchRow and _cust* are created)
|
||||
const _filterModels=(term)=>{
|
||||
term=term.trim().toLowerCase();
|
||||
const found=new Set();
|
||||
for(const m of _modelData){
|
||||
const name=m.name.toLowerCase();
|
||||
const id=m.id.toLowerCase();
|
||||
if(name.includes(term)||id.includes(term)){
|
||||
found.add(m.value);
|
||||
}
|
||||
}
|
||||
// Clear and rebuild
|
||||
dd.innerHTML='';
|
||||
// Add search and custom elements first (CRITICAL: must be before models)
|
||||
dd.appendChild(_searchRow);
|
||||
dd.appendChild(_custSep);
|
||||
dd.appendChild(_custRow);
|
||||
// Add models matching filter
|
||||
let _lastGroup=null;
|
||||
for(const m of _modelData){
|
||||
if(!term||found.has(m.value)){
|
||||
if(m.group&&m.group!==_lastGroup){
|
||||
const heading=document.createElement('div');
|
||||
heading.className='model-group';
|
||||
heading.textContent=m.group;
|
||||
dd.appendChild(heading);
|
||||
_lastGroup=m.group;
|
||||
}
|
||||
const row=document.createElement('div');
|
||||
row.className='model-opt'+(m.value===sel.value?' active':'');
|
||||
row.innerHTML=`<span class="model-opt-name">${m.name}</span><span class="model-opt-id">${m.id}</span>`;
|
||||
row.onclick=()=>selectModelFromDropdown(m.value);
|
||||
dd.appendChild(row);
|
||||
}
|
||||
}
|
||||
// Show "No results" if filtered and nothing matched
|
||||
if(term&&found.size===0){
|
||||
const noResult=document.createElement('div');
|
||||
noResult.className='model-search-no-results';
|
||||
noResult.textContent=t('model_search_no_results')||'No models found';
|
||||
noResult.style.padding='12px 14px';
|
||||
noResult.style.color='var(--muted)';
|
||||
noResult.style.textAlign='center';
|
||||
dd.appendChild(noResult);
|
||||
}
|
||||
// Restore focus to search input
|
||||
_si.focus();
|
||||
};
|
||||
// Event handlers for search input
|
||||
_si.addEventListener('input',()=>_filterModels(_si.value));
|
||||
_si.addEventListener('keydown',e=>{if(e.key==='Enter') {e.preventDefault();}if(e.key==='Escape') {closeModelDropdown();}});
|
||||
_si.addEventListener('click',e=>e.stopPropagation());
|
||||
// Event handlers for clear button
|
||||
_sc.onclick=()=>{ _si.value=''; _filterModels(''); _si.focus(); };
|
||||
_sc.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){ _si.value=''; _filterModels(''); _si.focus(); e.preventDefault(); }});
|
||||
// Event handlers for custom input
|
||||
const _applyCustom=()=>{const v=_ci.value.trim();if(!v)return;selectModelFromDropdown(v);_ci.value='';};
|
||||
_cb.onclick=_applyCustom;
|
||||
_ci.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();_applyCustom();}if(e.key==='Escape'){closeModelDropdown();}});
|
||||
_ci.addEventListener('click',e=>e.stopPropagation());
|
||||
// Add search and custom elements to dropdown (initial render)
|
||||
dd.appendChild(_searchRow);
|
||||
dd.appendChild(_custSep);
|
||||
dd.appendChild(_custRow);
|
||||
// Apply initial filter (empty shows all)
|
||||
_filterModels('');
|
||||
}
|
||||
|
||||
async function selectModelFromDropdown(value){
|
||||
|
||||
Reference in New Issue
Block a user