feat(#4336): add vendor prefix sub-grouping in model picker for OpenRouter and Nous
This commit is contained in:
1 parent
6f233d3a20
commit
7aa0649e3e
3 files changed
+109
-23
No files matched your search
@@ -2519,6 +2519,7 @@
|
||||
.model-dropdown.open{display:block;}
|
||||
.model-scope-note{position:sticky;top:0;z-index:2;padding:9px 14px;border-bottom:1px solid var(--border);color:var(--text);font-size:11px;font-weight:650;line-height:1.4;background:color-mix(in srgb,var(--surface) 82%,var(--accent-bg));box-shadow:0 1px 0 rgba(0,0,0,.12);}
|
||||
.model-group{padding:8px 14px 4px;font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted);text-transform:uppercase;border-top:1px solid var(--border2);margin-top:2px;}.model-group.collapsible{cursor:pointer;user-select:none}.model-group.collapsible:before{content:'\25b6';display:inline-block;font-size:8px;margin-right:5px;transition:transform .15s ease}.model-group.collapsible.open:before{transform:rotate(90deg)}.model-group-body:empty{display:none}
|
||||
.model-group.sub{padding:4px 14px 2px 22px;font-size:9px;border-top:none;margin-top:0;letter-spacing:.03em;color:var(--muted);background:transparent;}.model-group.sub.collapsible:before{content:'\25b6';display:inline-block;font-size:7px;margin-right:4px;transition:transform .15s ease}.model-group.sub.collapsible.open:before{transform:rotate(90deg)}.model-group-body.sub{padding-left:8px;border-left:1px solid var(--border);}
|
||||
.model-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:3px;align-items:flex-start;}
|
||||
.model-opt:hover{background:rgba(255,255,255,.07);}
|
||||
.model-opt.is-highlighted{background:rgba(255,255,255,.1);outline:1px solid var(--border2);outline-offset:-1px;}
|
||||
|
||||
+71
-23
@@ -2296,6 +2296,13 @@ function renderModelDropdown(){
|
||||
}
|
||||
return _groupMeta.get(groupKey);
|
||||
};
|
||||
const _vendorPrefix=(rawId)=>{
|
||||
const stripped=String(rawId||'').replace(/^@([^:]+:)+/,'');
|
||||
const slash=stripped.indexOf('/');
|
||||
return slash>0?stripped.slice(0,slash):'';
|
||||
};
|
||||
const SUB_GROUP_PROVIDERS=new Set(['openrouter','nous']);
|
||||
const SUB_GROUP_MIN_MODELS=8;
|
||||
for(const child of Array.from(sel.children)){
|
||||
if(child.tagName==='OPTGROUP'){
|
||||
const providerId=child.dataset&&child.dataset.provider?child.dataset.provider:'';
|
||||
@@ -2515,6 +2522,17 @@ function renderModelDropdown(){
|
||||
const _hit=_modelData.find(m=>m&&!m.endpointErrorOnly&&String(m.value||'')===_selVal);
|
||||
return _hit?_hit.groupKey:null;
|
||||
})();
|
||||
const _makeModelRow=(m,sel,configuredIds)=>{
|
||||
const row=document.createElement('div');
|
||||
row.className='model-opt'+(m.value===sel.value?' active':'');
|
||||
const badgeHtml=m.badge?`<span class="model-opt-badge model-opt-badge--${esc(m.badge.role||'configured')}">${esc(m.badge.label||'Configured')}</span>`:'';
|
||||
const _plainGroup=m.group?String(m.group).replace(/\s*\(\d+\s+of\s+\d+\)\s*$/,''):'';
|
||||
const _underOwnHeading=shouldRenderHeading&&!!(m.groupKey&&_groupWrappers[m.groupKey]);
|
||||
const providerChip=(_plainGroup&&!_underOwnHeading)?`<span class="model-opt-provider">${esc(_plainGroup)}</span>`:'';
|
||||
row.innerHTML=`<div class="model-opt-top"><span class="model-opt-name">${esc(m.name)}</span>${badgeHtml}${providerChip}</div><span class="model-opt-id">${esc(m.id)}</span>`;
|
||||
row.onclick=()=>selectModelFromDropdown(m.value,m.providerId||(m.badge&&m.badge.provider)||null);
|
||||
return row;
|
||||
};
|
||||
const _filterModels=(term)=>{
|
||||
term=term.trim().toLowerCase();
|
||||
const hasSearch=!!term;
|
||||
@@ -2669,29 +2687,52 @@ function renderModelDropdown(){
|
||||
heading.classList.toggle('open',closed);
|
||||
});
|
||||
}
|
||||
for(const m of groupRows){
|
||||
const row=document.createElement('div');
|
||||
row.className='model-opt'+(m.value===sel.value?' active':'');
|
||||
const badgeHtml=m.badge?`<span class="model-opt-badge model-opt-badge--${esc(m.badge.role||'configured')}">${esc(m.badge.label||'Configured')}</span>`:'';
|
||||
// The group heading carries the overflow count ("Provider (15 of 30)"); the
|
||||
// per-row provider chip must show the PLAIN provider label only — otherwise
|
||||
// the count is stamped redundantly on every row and reads as nonsense after
|
||||
// "Show all" expands the group (e.g. row 30 still showing "(15 of 30)"). (#3691)
|
||||
const _plainGroup=m.group?String(m.group).replace(/\s*\(\d+\s+of\s+\d+\)\s*$/,''):'';
|
||||
// Only show the per-row provider chip when the row is NOT already under its
|
||||
// own provider heading — i.e. it's a flat/hoisted row appended straight to
|
||||
// the dropdown (no group wrapper). Repeating the provider name on every row
|
||||
// beneath its own "PROVIDER" heading is pure visual noise. The chip still
|
||||
// orients hoisted/configured rows and search results that render flat.
|
||||
const _underOwnHeading=shouldRenderHeading&&!!(m.groupKey&&_groupWrappers[m.groupKey]);
|
||||
const providerChip=(_plainGroup&&!_underOwnHeading)?`<span class="model-opt-provider">${esc(_plainGroup)}</span>`:'';
|
||||
row.innerHTML=`<div class="model-opt-top"><span class="model-opt-name">${esc(m.name)}</span>${badgeHtml}${providerChip}</div><span class="model-opt-id">${esc(m.id)}</span>`;
|
||||
row.onclick=()=>selectModelFromDropdown(m.value,m.providerId||(m.badge&&m.badge.provider)||null);
|
||||
if(m.groupKey&&_groupWrappers[m.groupKey]){
|
||||
_groupWrappers[m.groupKey].appendChild(row);
|
||||
}else{
|
||||
dd.appendChild(row);
|
||||
const useSubGroups=(
|
||||
SUB_GROUP_PROVIDERS.has(meta.providerId) &&
|
||||
groupRows.length>=SUB_GROUP_MIN_MODELS
|
||||
);
|
||||
if(useSubGroups){
|
||||
const byPrefix=new Map();
|
||||
for(const m of groupRows){
|
||||
const pfx=_vendorPrefix(m.value)||'other';
|
||||
if(!byPrefix.has(pfx)) byPrefix.set(pfx,[]);
|
||||
byPrefix.get(pfx).push(m);
|
||||
}
|
||||
const sorted=[...byPrefix.entries()].sort((a,b)=>{
|
||||
if(a[0]==='other') return 1;
|
||||
if(b[0]==='other') return -1;
|
||||
return b[1].length-a[1].length;
|
||||
});
|
||||
for(const [pfx,pfxRows] of sorted){
|
||||
if(pfxRows.length>=2){
|
||||
const subKey=`${groupKey}::${pfx}`;
|
||||
if(!(subKey in _groupOpenState)) _groupOpenState[subKey]=true;
|
||||
if(hasSearch) _groupOpenState[subKey]=true;
|
||||
const subHeading=document.createElement('div');
|
||||
subHeading.className='model-group sub collapsible';
|
||||
subHeading.dataset.group=subKey;
|
||||
if(_groupOpenState[subKey]) subHeading.classList.add('open');
|
||||
subHeading.textContent=pfx;
|
||||
const subWrapper=document.createElement('div');
|
||||
subWrapper.className='model-group-body sub';
|
||||
subWrapper.dataset.group=subKey;
|
||||
if(!_groupOpenState[subKey]) subWrapper.style.display='none';
|
||||
subHeading.addEventListener('click',(e)=>{
|
||||
e.stopPropagation();
|
||||
const closed=subWrapper.style.display==='none';
|
||||
subWrapper.style.display=closed?'':'none';
|
||||
_groupOpenState[subKey]=closed;
|
||||
subHeading.classList.toggle('open',closed);
|
||||
});
|
||||
wrapper.appendChild(subHeading);
|
||||
wrapper.appendChild(subWrapper);
|
||||
for(const m of pfxRows) subWrapper.appendChild(_makeModelRow(m,sel,configuredIds));
|
||||
} else {
|
||||
for(const m of pfxRows) wrapper.appendChild(_makeModelRow(m,sel,configuredIds));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for(const m of groupRows) wrapper.appendChild(_makeModelRow(m,sel,configuredIds));
|
||||
}
|
||||
if(!term&&hiddenCount){
|
||||
const showAll=document.createElement('div');
|
||||
@@ -2733,7 +2774,14 @@ function renderModelDropdown(){
|
||||
};
|
||||
_si.addEventListener('input',()=>_filterModels(_si.value));
|
||||
// Keyboard navigation through filtered model rows (#2791).
|
||||
const _visibleModelRows=()=>Array.from(dd.querySelectorAll('.model-opt,.model-opt-more')).filter(el=>!el.closest('.model-group-body')||el.closest('.model-group-body').style.display!=='none');
|
||||
const _visibleModelRows=()=>Array.from(dd.querySelectorAll('.model-opt,.model-opt-more')).filter(el=>{
|
||||
let node=el.parentElement;
|
||||
while(node&&node!==dd){
|
||||
if(node.classList.contains('model-group-body')&&node.style.display==='none') return false;
|
||||
node=node.parentElement;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const _activeRowIndex=(rows)=>rows.findIndex(r=>r.classList.contains('is-highlighted'));
|
||||
const _highlightRow=(rows,idx)=>{
|
||||
for(const r of rows) r.classList.remove('is-highlighted');
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
UI=(ROOT/"static"/"ui.js").read_text(encoding="utf-8")
|
||||
CSS=(ROOT/"static"/"style.css").read_text(encoding="utf-8")
|
||||
|
||||
def test_vendor_subgroup_allowlist():
|
||||
assert "SUB_GROUP_PROVIDERS" in UI
|
||||
assert "openrouter" in UI
|
||||
assert "nous" in UI
|
||||
|
||||
def test_vendor_prefix_strips_routing_prefix():
|
||||
# The strip pattern must match the existing normalizer at line 1749
|
||||
assert "replace(/^@([^:]+:)+/,'')" in UI
|
||||
assert re.search(r"indexOf\('/'\)|split\('/'\)", UI)
|
||||
|
||||
def test_subgroup_keys_use_double_colon_separator():
|
||||
assert "::" in UI
|
||||
assert re.search(r"_groupOpenState\[subKey\]", UI)
|
||||
|
||||
def test_single_model_vendor_buckets_stay_flat():
|
||||
# pfxRows.length>=2 gate means single-model prefixes render flat
|
||||
assert re.search(r"\.length\s*>=\s*2", UI)
|
||||
|
||||
def test_visible_rows_walk_all_group_body_ancestors():
|
||||
visible_match=re.search(r"const _visibleModelRows=\(\)=>[\s\S]+?\}\);", UI)
|
||||
assert visible_match, "_visibleModelRows definition not found"
|
||||
visible=visible_match.group(0)
|
||||
assert "while(" in visible
|
||||
assert "parentElement" in visible
|
||||
assert "model-group-body" in visible
|
||||
assert "closest('.model-group-body')" not in visible
|
||||
|
||||
def test_nested_group_css_exists():
|
||||
assert ".model-group.sub" in CSS
|
||||
assert ".model-group-body.sub" in CSS
|
||||
Reference in new issue
Block a user