const ONBOARDING={status:null,step:0,steps:['system','setup','workspace','password','finish'],form:{provider:'openrouter',workspace:'',model:'',password:'',apiKey:'',baseUrl:''},active:false,probe:{status:'idle',error:null,detail:'',models:null,probedKey:''}}; // ── Onboarding base-URL probe (#1499) ─────────────────────────────────────── // Probes /models so the wizard can validate the configured endpoint // before persisting AND populate the model dropdown from the live catalog. // Probe state lives on ONBOARDING.probe; the dropdown render and the // nextOnboardingStep gate both consult it. let _onboardingProbeTimer=null; function _onboardingProbeKey(provider,baseUrl,apiKey){ return `${provider||''}|${(baseUrl||'').trim().replace(/\/+$/,'')}|${apiKey||''}`; } function _setOnboardingProbeState(patch){ ONBOARDING.probe={...ONBOARDING.probe,...patch}; // Re-render body so probe status / model dropdown reflect new state. _renderOnboardingBody(); } async function _runOnboardingProbe({force=false}={}){ const provider=ONBOARDING.form.provider; const cat=_getOnboardingSetupProvider(provider); if(!cat||!cat.requires_base_url){ _setOnboardingProbeState({status:'idle',error:null,detail:'',models:null,probedKey:''}); return ONBOARDING.probe; } const baseUrl=(ONBOARDING.form.baseUrl||'').trim(); if(!baseUrl){ _setOnboardingProbeState({status:'idle',error:null,detail:'',models:null,probedKey:''}); return ONBOARDING.probe; } const apiKey=(ONBOARDING.form.apiKey||'').trim(); const key=_onboardingProbeKey(provider,baseUrl,apiKey); if(!force&&ONBOARDING.probe.probedKey===key&&ONBOARDING.probe.status!=='probing'){ return ONBOARDING.probe; } _setOnboardingProbeState({status:'probing',error:null,detail:'',probedKey:key}); try{ const res=await api('/api/onboarding/probe',{method:'POST',body:JSON.stringify({provider,base_url:baseUrl,api_key:apiKey||undefined})}); if(res&&res.ok){ _setOnboardingProbeState({status:'ok',error:null,detail:'',models:Array.isArray(res.models)?res.models:[],probedKey:key}); // If the user hasn't picked a model yet (or their pick is no longer in // the list), default to the first probed model so Continue isn't blocked // on an empty selection. const stillPresent=ONBOARDING.form.model&&(res.models||[]).some(m=>m.id===ONBOARDING.form.model); if(!stillPresent&&(res.models||[]).length>0){ ONBOARDING.form.model=res.models[0].id; _renderOnboardingBody(); } }else{ const err=(res&&res.error)||'unreachable'; const detail=(res&&res.detail)||''; _setOnboardingProbeState({status:'error',error:err,detail,models:null,probedKey:key}); } }catch(e){ _setOnboardingProbeState({status:'error',error:'unreachable',detail:(e&&e.message)||String(e),models:null,probedKey:key}); } return ONBOARDING.probe; } function _scheduleOnboardingProbe(){ if(_onboardingProbeTimer)clearTimeout(_onboardingProbeTimer); _onboardingProbeTimer=setTimeout(()=>{_runOnboardingProbe();},400); } function _onboardingProbeMessage(probe){ if(!probe||probe.status==='idle')return ''; if(probe.status==='probing')return t('onboarding_probe_probing')||'Testing connection…'; if(probe.status==='ok'){ const n=(probe.models||[]).length; const tmpl=t('onboarding_probe_ok')||'Connected. {n} model(s) available.'; return tmpl.replace('{n}',String(n)); } // status === 'error' const errKey='onboarding_probe_error_'+probe.error; const localized=t(errKey); // i18n.js's `t()` returns the key itself when missing — fall back to a generic message. const heading=(localized&&localized!==errKey)?localized:(t('onboarding_probe_error_generic')||'Could not reach the configured base URL.'); const detail=probe.detail?` (${probe.detail})`:''; return heading+detail; } function _getOnboardingSetupProviders(){ return (((ONBOARDING.status||{}).setup||{}).providers)||[]; } function _getOnboardingSetupProvider(id){ return _getOnboardingSetupProviders().find(p=>p.id===id)||null; } function _getOnboardingSetupCategories(){ return (((ONBOARDING.status||{}).setup||{}).categories)||[]; } /** Render the provider
${banner}`; } function _renderOnboardingApiKeyField(){ // Renders the API-key input. For providers flagged `key_optional` in the // setup catalog (lmstudio, ollama, custom — typically self-hosted servers // that run keyless by default), the field shows an "(optional)" hint and // empty input is accepted on Continue. Pre-#1499-third-sub-bug-fix the // wizard required a non-empty string here even for keyless installs, which // forced users to type random gibberish to clear onboarding. const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider); const keyOptional=!!(provider&&provider.key_optional); const labelKey=keyOptional?'onboarding_api_key_label_optional':'onboarding_api_key_label'; const placeholderKey=keyOptional?'onboarding_api_key_placeholder_optional':'onboarding_api_key_placeholder'; const helpHtml=keyOptional?`

${esc(t('onboarding_api_key_help_keyless')||'')}

`:''; return `${helpHtml}`; } function _getOnboardingSelectedModel(){ return ONBOARDING.form.model||''; } function _renderOnboardingModelField(){ const choices=_getOnboardingProviderModelChoices(); if(ONBOARDING.form.provider==='custom'){ return `

${t('onboarding_custom_model_help')}

`; } const options=choices.map(m=>``).join(''); return `

${t('onboarding_workspace_help')}

`; } function _providerStatusLabel(system){ if(system.chat_ready) return t('onboarding_check_provider_ready'); if(system.provider_configured) return t('onboarding_check_provider_partial'); return t('onboarding_check_provider_pending'); } function _renderOnboardingBody(){ const body=$('onboardingBody'); if(!body||!ONBOARDING.status)return; const key=ONBOARDING.steps[ONBOARDING.step]; const system=ONBOARDING.status.system||{}; const settings=ONBOARDING.status.settings||{}; const setup=ONBOARDING.status.setup||{}; const nextBtn=$('onboardingNextBtn'); const backBtn=$('onboardingBackBtn'); if(backBtn) backBtn.style.display=ONBOARDING.step>0?'':'none'; if(nextBtn) nextBtn.textContent=key==='finish'?t('onboarding_open'):t('onboarding_continue'); if(key==='system'){ const hermesOk=system.hermes_found&&system.imports_ok; const setupOk=!!system.chat_ready; _setOnboardingNotice(system.provider_note|| (setupOk?t('onboarding_notice_system_ready'):t('onboarding_notice_system_unavailable')),setupOk?'success':(hermesOk?'info':'warn')); body.innerHTML=`
${t('onboarding_check_agent')}${hermesOk?t('onboarding_check_agent_ready'):t('onboarding_check_agent_missing')}
${t('onboarding_check_provider')}${_providerStatusLabel(system)}
${t('onboarding_check_password')}${settings.password_enabled?t('onboarding_check_password_enabled'):t('onboarding_check_password_disabled')}

${t('onboarding_config_file')} ${esc(system.config_path||t('onboarding_unknown'))}

${t('onboarding_env_file')} ${esc(system.env_path||t('onboarding_unknown'))}

${esc(system.provider_note||'')}

${system.current_provider?`

${t('onboarding_current_provider')} ${esc(system.current_provider)}${system.current_model?` — ${esc(system.current_model)}`:''}

`:''} ${system.current_base_url?`

${t('onboarding_base_url_label')} ${esc(system.current_base_url)}

`:''} ${system.missing_modules&&system.missing_modules.length?`

${t('onboarding_missing_imports')} ${esc(system.missing_modules.join(', '))}

`:''}
`; return; } if(key==='setup'){ const selectedId=ONBOARDING.form.provider; const groupedOptions=_renderProviderSelectOptions(selectedId); const provider=_getOnboardingSetupProvider(selectedId)||_getOnboardingSetupProviders()[0]||null; const showBaseUrl=provider&&provider.requires_base_url; const keyHelp=provider?`${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`:''; // OAuth provider path: configured via CLI, no API key input needed. const currentIsOauth=!!(ONBOARDING.status.setup||{}).current_is_oauth; const currentProviderName=((ONBOARDING.status.setup||{}).current||{}).provider||''; if(currentIsOauth){ const isReady=!!(ONBOARDING.status.system||{}).chat_ready; const providerLabel=esc(currentProviderName); if(isReady){ _setOnboardingNotice(t('onboarding_notice_setup_already_ready'),'success'); body.innerHTML=`
${t('onboarding_oauth_provider_ready_title')}

${t('onboarding_oauth_provider_ready_body').replace('{provider}',providerLabel)}

${t('onboarding_oauth_switch_hint')}

${_renderOnboardingApiKeyField()} ${_renderOnboardingBaseUrlField(showBaseUrl)}

${keyHelp}

`; } else { _setOnboardingNotice(t('onboarding_notice_setup_required'),'warn'); body.innerHTML=`
${t('onboarding_oauth_provider_not_ready_title')}

${t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel)}

${t('onboarding_oauth_switch_hint')}

${_renderOnboardingApiKeyField()} ${_renderOnboardingBaseUrlField(showBaseUrl)}

${keyHelp}

`; } return; } _setOnboardingNotice(system.chat_ready?t('onboarding_notice_setup_already_ready'):t('onboarding_notice_setup_required'),system.chat_ready?'success':'info'); body.innerHTML=` ${_renderOnboardingApiKeyField()} ${_renderOnboardingBaseUrlField(showBaseUrl)}

${keyHelp}

🔑
${t('oauth_login_codex')}

${t('onboarding_oauth_switch_hint')}

${showBaseUrl?`

${t('onboarding_base_url_help')}

`:''}

${esc(setup.unsupported_note||'')||''}

`; return; } if(key==='workspace'){ const workspaceOptions=_getOnboardingWorkspaceChoices().map(ws=>``).join(''); _setOnboardingNotice(t('onboarding_notice_workspace'), 'info'); body.innerHTML=` ${_renderOnboardingModelField()}`; const wsSel=$('onboardingWorkspaceSelect'); if(wsSel && ONBOARDING.form.workspace) wsSel.value=ONBOARDING.form.workspace; const modelSel=$('onboardingModelSelect'); if(modelSel && ONBOARDING.form.model) modelSel.value=ONBOARDING.form.model; return; } if(key==='password'){ _setOnboardingNotice(settings.password_enabled?t('onboarding_notice_password_enabled'):t('onboarding_notice_password_recommended'), settings.password_enabled?'success':'info'); body.innerHTML=`

${t('onboarding_password_help')}

`; return; } const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider); _setOnboardingNotice(t('onboarding_notice_finish'), 'success'); body.innerHTML=`
${t('onboarding_provider_label')}${esc((provider&&provider.label)||ONBOARDING.form.provider||t('onboarding_not_set'))}
${t('onboarding_model_label')}${esc(_getOnboardingSelectedModel()||t('onboarding_not_set'))}
${t('onboarding_workspace_label')}${esc(ONBOARDING.form.workspace||t('onboarding_not_set'))}
${t('onboarding_check_password')}${t(_getOnboardingPasswordSummaryKey(settings))}
${ONBOARDING.form.baseUrl?`

${t('onboarding_base_url_label')} ${esc(ONBOARDING.form.baseUrl)}

`:''}

${t('onboarding_finish_help')}

`; } function _getOnboardingPasswordSummaryKey(settings){ const hasExistingPassword=!!(settings&&settings.password_enabled); const hasNewPassword=!!((ONBOARDING.form.password||'').trim()); if(hasNewPassword) return hasExistingPassword?'onboarding_password_will_replace':'onboarding_password_will_enable'; return hasExistingPassword?'onboarding_password_keep_existing':'onboarding_password_remains_disabled'; } function syncOnboardingWorkspaceSelect(value){ ONBOARDING.form.workspace=value; const input=$('onboardingWorkspaceInput'); if(input) input.value=value; } function syncOnboardingProvider(value){ const provider=_getOnboardingSetupProvider(value); ONBOARDING.form.provider=value; if(provider){ if(!ONBOARDING.form.model || !_getOnboardingProviderModelChoices().some(m=>m.id===ONBOARDING.form.model) || value==='custom'){ ONBOARDING.form.model=provider.default_model||''; } if(provider.requires_base_url){ ONBOARDING.form.baseUrl=ONBOARDING.form.baseUrl||provider.default_base_url||''; }else{ ONBOARDING.form.baseUrl=provider.default_base_url||''; } } _renderOnboardingBody(); } async function loadOnboardingWizard(){ try{ const status=await api('/api/onboarding/status'); ONBOARDING.status=status; const current=((status.setup||{}).current)||{}; ONBOARDING.form.provider=current.provider||'openrouter'; ONBOARDING.form.workspace=(status.workspaces&&status.workspaces.last)||status.settings.default_workspace||''; ONBOARDING.form.model=status.settings.default_model||current.model||''; ONBOARDING.form.password=''; ONBOARDING.form.apiKey=''; ONBOARDING.form.baseUrl=current.base_url||''; ONBOARDING.active=!status.completed; if(!ONBOARDING.active) return false; $('onboardingOverlay').style.display='flex'; _renderOnboardingSteps(); _renderOnboardingBody(); return true; }catch(e){ console.warn('onboarding status failed',e); return false; } } function prevOnboardingStep(){ if(ONBOARDING.step===0)return; ONBOARDING.step--; _renderOnboardingSteps(); _renderOnboardingBody(); } async function _saveOnboardingProviderSetup(){ const provider=(ONBOARDING.form.provider||'').trim(); const model=(ONBOARDING.form.model||'').trim(); const apiKey=(ONBOARDING.form.apiKey||'').trim(); const baseUrl=(ONBOARDING.form.baseUrl||'').trim(); const current=_getOnboardingCurrentSetup(); const isUnchanged=current.provider===provider&&((current.model||'')===model)&&((current.base_url||'')===baseUrl); // Skip the POST when nothing changed. We also skip when the provider is // unsupported/OAuth-based and already working — chat_ready may be false for // providers not in the quick-setup list (e.g. minimax-cn) even though they are // fully configured. Posting in that case would either be a no-op (the server // just marks complete for unsupported providers) or could silently overwrite // config.yaml if the user accidentally changed the provider dropdown. const currentIsOauth=!!(ONBOARDING.status&&ONBOARDING.status.setup&&ONBOARDING.status.setup.current_is_oauth); if(isUnchanged && !apiKey && ((ONBOARDING.status.system||{}).chat_ready || currentIsOauth)) return; const body={provider,model}; if(apiKey) body.api_key=apiKey; if(baseUrl) body.base_url=baseUrl; const status=await api('/api/onboarding/setup',{method:'POST',body:JSON.stringify(body)}); ONBOARDING.status=status; } async function _saveOnboardingDefaults(){ const workspace=(ONBOARDING.form.workspace||'').trim(); const model=(ONBOARDING.form.model||'').trim(); const password=(ONBOARDING.form.password||'').trim(); if(!workspace) throw new Error(t('onboarding_error_choose_workspace')); if(!model) throw new Error(t('onboarding_error_choose_model')); const known=_getOnboardingWorkspaceChoices().some(ws=>ws.path===workspace); if(!known){ await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path:workspace})}); } // Model persisted by /api/onboarding/setup — no /api/default-model call needed here const body={default_workspace:workspace}; if(password) body._set_password=password; const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); if(ONBOARDING.status){ ONBOARDING.status.settings={...(ONBOARDING.status.settings||{}),password_enabled:!!saved.auth_enabled}; } localStorage.setItem('hermes-webui-model',model); if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect')); } async function _finishOnboarding(){ await _saveOnboardingProviderSetup(); await _saveOnboardingDefaults(); const done=await api('/api/onboarding/complete',{method:'POST',body:'{}'}); ONBOARDING.status=done; ONBOARDING.active=false; $('onboardingOverlay').style.display='none'; showToast(t('onboarding_complete')); await loadWorkspaceList(); if(typeof renderSessionList==='function') await renderSessionList(); if(!S.session && typeof newSession==='function'){ await newSession(true); await renderSessionList(); } } async function skipOnboarding(){ try{ // Mark onboarding completed server-side without changing any config await api('/api/onboarding/complete',{method:'POST',body:'{}'}); ONBOARDING.active=false; $('onboardingOverlay').style.display='none'; showToast(t('onboarding_skipped')||'Setup skipped'); }catch(e){ _setOnboardingNotice((e.message||String(e)),'warn'); } } async function nextOnboardingStep(){ try{ if(ONBOARDING.steps[ONBOARDING.step]==='setup'){ ONBOARDING.form.provider=(($('onboardingProviderSelect')||{}).value||ONBOARDING.form.provider||'').trim(); ONBOARDING.form.apiKey=(($('onboardingApiKeyInput')||{}).value||'').trim(); ONBOARDING.form.baseUrl=(($('onboardingBaseUrlInput')||{}).value||ONBOARDING.form.baseUrl||'').trim(); if(!ONBOARDING.form.provider) throw new Error(t('onboarding_error_provider_required')); if(ONBOARDING.form.provider==='custom' && !ONBOARDING.form.baseUrl) throw new Error(t('onboarding_error_base_url_required')); // For self-hosted providers (requires_base_url=True), gate Continue on a // successful probe of /models — otherwise the wizard would // happily persist an unreachable URL and finish in 200ms with no // outbound HTTP, exactly the bug in #1499. Run the probe synchronously // here, then check status; the probe is idempotent & cached on // (provider, baseUrl, apiKey) so this rarely triggers a second network // call when the user already saw a green banner. const cat=_getOnboardingSetupProvider(ONBOARDING.form.provider); if(cat&&cat.requires_base_url){ if(!ONBOARDING.form.baseUrl) throw new Error(t('onboarding_error_base_url_required')); await _runOnboardingProbe(); if(ONBOARDING.probe.status!=='ok'){ // Surface the same localized error string the inline banner shows. const msg=_onboardingProbeMessage(ONBOARDING.probe)||t('onboarding_error_probe_failed')||'Could not reach the configured base URL.'; throw new Error(msg); } } } if(ONBOARDING.steps[ONBOARDING.step]==='workspace'){ ONBOARDING.form.workspace=(($('onboardingWorkspaceInput')||{}).value||ONBOARDING.form.workspace||'').trim(); ONBOARDING.form.model=(($('onboardingModelInput')||{}).value||($('onboardingModelSelect')||{}).value||ONBOARDING.form.model||'').trim(); if(!ONBOARDING.form.workspace) throw new Error(t('onboarding_error_workspace_required')); if(!ONBOARDING.form.model) throw new Error(t('onboarding_error_model_required')); } if(ONBOARDING.steps[ONBOARDING.step]==='password'){ ONBOARDING.form.password=(($('onboardingPasswordInput')||{}).value||'').trim(); } if(ONBOARDING.step===ONBOARDING.steps.length-1){ await _finishOnboarding(); return; } ONBOARDING.step++; _renderOnboardingSteps(); _renderOnboardingBody(); }catch(e){ _setOnboardingNotice(e.message||String(e),'warn'); } } /* ── Codex OAuth device-code flow ── */ let _codexOAuthSSE=null; async function startCodexOAuth(){ const flowDiv=$('codexOAuthFlow'); const btn=$('codexOAuthBtn'); if(!flowDiv)return; if(btn){btn.disabled=true;btn.textContent='...';} flowDiv.style.display='block'; flowDiv.innerHTML=`
${t('oauth_codex_polling')}

Starting device-code flow…

`; try{ const resp=await api('/api/oauth/codex/start',{method:'POST'}); if(resp.error) throw new Error(resp.error); const{device_code,user_code,verification_uri}=resp; if(!device_code||!user_code||!verification_uri) throw new Error('Invalid OAuth response'); // Open verification URI in new tab window.open(verification_uri,'_blank'); // Show user code prominently flowDiv.innerHTML=`
📋
${t('oauth_codex_step1')}

${esc(verification_uri)}

${t('oauth_codex_step2')}

${esc(user_code)}

${t('oauth_codex_polling')}

`; // Connect to SSE poll endpoint const pollUrl=new URL('api/oauth/codex/poll?device_code='+encodeURIComponent(device_code),location.href); if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;} _codexOAuthSSE=new EventSource(pollUrl.href); _codexOAuthSSE.onmessage=function(ev){ let data; try{data=JSON.parse(ev.data);}catch(e){return;} if(data.status==='success'){ if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;} flowDiv.innerHTML=`
${t('oauth_codex_success')}

Token saved to credential pool. You can now use Codex as a provider.

`; if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');} showToast(t('oauth_codex_success')); // Refresh onboarding status in background loadOnboardingWizard().catch(()=>{}); }else if(data.status==='error'){ if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;} const isExpired=(data.error||'').includes('expired'); flowDiv.innerHTML=`
${isExpired?t('oauth_codex_expired'):t('oauth_codex_error')}

${esc(data.error||'Unknown error')}

`; if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');} } // 'polling' status — keep waiting }; _codexOAuthSSE.onerror=function(){ if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;} if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');} // Don't overwrite if already showing success/error if(!flowDiv.querySelector('.onboarding-oauth-ready')&&!flowDiv.querySelector('[style*="error"]')){ flowDiv.innerHTML=`
${t('oauth_codex_error')}

Connection lost. Please try again.

`; } }; }catch(e){ flowDiv.innerHTML=`
${t('oauth_codex_error')}

${esc(e.message||String(e))}

`; if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');} } }