mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 12:40:18 +00:00
fix(#4706): reuse the searchable model picker in onboarding
This commit is contained in:
committed by
nesquena-hermes
parent
12b269f94c
commit
d0eb9b57df
@@ -209,6 +209,9 @@ function _renderOnboardingModelField(){
|
||||
if(ONBOARDING.form.provider==='custom'){
|
||||
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><input id="onboardingModelInput" value="${esc(_getOnboardingSelectedModel())}" placeholder="${t('onboarding_custom_model_placeholder')}" oninput="ONBOARDING.form.model=this.value"></label><p class="onboarding-copy">${t('onboarding_custom_model_help')}</p>`;
|
||||
}
|
||||
if(typeof _mountSearchableModelSelect==='function'){
|
||||
return `<div class="onboarding-field onboarding-model-field"><span>${t('onboarding_model_label')}</span><div id="onboardingModelPickerRoot"></div></div><p class="onboarding-copy">${t('onboarding_workspace_help')}</p>`;
|
||||
}
|
||||
const options=choices.map(m=>`<option value="${esc(m.id)}">${esc(m.label)}</option>`).join('');
|
||||
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><select id="onboardingModelSelect" onchange="ONBOARDING.form.model=this.value">${options}</select></label><p class="onboarding-copy">${t('onboarding_workspace_help')}</p>`;
|
||||
}
|
||||
@@ -356,6 +359,17 @@ function _renderOnboardingBody(){
|
||||
${_renderOnboardingModelField()}`;
|
||||
const wsSel=$('onboardingWorkspaceSelect');
|
||||
if(wsSel && ONBOARDING.form.workspace) wsSel.value=ONBOARDING.form.workspace;
|
||||
const modelPickerRoot=$('onboardingModelPickerRoot');
|
||||
if(modelPickerRoot && typeof _mountSearchableModelSelect==='function'){
|
||||
_mountSearchableModelSelect({
|
||||
root:modelPickerRoot,
|
||||
selectId:'onboardingModelSelect',
|
||||
customInputId:'onboardingModelInput',
|
||||
choices:_getOnboardingProviderModelChoices(),
|
||||
selectedValue:ONBOARDING.form.model,
|
||||
onModelChange:(value)=>{ ONBOARDING.form.model=(value||'').trim(); },
|
||||
});
|
||||
}
|
||||
const modelSel=$('onboardingModelSelect');
|
||||
if(modelSel && ONBOARDING.form.model) modelSel.value=ONBOARDING.form.model;
|
||||
return;
|
||||
|
||||
+111
@@ -2443,6 +2443,117 @@ function _appendOverflowOptionsToGroup(group, extraModels){
|
||||
return appended;
|
||||
}
|
||||
|
||||
function _mountSearchableModelSelect(opts={}){
|
||||
const root=opts.root;
|
||||
if(!root) return null;
|
||||
const choices=Array.isArray(opts.choices)
|
||||
? opts.choices
|
||||
.map(choice=>choice&&choice.id?{id:String(choice.id),label:String(choice.label||choice.id)}:null)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const selectedValue=String(opts.selectedValue||'');
|
||||
const onModelChange=typeof opts.onModelChange==='function' ? opts.onModelChange : ()=>{};
|
||||
const selectId=opts.selectId||'';
|
||||
const customInputId=opts.customInputId||'';
|
||||
const listedChoiceIds=new Set(choices.map(choice=>choice.id));
|
||||
const listedSelection=listedChoiceIds.has(selectedValue) ? selectedValue : '';
|
||||
const customSelection=listedSelection ? '' : selectedValue;
|
||||
let lastListedValue=listedSelection||(choices[0]?choices[0].id:'');
|
||||
root.innerHTML=
|
||||
`<div class="model-search-row">`+
|
||||
`<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>`+
|
||||
`</div>`+
|
||||
`<select ${selectId?`id="${esc(selectId)}"`:''}></select>`+
|
||||
`<div class="model-group model-custom-sep">${esc(t('model_custom_label')||'Custom model ID')}</div>`+
|
||||
`<div class="model-custom-row">`+
|
||||
`<input ${customInputId?`id="${esc(customInputId)}"`:''} 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>`+
|
||||
`</div>`;
|
||||
const searchInput=root.querySelector('.model-search-input');
|
||||
const clearButton=root.querySelector('.model-search-clear');
|
||||
const selectEl=selectId ? root.querySelector(`#${selectId}`) : root.querySelector('select');
|
||||
const customInput=customInputId ? root.querySelector(`#${customInputId}`) : root.querySelector('.model-custom-input');
|
||||
const customButton=root.querySelector('.model-custom-btn');
|
||||
if(!searchInput||!clearButton||!selectEl||!customInput||!customButton) return null;
|
||||
|
||||
const noMatchesOption=document.createElement('option');
|
||||
noMatchesOption.value='';
|
||||
noMatchesOption.textContent='No matching models';
|
||||
noMatchesOption.disabled=true;
|
||||
noMatchesOption.hidden=true;
|
||||
selectEl.appendChild(noMatchesOption);
|
||||
|
||||
for(const choice of choices){
|
||||
const option=document.createElement('option');
|
||||
option.value=choice.id;
|
||||
option.textContent=choice.label;
|
||||
selectEl.appendChild(option);
|
||||
}
|
||||
if(listedSelection){
|
||||
selectEl.value=listedSelection;
|
||||
}else if(customSelection){
|
||||
selectEl.selectedIndex=-1;
|
||||
}else if(choices.length){
|
||||
selectEl.value=choices[0].id;
|
||||
onModelChange(lastListedValue);
|
||||
}
|
||||
customInput.value=customSelection;
|
||||
|
||||
const applyFilter=()=>{
|
||||
const needle=(searchInput.value||'').trim().toLowerCase();
|
||||
let visibleCount=0;
|
||||
for(const option of Array.from(selectEl.options)){
|
||||
if(option===noMatchesOption) continue;
|
||||
const haystack=`${option.textContent||''} ${option.value||''}`.toLowerCase();
|
||||
const visible=!needle||haystack.includes(needle);
|
||||
option.hidden=!visible;
|
||||
if(visible) visibleCount++;
|
||||
}
|
||||
noMatchesOption.hidden=visibleCount!==0;
|
||||
};
|
||||
|
||||
const applyCustomSelection=()=>{
|
||||
onModelChange((customInput.value||'').trim());
|
||||
};
|
||||
|
||||
searchInput.addEventListener('input', applyFilter);
|
||||
clearButton.addEventListener('click', ()=>{
|
||||
searchInput.value='';
|
||||
applyFilter();
|
||||
searchInput.focus();
|
||||
});
|
||||
selectEl.addEventListener('change', ()=>{
|
||||
customInput.value='';
|
||||
lastListedValue=selectEl.value||lastListedValue;
|
||||
onModelChange(lastListedValue);
|
||||
});
|
||||
customInput.addEventListener('input', ()=>{
|
||||
const value=(customInput.value||'').trim();
|
||||
if(value){
|
||||
selectEl.selectedIndex=-1;
|
||||
onModelChange(value);
|
||||
return;
|
||||
}
|
||||
customInput.value='';
|
||||
if(lastListedValue){
|
||||
selectEl.value=lastListedValue;
|
||||
}
|
||||
applyCustomSelection();
|
||||
});
|
||||
customInput.addEventListener('keydown', (event)=>{
|
||||
if(event.key!=='Enter') return;
|
||||
event.preventDefault();
|
||||
applyCustomSelection();
|
||||
});
|
||||
customButton.addEventListener('click', (event)=>{
|
||||
event.preventDefault();
|
||||
applyCustomSelection();
|
||||
});
|
||||
applyFilter();
|
||||
return {searchInput,selectEl,customInput,customButton};
|
||||
}
|
||||
|
||||
function renderModelDropdown(){
|
||||
const dd=$('composerModelDropdown');
|
||||
const sel=$('modelSelect');
|
||||
|
||||
@@ -173,6 +173,19 @@ class TestLiveModelsProviderNormalization:
|
||||
assert c._resolve_provider_alias(None) is None
|
||||
|
||||
|
||||
def test_shared_searchable_model_picker_helper_has_search_and_custom_controls():
|
||||
src = read("static/ui.js")
|
||||
m = re.search(r"function _mountSearchableModelSelect\(opts=\{\}\)\{.*?\n\}", src, re.DOTALL)
|
||||
assert m, "_mountSearchableModelSelect helper not found in static/ui.js"
|
||||
fn = m.group(0)
|
||||
assert "model-search-input" in fn
|
||||
assert "model-custom-input" in fn
|
||||
assert "model-custom-btn" in fn
|
||||
assert "onModelChange" in fn
|
||||
assert "selectEl.selectedIndex=-1;" in fn
|
||||
assert "onModelChange(lastListedValue);" in fn
|
||||
|
||||
|
||||
# ── api/routes.py — /api/models/live custom_providers fallback ────────────────
|
||||
|
||||
class TestLiveModelsCustomProviderFallback:
|
||||
|
||||
@@ -4,7 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -12,6 +15,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
UI_JS = REPO / "static" / "ui.js"
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def test_onboarding_codex_oauth_routes_use_post_start_cancel_and_get_poll():
|
||||
@@ -595,3 +600,338 @@ def test_frontend_has_anthropic_oauth_support():
|
||||
assert "window.open(" not in js[js.find("startAnthropicOAuth"):]
|
||||
assert "accessToken" not in js[js.find("startAnthropicOAuth"):]
|
||||
assert "refreshToken" not in js[js.find("startAnthropicOAuth"):]
|
||||
|
||||
|
||||
def test_onboarding_non_custom_provider_mounts_searchable_model_picker():
|
||||
js = (REPO / "static" / "onboarding.js").read_text(encoding="utf-8")
|
||||
assert "onboardingModelPickerRoot" in js
|
||||
assert "_mountSearchableModelSelect" in js
|
||||
assert "choices:_getOnboardingProviderModelChoices()" in js
|
||||
assert "selectId:'onboardingModelSelect'" in js
|
||||
assert "customInputId:'onboardingModelInput'" in js
|
||||
|
||||
|
||||
def test_onboarding_custom_provider_text_input_branch_stays_intact():
|
||||
js = (REPO / "static" / "onboarding.js").read_text(encoding="utf-8")
|
||||
assert "ONBOARDING.form.provider==='custom'" in js
|
||||
assert 'id="onboardingModelInput"' in js
|
||||
assert "onboarding_custom_model_placeholder" in js
|
||||
assert "onboarding_custom_model_help" in js
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required")
|
||||
def test_onboarding_searchable_picker_runtime_and_submit_fallback():
|
||||
driver = r"""
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync(process.argv[2], 'utf8');
|
||||
|
||||
function extractFunc(name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
const parenStart = src.indexOf('(', start);
|
||||
let parenDepth = 0;
|
||||
let inString = null;
|
||||
let escaped = false;
|
||||
let bodyStart = -1;
|
||||
for (let i = parenStart; i < src.length; i++) {
|
||||
const ch = src[i];
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (ch === '\\') escaped = true;
|
||||
else if (ch === inString) inString = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === '`') {
|
||||
inString = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(') parenDepth++;
|
||||
else if (ch === ')') {
|
||||
parenDepth--;
|
||||
if (parenDepth === 0) {
|
||||
bodyStart = src.indexOf('{', i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bodyStart < 0) throw new Error(name + ' body not found');
|
||||
let depth = 0;
|
||||
inString = null;
|
||||
escaped = false;
|
||||
for (let i = bodyStart; i < src.length; i++) {
|
||||
const ch = src[i];
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (ch === '\\') escaped = true;
|
||||
else if (ch === inString) inString = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === '`') {
|
||||
inString = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(name + ' brace scan failed');
|
||||
}
|
||||
|
||||
const elementsById = new Map();
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.className = '';
|
||||
this.id = '';
|
||||
this.hidden = false;
|
||||
this.disabled = false;
|
||||
this.textContent = '';
|
||||
this._value = '';
|
||||
this._selectedIndex = -1;
|
||||
this._listeners = {};
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
if (child.id) elementsById.set(child.id, child);
|
||||
if (this.tagName === 'SELECT' && child.tagName === 'OPTION' && this._selectedIndex < 0 && !child.disabled) {
|
||||
this.selectedIndex = this.children.length - 1;
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
addEventListener(type, handler) {
|
||||
this._listeners[type] = handler;
|
||||
}
|
||||
|
||||
dispatch(type, extra = {}) {
|
||||
const handler = this._listeners[type];
|
||||
if (!handler) return;
|
||||
handler({
|
||||
type,
|
||||
target: this,
|
||||
key: extra.key,
|
||||
preventDefault() {},
|
||||
});
|
||||
}
|
||||
|
||||
focus() {}
|
||||
|
||||
querySelector(selector) {
|
||||
const queue = [...this.children];
|
||||
while (queue.length) {
|
||||
const node = queue.shift();
|
||||
if (selector.startsWith('.')) {
|
||||
const cls = selector.slice(1);
|
||||
if ((node.className || '').split(/\s+/).includes(cls)) return node;
|
||||
} else if (selector.startsWith('#')) {
|
||||
if (node.id === selector.slice(1)) return node;
|
||||
} else if (node.tagName === selector.toUpperCase()) {
|
||||
return node;
|
||||
}
|
||||
queue.push(...node.children);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
set innerHTML(html) {
|
||||
this.children = [];
|
||||
const selectId = (html.match(/<select id="([^"]+)"/) || [])[1] || '';
|
||||
const customInputId = (html.match(/<input id="([^"]+)" class="model-custom-input"/) || [])[1] || '';
|
||||
|
||||
const searchRow = new FakeElement('div');
|
||||
searchRow.className = 'model-search-row';
|
||||
const searchInput = new FakeElement('input');
|
||||
searchInput.className = 'model-search-input';
|
||||
const clearBtn = new FakeElement('button');
|
||||
clearBtn.className = 'model-search-clear';
|
||||
searchRow.appendChild(searchInput);
|
||||
searchRow.appendChild(clearBtn);
|
||||
|
||||
const selectEl = new FakeElement('select');
|
||||
selectEl.id = selectId;
|
||||
|
||||
const sep = new FakeElement('div');
|
||||
sep.className = 'model-group model-custom-sep';
|
||||
|
||||
const customRow = new FakeElement('div');
|
||||
customRow.className = 'model-custom-row';
|
||||
const customInput = new FakeElement('input');
|
||||
customInput.className = 'model-custom-input';
|
||||
customInput.id = customInputId;
|
||||
const customBtn = new FakeElement('button');
|
||||
customBtn.className = 'model-custom-btn';
|
||||
customRow.appendChild(customInput);
|
||||
customRow.appendChild(customBtn);
|
||||
|
||||
this.appendChild(searchRow);
|
||||
this.appendChild(selectEl);
|
||||
this.appendChild(sep);
|
||||
this.appendChild(customRow);
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this.children;
|
||||
}
|
||||
|
||||
set value(value) {
|
||||
this._value = String(value);
|
||||
if (this.tagName === 'SELECT') {
|
||||
const idx = this.children.findIndex((child) => child.value === this._value);
|
||||
this._selectedIndex = idx;
|
||||
}
|
||||
}
|
||||
|
||||
get value() {
|
||||
if (this.tagName === 'SELECT') {
|
||||
if (this._selectedIndex >= 0 && this.children[this._selectedIndex]) {
|
||||
return this.children[this._selectedIndex].value;
|
||||
}
|
||||
}
|
||||
return this._value || '';
|
||||
}
|
||||
|
||||
set selectedIndex(index) {
|
||||
this._selectedIndex = index;
|
||||
if (this.tagName === 'SELECT') {
|
||||
this._value = index >= 0 && this.children[index] ? this.children[index].value : '';
|
||||
}
|
||||
}
|
||||
|
||||
get selectedIndex() {
|
||||
return this._selectedIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const document = {
|
||||
createElement(tagName) {
|
||||
return new FakeElement(tagName);
|
||||
},
|
||||
getElementById(id) {
|
||||
return elementsById.get(id) || null;
|
||||
},
|
||||
};
|
||||
|
||||
global.document = document;
|
||||
global.window = {};
|
||||
global.esc = (value) => String(value);
|
||||
global.t = (key) => key === 'model_search_placeholder'
|
||||
? 'Search models…'
|
||||
: key === 'model_custom_label'
|
||||
? 'Custom model ID'
|
||||
: key === 'model_custom_placeholder'
|
||||
? 'e.g. openai/gpt-5.4'
|
||||
: key;
|
||||
global.li = () => '+';
|
||||
|
||||
eval(extractFunc('_mountSearchableModelSelect'));
|
||||
|
||||
const ONBOARDING = { form: { model: '' } };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const root = new FakeElement('div');
|
||||
|
||||
_mountSearchableModelSelect({
|
||||
root,
|
||||
selectId: 'onboardingModelSelect',
|
||||
customInputId: 'onboardingModelInput',
|
||||
choices: [
|
||||
{ id: 'openrouter/alpha', label: 'OpenRouter Alpha' },
|
||||
{ id: 'openrouter/beta', label: 'OpenRouter Beta' },
|
||||
],
|
||||
selectedValue: ONBOARDING.form.model,
|
||||
onModelChange: (value) => { ONBOARDING.form.model = value; },
|
||||
});
|
||||
|
||||
const selectEl = $('onboardingModelSelect');
|
||||
const customInput = $('onboardingModelInput');
|
||||
const searchInput = root.querySelector('.model-search-input');
|
||||
|
||||
const initial = {
|
||||
model: ONBOARDING.form.model,
|
||||
selectValue: selectEl.value,
|
||||
selectedIndex: selectEl.selectedIndex,
|
||||
};
|
||||
|
||||
searchInput.value = 'beta';
|
||||
searchInput.dispatch('input');
|
||||
const hiddenStates = selectEl.options.map((option) => ({
|
||||
value: option.value,
|
||||
hidden: option.hidden,
|
||||
}));
|
||||
|
||||
customInput.value = 'vendor/custom-model';
|
||||
customInput.dispatch('input');
|
||||
const afterCustom = {
|
||||
model: ONBOARDING.form.model,
|
||||
selectedIndex: selectEl.selectedIndex,
|
||||
selectValue: selectEl.value,
|
||||
};
|
||||
|
||||
selectEl.value = 'openrouter/beta';
|
||||
selectEl.dispatch('change');
|
||||
const afterSelect = {
|
||||
model: ONBOARDING.form.model,
|
||||
customValue: customInput.value,
|
||||
selectValue: selectEl.value,
|
||||
};
|
||||
|
||||
customInput.value = 'vendor/final-model';
|
||||
customInput.dispatch('input');
|
||||
const submitValue = (($('onboardingModelInput') || {}).value || ($('onboardingModelSelect') || {}).value || ONBOARDING.form.model || '').trim();
|
||||
|
||||
customInput.value = ' ';
|
||||
customInput.dispatch('input');
|
||||
const whitespaceSubmitValue = (($('onboardingModelInput') || {}).value || ($('onboardingModelSelect') || {}).value || ONBOARDING.form.model || '').trim();
|
||||
|
||||
console.log(JSON.stringify({ initial, hiddenStates, afterCustom, afterSelect, submitValue, whitespaceSubmitValue, whitespaceCustomValue: customInput.value }));
|
||||
"""
|
||||
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".cjs", encoding="utf-8", dir=REPO, delete=False) as handle:
|
||||
handle.write(driver)
|
||||
script = Path(handle.name)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[NODE, str(script), str(UI_JS)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=str(REPO),
|
||||
)
|
||||
finally:
|
||||
script.unlink(missing_ok=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr)
|
||||
|
||||
out = json.loads(result.stdout.strip())
|
||||
assert out["initial"] == {
|
||||
"model": "openrouter/alpha",
|
||||
"selectValue": "openrouter/alpha",
|
||||
"selectedIndex": 1,
|
||||
}
|
||||
assert out["hiddenStates"] == [
|
||||
{"value": "", "hidden": True},
|
||||
{"value": "openrouter/alpha", "hidden": True},
|
||||
{"value": "openrouter/beta", "hidden": False},
|
||||
]
|
||||
assert out["afterCustom"] == {
|
||||
"model": "vendor/custom-model",
|
||||
"selectedIndex": -1,
|
||||
"selectValue": "",
|
||||
}
|
||||
assert out["afterSelect"] == {
|
||||
"model": "openrouter/beta",
|
||||
"customValue": "",
|
||||
"selectValue": "openrouter/beta",
|
||||
}
|
||||
assert out["submitValue"] == "vendor/final-model"
|
||||
assert out["whitespaceSubmitValue"] == "openrouter/beta"
|
||||
assert out["whitespaceCustomValue"] == ""
|
||||
|
||||
Reference in New Issue
Block a user