// ── Slash commands ────────────────────────────────────────────────────────── // Built-in commands intercepted before send(). Each command runs locally // (no round-trip to the agent) and shows feedback via toast or local message. const COMMANDS=[ // noEcho:true = action-only commands that don't produce a chat response. // Commands without noEcho get a user message echoed to the chat (#840). {name:'help', desc:t('cmd_help'), fn:cmdHelp}, {name:'clear', desc:t('cmd_clear'), fn:cmdClear, noEcho:true}, {name:'compress', desc:t('cmd_compress'), fn:cmdCompress, arg:'[focus topic]', noEcho:true}, {name:'compact', desc:t('cmd_compact_alias'), fn:cmdCompact, noEcho:true}, {name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name', subArgs:'models', noEcho:true}, {name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name', noEcho:true}, {name:'terminal', desc:t('cmd_terminal'), fn:cmdTerminal, noEcho:true}, {name:'new', desc:t('cmd_new'), fn:cmdNew, noEcho:true}, {name:'usage', desc:t('cmd_usage'), fn:cmdUsage, noEcho:true}, {name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name', noEcho:true}, {name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name', subArgs:'personalities'}, {name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'}, {name:'use', desc:t('cmd_use'), fn:cmdUse, arg:'skill-name', subArgs:'skills', noEcho:true}, {name:'stop', desc:t('cmd_stop'), fn:cmdStop, noEcho:true}, {name:'goal', desc:t('cmd_goal'), fn:cmdGoal, arg:'[status|pause|resume|clear|text]', subArgs:['status','pause','resume','clear']}, {name:'queue', desc:t('cmd_queue'), fn:cmdQueue, arg:'message', noEcho:true}, {name:'interrupt', desc:t('cmd_interrupt'), fn:cmdInterrupt, arg:'message', noEcho:true}, {name:'steer', desc:t('cmd_steer'), fn:cmdSteer, arg:'message', noEcho:true}, {name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'}, {name:'retry', desc:t('cmd_retry'), fn:cmdRetry, noEcho:true}, {name:'undo', desc:t('cmd_undo'), fn:cmdUndo, noEcho:true}, {name:'btw', desc:t('cmd_btw'), fn:cmdBtw, arg:'question', noEcho:true}, {name:'background',desc:t('cmd_background'),fn:cmdBackground,arg:'prompt', noEcho:true}, {name:'status', desc:t('cmd_status'), fn:cmdStatus}, {name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true}, {name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh'], noEcho:true}, {name:'yolo', desc:t('cmd_yolo'), fn:cmdYolo, noEcho:true}, {name:'branch', desc:t('cmd_branch'), fn:cmdBranch, arg:'[name]', noEcho:true}, ]; const SLASH_SUBARG_SOURCES={ model:{desc:t('cmd_model'), subArgs:'models'}, personality:{desc:t('cmd_personality'), subArgs:'personalities'}, }; function parseCommand(text){ if(!text.startsWith('/'))return null; const parts=text.slice(1).split(/\s+/); const name=parts[0].toLowerCase(); const args=parts.slice(1).join(' ').trim(); return {name,args}; } const DESKTOP_COMPANION_EXTENSION_ID='desktop-companion'; const DESKTOP_COMPANION_NAME='Desktop Companion'; const DESKTOP_COMPANION_INSTALL_PATH='Settings -> Extensions -> Gallery -> Desktop Companion'; const DESKTOP_COMPANION_SETUP_GUIDE_URL='https://github.com/franksong2702/hermes-webui-desktop-companion#after-gallery-install'; const DESKTOP_COMPANION_LOCAL_APP_LABEL='Desktop Companion app'; function _getDesktopCompanionStatusGlobal(){ if(typeof window==='undefined') return null; return window.__HERMES_WEBUI_DESKTOP_COMPANION_STATUS__||null; } function _getDesktopCompanionExtensionStatus(status){ return Array.isArray(status&&status.extensions) ? status.extensions.find(ext=>String(ext&&ext.id||'')===DESKTOP_COMPANION_EXTENSION_ID)||null : null; } function _petSlashCommandArgs(rawCommandText){ const parsed=parseCommand(String(rawCommandText||'').trim()); return parsed&&parsed.name==='pet' ? parsed.args : String(rawCommandText||'').trim().replace(/^\/pet\b\s*/i,'').trim(); } function _desktopCompanionMissingMessage(){ return `${DESKTOP_COMPANION_NAME} is not installed yet. Install it from ${DESKTOP_COMPANION_INSTALL_PATH}, then follow the setup guide: ${DESKTOP_COMPANION_SETUP_GUIDE_URL}. The guide also shows how to start the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}.`; } function _desktopCompanionDisabledMessage(){ return `${DESKTOP_COMPANION_NAME} is installed but disabled. Enable it in Settings -> Extensions, then reload WebUI if you just changed that and start the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}. Setup guide: ${DESKTOP_COMPANION_SETUP_GUIDE_URL}`; } function _desktopCompanionReloadMessage(){ return `${DESKTOP_COMPANION_NAME} is enabled, but the adapter status is not loaded yet. Reload WebUI if you just enabled it, then start the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}.`; } function _desktopCompanionConnectMessage(){ return `${DESKTOP_COMPANION_NAME} is enabled, but the local app is not connected yet. Start or connect the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}, then retry /pet. Setup guide: ${DESKTOP_COMPANION_SETUP_GUIDE_URL}`; } function _desktopCompanionStatusUnavailableMessage(){ return `${DESKTOP_COMPANION_NAME} status is unavailable right now. Reload WebUI or check your connection, then retry /pet.`; } function _desktopCompanionUnavailableMessage(){ return `${DESKTOP_COMPANION_NAME} is installed and connected, but /pet is not available yet in this Desktop Companion version. Update the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}, then follow the setup guide: ${DESKTOP_COMPANION_SETUP_GUIDE_URL}.`; } function _desktopCompanionHookErrorMessage(){ return `${DESKTOP_COMPANION_NAME} is installed and connected, but it hit an error while handling /pet. Check the browser console and the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}, then retry /pet.`; } async function handlePetSlashCommand(rawCommandText,meta){ const command=String(rawCommandText||''); const commandName=String((meta&&meta.name)||'pet').trim()||'pet'; const args=_petSlashCommandArgs(command); let status; try{ status=await api('/api/extensions/status'); }catch(_e){ return {handled:false,message:_desktopCompanionStatusUnavailableMessage()}; } const companion=_getDesktopCompanionExtensionStatus(status); if(!companion){ return {handled:false,message:_desktopCompanionMissingMessage()}; } if(companion.effective_enabled!==true){ return {handled:false,message:_desktopCompanionDisabledMessage()}; } const companionStatus=_getDesktopCompanionStatusGlobal(); if(!companionStatus){ return {handled:false,message:_desktopCompanionReloadMessage()}; } if(companionStatus.connected!==true){ return {handled:false,message:_desktopCompanionConnectMessage()}; } const hook=typeof window!=='undefined'&&window.__hermesHandlePetSlashCommand; if(typeof hook==='function'){ try{ const result=await hook({ command, args, source:'webui-slash-command', metadata:{name:commandName}, }); if(result){ return { handled:true, message:result&&typeof result==='object'&&'message' in result ? String(result.message??'') : '', }; } }catch(_e){ if(typeof console!=='undefined'&&console.error){ console.error('[hermes] Desktop Companion /pet hook error:',_e); } return {handled:false,message:_desktopCompanionHookErrorMessage()}; } } return {handled:false,message:_desktopCompanionUnavailableMessage()}; } function executeCommand(text){ const parsed=parseCommand(text); if(!parsed)return null; const cmd=COMMANDS.find(c=>c.name===parsed.name); if(!cmd)return null; // A handler may return `false` to opt out of interception — e.g. /reasoning // with an effort level falls through so the agent's own handler sees it, // preserving the pre-existing pass-through behaviour for that subcommand. if(cmd.fn(parsed.args)===false)return null; // Return noEcho flag so send() knows whether to echo the command as a user message (#840). return {noEcho:!!cmd.noEcho}; } function getMatchingCommands(prefix){ const q=prefix.toLowerCase(); const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'})); const seen=new Set(matches.map(c=>c.name)); const reserved=_getReservedSlashCommandSlugs(); for(const [name, spec] of Object.entries(SLASH_SUBARG_SOURCES)){ if(!name.startsWith(q)||seen.has(name))continue; matches.push({ name, desc:spec.desc, arg:'name', source:'subarg-command', }); seen.add(name); } if('pet'.startsWith(q)&&!seen.has('pet')){ const petMeta=Array.isArray(_agentCommandCache) ? _agentCommandCache.find(cmd=>String(cmd&&cmd.name||'').toLowerCase()==='pet') : null; matches.push({ name:'pet', desc:String((petMeta&&petMeta.description)||'Desktop Companion command').trim()||'Desktop Companion command', source:'agent', }); seen.add('pet'); } // Include agent/plugin commands from /api/commands metadata for(const cmd of (_agentCommandCache||[])){ const name=String(cmd&&cmd.name||'').toLowerCase(); if(!name.startsWith(q)||seen.has(name))continue; if(cmd.cli_only&&name!=='pet')continue; matches.push({ name, desc:String(cmd&&cmd.description||'').trim()||'Agent command', source:cmd.category==='Plugin'?'plugin':'agent', }); seen.add(name); } if(_agentCommandCacheReady){ for(const bundle of _bundleCommandCache){ if(!bundle.name.startsWith(q)||seen.has(bundle.name)||reserved.has(bundle.name))continue; matches.push(bundle); seen.add(bundle.name); } } for(const skill of _skillCommandCache){ if(!skill.name.startsWith(q)||seen.has(skill.name)||reserved.has(skill.name))continue; matches.push(skill); seen.add(skill.name); } return matches; } let _forcedSkillDirectivePending=null; let _slashModelCache=null; let _slashModelCachePromise=null; let _slashPersonalityCache=null; let _slashPersonalityCachePromise=null; let _bundleCommandCache=[]; let _bundleCommandLoadPromise=null; let _bundleCommandCacheReady=false; let _slashSkillCache=null; let _slashSkillCachePromise=null; let _agentCommandCache=null; let _agentCommandCachePromise=null; // Invalidate the /api/models slash-suggestion cache. Called by panels.js // after a provider is added or removed so the next /model autocomplete // rebuilds from a fresh /api/models response (#1539). Returning a function // rather than letting callers poke the module-local lets/promises directly // keeps the cache shape encapsulated to this module. function _invalidateSlashModelCache(){ _slashModelCache=null; _slashModelCachePromise=null; } // Expose on window when available. Guarded by typeof so the module is // importable in headless test contexts (vm.runInContext) that don't // define a window global — see tests/test_cli_only_slash_commands.py. if(typeof window!=='undefined'){ window._invalidateSlashModelCache=_invalidateSlashModelCache; window.invalidateSlashSkillCaches=invalidateSlashSkillCaches; } function _normalizeSlashSubArg(value){ return String(value||'').trim(); } function _getSlashModelSubArgsFromDom(){ const sel=$('modelSelect'); if(!sel) return []; const values=[]; for(const opt of Array.from(sel.options||[])){ const value=_normalizeSlashSubArg(opt.value||opt.textContent||''); if(value) values.push(value); } return Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b)); } async function _loadSlashModelSubArgs(force=false){ const domValues=_getSlashModelSubArgsFromDom(); if(domValues.length&&!force){ _slashModelCache=domValues; return domValues; } if(_slashModelCache&&!force) return _slashModelCache; if(_slashModelCachePromise&&!force) return _slashModelCachePromise; _slashModelCachePromise=(async()=>{ try{ const data=await api('/api/models'); const values=[]; for(const group of (data&&data.groups)||[]){ for(const model of (group&&group.models)||[]){ const id=_normalizeSlashSubArg(model&&model.id); if(id) values.push(id); } // Include extra_models (the catalog tail that doesn't render as //