feat(onboarding): add one-shot bootstrap and first-run setup wizard (#285)

Adds a bootstrap launcher and a blocking first-run onboarding wizard that guides
new users through minimum Hermes setup from the browser UI.

Supported provider flows: OpenRouter, Anthropic, OpenAI, custom OpenAI-compatible.
OAuth/terminal-first flows remain via 'hermes model'.

Security hardening applied during review:
- /api/onboarding/setup restricted to loopback when auth disabled
- Newline injection guard in _write_env_file
- esc() on setup.unsupported_note in onboarding.js
- Test isolation fix (send_key instead of bot_name in contamination test)
- Skip markers for PyYAML-dependent tests in agent-less environments

Tests: 693 passed (up from 679)

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: gabogabucho <gabogabucho@gmail.com>
This commit is contained in:
nesquena-hermes
2026-04-12 00:11:41 -07:00
committed by GitHub
parent f9663d2f1d
commit 31a721417e
15 changed files with 3088 additions and 1266 deletions

View File

@@ -387,6 +387,7 @@ function applyBotName(){
}
// Pre-load workspace list so sidebar name is correct from first render
await loadWorkspaceList();
await loadOnboardingWizard();
_initResizePanels();
const saved=localStorage.getItem('hermes-webui-session');
if(saved){

View File

@@ -193,6 +193,75 @@ const LOCALES = {
suggest_files: 'What files are in this workspace?',
suggest_schedule: "What's on my schedule today?",
suggest_plan: 'Help me plan a small project.',
// onboarding
onboarding_badge: 'FIRST RUN',
onboarding_title: 'Welcome to Hermes Web UI',
onboarding_lead: 'A quick guided setup will verify Hermes, save a real provider configuration, choose a workspace and model, and optionally protect the app with a password.',
onboarding_back: 'Back',
onboarding_continue: 'Continue',
onboarding_open: 'Open Hermes',
onboarding_step_system_title: 'System check',
onboarding_step_system_desc: 'Verify Hermes Agent and config visibility.',
onboarding_step_setup_title: 'Provider setup',
onboarding_step_setup_desc: 'Save the minimum Hermes provider config.',
onboarding_step_workspace_title: 'Workspace + model',
onboarding_step_workspace_desc: 'Pick defaults for new sessions and chat.',
onboarding_step_password_title: 'Optional password',
onboarding_step_password_desc: 'Protect the Web UI before sharing it.',
onboarding_step_finish_title: 'Finish',
onboarding_step_finish_desc: 'Review and enter the app.',
onboarding_notice_system_ready: 'Hermes Agent looks reachable from the Web UI.',
onboarding_notice_system_unavailable: 'Hermes Agent is not fully available yet. Bootstrap can install it, but provider setup may still require a terminal.',
onboarding_check_agent: 'Hermes Agent',
onboarding_check_agent_ready: 'Detected and importable',
onboarding_check_agent_missing: 'Missing or partially importable',
onboarding_check_password: 'Password',
onboarding_check_password_enabled: 'Already enabled',
onboarding_check_password_disabled: 'Not enabled yet',
onboarding_check_provider: 'Provider config',
onboarding_check_provider_ready: 'Ready to chat',
onboarding_check_provider_partial: 'Saved but incomplete',
onboarding_check_provider_pending: 'Needs verification',
onboarding_config_file: 'Config file:',
onboarding_env_file: '.env file:',
onboarding_unknown: 'Unknown',
onboarding_current_provider: 'Current setup:',
onboarding_missing_imports: 'Missing imports:',
onboarding_notice_setup_required: 'Choose a simple provider path here. Advanced OAuth flows still belong in the Hermes CLI for now.',
onboarding_notice_setup_already_ready: 'A working Hermes provider setup is already detected. You can keep it or replace it here.',
onboarding_notice_workspace: 'These values reuse the same settings APIs as the normal app.',
onboarding_workspace_label: 'Workspace',
onboarding_workspace_or_path: 'Or enter a workspace path',
onboarding_workspace_placeholder: '/home/you/workspace',
onboarding_provider_label: 'Setup mode',
onboarding_quick_setup_badge: 'quick setup',
onboarding_api_key_label: 'API key',
onboarding_api_key_placeholder: 'Leave blank to keep an existing saved key',
onboarding_api_key_help_prefix: 'Saved as a secret in your Hermes .env file using',
onboarding_base_url_label: 'Base URL',
onboarding_base_url_placeholder: 'https://your-endpoint.example/v1',
onboarding_base_url_help: 'Use this for OpenAI-compatible routers, self-hosted servers, LiteLLM, Ollama, LM Studio, vLLM, or similar endpoints.',
onboarding_model_label: 'Default model',
onboarding_workspace_help: 'Pick the model Hermes should use for new chats after setup completes.',
onboarding_custom_model_placeholder: 'your-model-name',
onboarding_custom_model_help: 'For custom endpoints, enter the exact model ID your server expects.',
onboarding_notice_password_enabled: 'A password is already configured. Enter a new one only if you want to replace it.',
onboarding_notice_password_recommended: 'Optional but recommended if you will expose the UI beyond localhost.',
onboarding_password_label: 'Password (optional)',
onboarding_password_placeholder: 'Leave blank to skip',
onboarding_password_help: 'Passwords are stored through the existing settings API and hashed server-side.',
onboarding_notice_finish: 'You can reopen Settings later to change any of this.',
onboarding_not_set: 'Not set',
onboarding_password_will_enable: 'Will be enabled',
onboarding_password_skipped: 'Skipped for now',
onboarding_finish_help: 'Finishing stores <code>onboarding_completed</code> in settings and drops you into the normal app.',
onboarding_error_choose_workspace: 'Choose a workspace before continuing.',
onboarding_error_choose_model: 'Choose a model before continuing.',
onboarding_error_provider_required: 'Choose a setup mode before continuing.',
onboarding_error_base_url_required: 'Base URL is required for custom endpoints.',
onboarding_error_workspace_required: 'Workspace is required.',
onboarding_error_model_required: 'Model is required.',
onboarding_complete: 'Onboarding complete',
},
es: {
@@ -384,6 +453,75 @@ const LOCALES = {
suggest_files: '¿Qué archivos hay en este espacio de trabajo?',
suggest_schedule: '¿Qué tengo hoy en mi agenda?',
suggest_plan: 'Ayúdame a planificar un proyecto pequeño.',
// onboarding
onboarding_badge: 'PRIMER USO',
onboarding_title: 'Bienvenido a Hermes Web UI',
onboarding_lead: 'Una guía rápida verificará Hermes, guardará una configuración real del proveedor, elegirá un espacio de trabajo y un modelo, y opcionalmente protegerá la app con una contraseña.',
onboarding_back: 'Atrás',
onboarding_continue: 'Continuar',
onboarding_open: 'Abrir Hermes',
onboarding_step_system_title: 'Comprobación del sistema',
onboarding_step_system_desc: 'Verifica Hermes Agent y la visibilidad de la configuración.',
onboarding_step_setup_title: 'Configuración del proveedor',
onboarding_step_setup_desc: 'Guarda la configuración mínima real de Hermes.',
onboarding_step_workspace_title: 'Espacio de trabajo + modelo',
onboarding_step_workspace_desc: 'Elige los valores predeterminados para nuevas sesiones y chats.',
onboarding_step_password_title: 'Contraseña opcional',
onboarding_step_password_desc: 'Protege la Web UI antes de compartirla.',
onboarding_step_finish_title: 'Finalizar',
onboarding_step_finish_desc: 'Revisa todo y entra en la app.',
onboarding_notice_system_ready: 'Parece que Hermes Agent está accesible desde la Web UI.',
onboarding_notice_system_unavailable: 'Hermes Agent todavía no está totalmente disponible. Bootstrap puede instalarlo, pero la configuración del proveedor quizá aún requiera una terminal.',
onboarding_check_agent: 'Hermes Agent',
onboarding_check_agent_ready: 'Detectado e importable',
onboarding_check_agent_missing: 'Falta o solo es parcialmente importable',
onboarding_check_password: 'Contraseña',
onboarding_check_password_enabled: 'Ya está activada',
onboarding_check_password_disabled: 'Todavía no está activada',
onboarding_check_provider: 'Configuración del proveedor',
onboarding_check_provider_ready: 'Listo para chatear',
onboarding_check_provider_partial: 'Guardado pero incompleto',
onboarding_check_provider_pending: 'Necesita verificación',
onboarding_config_file: 'Archivo de configuración:',
onboarding_env_file: 'Archivo .env:',
onboarding_unknown: 'Desconocido',
onboarding_current_provider: 'Configuración actual:',
onboarding_missing_imports: 'Importaciones faltantes:',
onboarding_notice_setup_required: 'Elige aquí una ruta simple de proveedor. Los flujos OAuth avanzados siguen siendo del CLI de Hermes por ahora.',
onboarding_notice_setup_already_ready: 'Ya se detectó una configuración funcional del proveedor de Hermes. Puedes conservarla o reemplazarla aquí.',
onboarding_notice_workspace: 'Estos valores reutilizan las mismas APIs de configuración que la app normal.',
onboarding_workspace_label: 'Espacio de trabajo',
onboarding_workspace_or_path: 'O introduce la ruta de un espacio de trabajo',
onboarding_workspace_placeholder: '/home/you/workspace',
onboarding_provider_label: 'Modo de configuración',
onboarding_quick_setup_badge: 'configuración rápida',
onboarding_api_key_label: 'API key',
onboarding_api_key_placeholder: 'Déjala en blanco para conservar una key ya guardada',
onboarding_api_key_help_prefix: 'Se guarda como secreto en tu archivo .env de Hermes usando',
onboarding_base_url_label: 'Base URL',
onboarding_base_url_placeholder: 'https://tu-endpoint.example/v1',
onboarding_base_url_help: 'Úsalo para routers OpenAI-compatible, servidores autoalojados, LiteLLM, Ollama, LM Studio, vLLM o endpoints parecidos.',
onboarding_model_label: 'Modelo predeterminado',
onboarding_workspace_help: 'Elige el modelo que Hermes debe usar para nuevos chats cuando termine la configuración.',
onboarding_custom_model_placeholder: 'tu-modelo',
onboarding_custom_model_help: 'Para endpoints personalizados, introduce el identificador exacto del modelo que espera tu servidor.',
onboarding_notice_password_enabled: 'Ya hay una contraseña configurada. Introduce una nueva solo si quieres reemplazarla.',
onboarding_notice_password_recommended: 'Es opcional, pero recomendable si vas a exponer la UI más allá de localhost.',
onboarding_password_label: 'Contraseña (opcional)',
onboarding_password_placeholder: 'Déjala en blanco para omitirla',
onboarding_password_help: 'Las contraseñas se guardan mediante la API de configuración existente y se hashean en el servidor.',
onboarding_notice_finish: 'Puedes volver a abrir Configuración más tarde para cambiar cualquiera de estos valores.',
onboarding_not_set: 'Sin definir',
onboarding_password_will_enable: 'Se activará',
onboarding_password_skipped: 'Se omitirá por ahora',
onboarding_finish_help: 'Al finalizar se guarda <code>onboarding_completed</code> en la configuración y entras en la app normal.',
onboarding_error_choose_workspace: 'Elige un espacio de trabajo antes de continuar.',
onboarding_error_choose_model: 'Elige un modelo antes de continuar.',
onboarding_error_provider_required: 'Elige un modo de configuración antes de continuar.',
onboarding_error_base_url_required: 'La base URL es obligatoria para endpoints personalizados.',
onboarding_error_workspace_required: 'El espacio de trabajo es obligatorio.',
onboarding_error_model_required: 'El modelo es obligatorio.',
onboarding_complete: 'Onboarding completado',
},
de: {

View File

@@ -347,6 +347,26 @@
</div>
</aside>
</div>
<div class="onboarding-overlay" id="onboardingOverlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle">
<div class="onboarding-card">
<div class="onboarding-shell">
<div class="onboarding-sidebar">
<div class="onboarding-badge" data-i18n="onboarding_badge">FIRST RUN</div>
<h2 id="onboardingTitle" data-i18n="onboarding_title">Welcome to Hermes Web UI</h2>
<p id="onboardingLead" data-i18n="onboarding_lead">A quick guided setup will check your Hermes install, choose a workspace and model, and optionally protect the app with a password.</p>
<div class="onboarding-steps" id="onboardingSteps"></div>
</div>
<div class="onboarding-main">
<div class="onboarding-status" id="onboardingNotice"></div>
<div class="onboarding-body" id="onboardingBody"></div>
<div class="onboarding-actions">
<button class="sm-btn" id="onboardingBackBtn" onclick="prevOnboardingStep()" style="display:none" data-i18n="onboarding_back">Back</button>
<button class="sm-btn" id="onboardingNextBtn" onclick="nextOnboardingStep()" style="font-weight:700;color:var(--blue);border-color:rgba(124,185,255,.32)" data-i18n="onboarding_continue">Continue</button>
</div>
</div>
</div>
</div>
</div>
<div class="settings-overlay" id="settingsOverlay" style="display:none">
<div class="settings-panel">
<div class="settings-header">
@@ -471,6 +491,7 @@
<script src="/static/commands.js"></script>
<script src="/static/messages.js"></script>
<script src="/static/panels.js"></script>
<script src="/static/onboarding.js"></script>
<script src="/static/boot.js"></script>
<div class="app-dialog-overlay" id="appDialogOverlay" style="display:none" aria-hidden="true">
<div class="app-dialog" id="appDialog" role="dialog" aria-modal="true" aria-labelledby="appDialogTitle" aria-describedby="appDialogDesc">

306
static/onboarding.js Normal file
View File

@@ -0,0 +1,306 @@
const ONBOARDING={status:null,step:0,steps:['system','setup','workspace','password','finish'],form:{provider:'openrouter',workspace:'',model:'',password:'',apiKey:'',baseUrl:''},active:false};
function _getOnboardingSetupProviders(){
return (((ONBOARDING.status||{}).setup||{}).providers)||[];
}
function _getOnboardingSetupProvider(id){
return _getOnboardingSetupProviders().find(p=>p.id===id)||null;
}
function _getOnboardingCurrentSetup(){
return (((ONBOARDING.status||{}).setup||{}).current)||{};
}
function _onboardingStepMeta(key){
return ({
system:{title:t('onboarding_step_system_title'),desc:t('onboarding_step_system_desc')},
setup:{title:t('onboarding_step_setup_title'),desc:t('onboarding_step_setup_desc')},
workspace:{title:t('onboarding_step_workspace_title'),desc:t('onboarding_step_workspace_desc')},
password:{title:t('onboarding_step_password_title'),desc:t('onboarding_step_password_desc')},
finish:{title:t('onboarding_step_finish_title'),desc:t('onboarding_step_finish_desc')}
})[key];
}
function _renderOnboardingSteps(){
const wrap=$('onboardingSteps');
if(!wrap)return;
wrap.innerHTML='';
ONBOARDING.steps.forEach((key,idx)=>{
const meta=_onboardingStepMeta(key);
const item=document.createElement('div');
item.className='onboarding-step'+(idx===ONBOARDING.step?' active':idx<ONBOARDING.step?' done':'');
item.innerHTML=`<div class="onboarding-step-index">${idx+1}</div><div><div class="onboarding-step-title">${meta.title}</div><div class="onboarding-step-desc">${meta.desc}</div></div>`;
wrap.appendChild(item);
});
}
function _setOnboardingNotice(msg,kind='info'){
const el=$('onboardingNotice');
if(!el)return;
if(!msg){el.style.display='none';el.textContent='';el.className='onboarding-status';return;}
el.style.display='block';
el.className='onboarding-status '+kind;
el.textContent=msg;
}
function _getOnboardingWorkspaceChoices(){
const items=((ONBOARDING.status||{}).workspaces||{}).items||[];
return items.length?items:[{name:'Home',path:ONBOARDING.form.workspace||''}];
}
function _getOnboardingProviderModelChoices(){
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider);
return provider?(provider.models||[]):[];
}
function _getOnboardingSelectedModel(){
return ONBOARDING.form.model||'';
}
function _renderOnboardingModelField(){
const choices=_getOnboardingProviderModelChoices();
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>`;
}
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>`;
}
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=`
<div class="onboarding-panel-grid">
<div class="onboarding-check ${hermesOk?'ok':'warn'}"><strong>${t('onboarding_check_agent')}</strong><span>${hermesOk?t('onboarding_check_agent_ready'):t('onboarding_check_agent_missing')}</span></div>
<div class="onboarding-check ${(setupOk?'ok':system.provider_configured?'warn':'muted')}"><strong>${t('onboarding_check_provider')}</strong><span>${_providerStatusLabel(system)}</span></div>
<div class="onboarding-check ${(settings.password_enabled?'ok':'muted')}"><strong>${t('onboarding_check_password')}</strong><span>${settings.password_enabled?t('onboarding_check_password_enabled'):t('onboarding_check_password_disabled')}</span></div>
</div>
<div class="onboarding-copy">
<p><strong>${t('onboarding_config_file')}</strong> ${esc(system.config_path||t('onboarding_unknown'))}</p>
<p><strong>${t('onboarding_env_file')}</strong> ${esc(system.env_path||t('onboarding_unknown'))}</p>
<p>${esc(system.provider_note||'')}</p>
${system.current_provider?`<p><strong>${t('onboarding_current_provider')}</strong> ${esc(system.current_provider)}${system.current_model?`${esc(system.current_model)}`:''}</p>`:''}
${system.current_base_url?`<p><strong>${t('onboarding_base_url_label')}</strong> ${esc(system.current_base_url)}</p>`:''}
${system.missing_modules&&system.missing_modules.length?`<p><strong>${t('onboarding_missing_imports')}</strong> ${esc(system.missing_modules.join(', '))}</p>`:''}
</div>`;
return;
}
if(key==='setup'){
const providers=_getOnboardingSetupProviders();
const options=providers.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`).join('');
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider)||providers[0]||null;
const showBaseUrl=provider&&provider.requires_base_url;
const keyHelp=provider?`${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`:'';
_setOnboardingNotice(system.chat_ready?t('onboarding_notice_setup_already_ready'):t('onboarding_notice_setup_required'),system.chat_ready?'success':'info');
body.innerHTML=`
<label class="onboarding-field">
<span>${t('onboarding_provider_label')}</span>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
</label>
<label class="onboarding-field">
<span>${t('onboarding_api_key_label')}</span>
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
</label>
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
<p class="onboarding-copy">${keyHelp}</p>
${showBaseUrl?`<p class="onboarding-copy">${t('onboarding_base_url_help')}</p>`:''}
<p class="onboarding-copy">${esc(setup.unsupported_note||'')||''}</p>`;
const providerSel=$('onboardingProviderSelect');
if(providerSel) providerSel.value=ONBOARDING.form.provider;
return;
}
if(key==='workspace'){
const workspaceOptions=_getOnboardingWorkspaceChoices().map(ws=>`<option value="${esc(ws.path)}">${esc(ws.name||ws.path)}${esc(ws.path)}</option>`).join('');
_setOnboardingNotice(t('onboarding_notice_workspace'), 'info');
body.innerHTML=`
<label class="onboarding-field">
<span>${t('onboarding_workspace_label')}</span>
<select id="onboardingWorkspaceSelect" onchange="syncOnboardingWorkspaceSelect(this.value)">${workspaceOptions}</select>
</label>
<label class="onboarding-field">
<span>${t('onboarding_workspace_or_path')}</span>
<input id="onboardingWorkspaceInput" value="${esc(ONBOARDING.form.workspace||'')}" placeholder="${t('onboarding_workspace_placeholder')}" oninput="ONBOARDING.form.workspace=this.value">
</label>
${_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=`
<label class="onboarding-field">
<span>${t('onboarding_password_label')}</span>
<input id="onboardingPasswordInput" type="password" value="${esc(ONBOARDING.form.password||'')}" placeholder="${t('onboarding_password_placeholder')}" oninput="ONBOARDING.form.password=this.value">
</label>
<p class="onboarding-copy">${t('onboarding_password_help')}</p>`;
return;
}
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider);
_setOnboardingNotice(t('onboarding_notice_finish'), 'success');
body.innerHTML=`
<div class="onboarding-summary">
<div><strong>${t('onboarding_provider_label')}</strong><span>${esc((provider&&provider.label)||ONBOARDING.form.provider||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_model_label')}</strong><span>${esc(_getOnboardingSelectedModel()||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_workspace_label')}</strong><span>${esc(ONBOARDING.form.workspace||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_check_password')}</strong><span>${ONBOARDING.form.password?t('onboarding_password_will_enable'):t('onboarding_password_skipped')}</span></div>
</div>
${ONBOARDING.form.baseUrl?`<p class="onboarding-copy"><strong>${t('onboarding_base_url_label')}</strong> ${esc(ONBOARDING.form.baseUrl)}</p>`:''}
<p class="onboarding-copy">${t('onboarding_finish_help')}</p>`;
}
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||'openai/gpt-5.4-mini';
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);
if(isUnchanged && !apiKey && (ONBOARDING.status.system||{}).chat_ready) 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})});
}
const body={default_workspace:workspace,default_model:model};
if(password) body._set_password=password;
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
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 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'));
}
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');
}
}

View File

@@ -178,6 +178,44 @@
.app-dialog-btn:focus-visible,.app-dialog-close:focus-visible{outline:2px solid rgba(124,185,255,.85);outline-offset:2px;}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--surface);backdrop-filter:blur(12px);border:1px solid rgba(124,185,255,0.25);color:var(--text);font-size:13px;padding:10px 20px;border-radius:12px;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 4px 20px rgba(0,0,0,.3);letter-spacing:.01em;}
.toast.show{opacity:1;transform:translateX(-50%) translateY(-2px);}
.onboarding-overlay{position:fixed;inset:0;z-index:1050;background:rgba(7,12,19,.78);backdrop-filter:blur(8px);display:none;align-items:center;justify-content:center;padding:24px;}
.onboarding-card{width:min(980px,100%);max-height:min(760px,94vh);overflow:auto;border:1px solid rgba(124,185,255,.16);border-radius:24px;background:linear-gradient(180deg,rgba(20,30,44,.98),rgba(11,17,27,.98));box-shadow:0 24px 80px rgba(0,0,0,.45);}
.onboarding-shell{display:grid;grid-template-columns:minmax(240px,300px) minmax(0,1fr);}
.onboarding-sidebar{padding:28px 24px;border-right:1px solid var(--border);background:linear-gradient(180deg,rgba(124,185,255,.08),rgba(124,185,255,.02));}
.onboarding-sidebar h2{font-size:26px;line-height:1.15;margin-top:10px;margin-bottom:12px;letter-spacing:-.03em;}
.onboarding-badge{display:inline-flex;padding:4px 10px;border-radius:999px;font-size:10px;font-weight:800;letter-spacing:.12em;background:rgba(124,185,255,.14);color:var(--blue);}
.onboarding-sidebar p{font-size:13px;color:var(--muted);line-height:1.7;}
.onboarding-steps{display:flex;flex-direction:column;gap:10px;margin-top:24px;}
.onboarding-step{display:flex;gap:12px;align-items:flex-start;padding:10px 12px;border-radius:14px;border:1px solid transparent;background:rgba(255,255,255,.02);}
.onboarding-step.active{border-color:rgba(124,185,255,.25);background:rgba(124,185,255,.08);}
.onboarding-step.done{background:rgba(201,168,76,.08);}
.onboarding-step-index{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;background:rgba(255,255,255,.08);color:var(--text);flex-shrink:0;}
.onboarding-step.done .onboarding-step-index{background:rgba(201,168,76,.16);color:var(--gold);}
.onboarding-step.active .onboarding-step-index{background:rgba(124,185,255,.18);color:var(--blue);}
.onboarding-step-title{font-size:13px;font-weight:700;color:var(--text);}
.onboarding-step-desc{font-size:11px;color:var(--muted);margin-top:2px;line-height:1.5;}
.onboarding-main{padding:28px 28px 24px;display:flex;flex-direction:column;gap:18px;min-width:0;}
.onboarding-status{display:none;padding:12px 14px;border-radius:12px;font-size:13px;line-height:1.6;border:1px solid var(--border2);background:rgba(255,255,255,.04);}
.onboarding-status.info{color:var(--text);}
.onboarding-status.success{color:var(--blue);border-color:rgba(124,185,255,.3);background:rgba(124,185,255,.08);}
.onboarding-status.warn{color:var(--gold);border-color:rgba(201,168,76,.28);background:rgba(201,168,76,.08);}
.onboarding-body{display:flex;flex-direction:column;gap:16px;}
.onboarding-panel-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;}
.onboarding-check{padding:14px;border-radius:14px;border:1px solid var(--border);background:rgba(255,255,255,.03);display:flex;flex-direction:column;gap:5px;}
.onboarding-check strong{font-size:13px;color:var(--text);}
.onboarding-check span{font-size:12px;color:var(--muted);line-height:1.5;}
.onboarding-check.ok{border-color:rgba(124,185,255,.28);background:rgba(124,185,255,.08);}
.onboarding-check.warn{border-color:rgba(201,168,76,.25);background:rgba(201,168,76,.08);}
.onboarding-field{display:flex;flex-direction:column;gap:6px;}
.onboarding-field span{font-size:12px;font-weight:700;color:var(--text);}
.onboarding-field input,.onboarding-field select{margin-bottom:0;padding:10px 12px;border-radius:10px;font-size:13px;background:var(--input-bg);border:1px solid var(--border2);color:var(--text);}
.onboarding-copy{font-size:12px;color:var(--muted);line-height:1.7;}
.onboarding-summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;}
.onboarding-summary div{padding:14px;border-radius:14px;background:rgba(255,255,255,.03);border:1px solid var(--border);display:flex;flex-direction:column;gap:5px;}
.onboarding-summary strong{font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);}
.onboarding-summary span{font-size:13px;color:var(--text);word-break:break-word;}
.onboarding-actions{display:flex;justify-content:space-between;gap:10px;margin-top:auto;}
.onboarding-actions .sm-btn{padding:10px 16px;}
.reconnect-banner{display:none;background:var(--surface);border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
.reconnect-banner.visible{display:flex;}
.reconnect-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;}
@@ -508,6 +546,12 @@
.tool-card{margin-left:0!important;font-size:12px;}
/* Settings modal */
.settings-panel{width:95vw;max-width:95vw;min-height:min(580px,88vh);max-height:92vh;}
.onboarding-overlay{padding:12px;}
.onboarding-shell{grid-template-columns:1fr;}
.onboarding-sidebar{border-right:none;border-bottom:1px solid var(--border);padding:22px 18px;}
.onboarding-main{padding:20px 18px 18px;}
.onboarding-actions{flex-direction:column-reverse;}
.onboarding-actions .sm-btn{width:100%;min-height:44px;}
/* Login page responsive */
.card{width:90vw;max-width:320px;padding:28px 24px;}
/* Workspace panel mobile close button */