mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Merge PR #2089 into stage-339
support slash commands implemented in hermes plugin by @plerohellec
This commit is contained in:
@@ -53,4 +53,68 @@ def list_commands(_registry=None) -> list[dict[str, Any]]:
|
||||
'cli_only': bool(cmd.cli_only),
|
||||
'gateway_only': bool(cmd.gateway_only),
|
||||
})
|
||||
|
||||
# Include plugin-registered slash commands
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_commands
|
||||
plugin_cmds = get_plugin_commands() or {}
|
||||
existing_names = {c['name'] for c in out}
|
||||
for cmd_name, cmd_info in plugin_cmds.items():
|
||||
if cmd_name in existing_names or cmd_name in _NEVER_EXPOSE:
|
||||
continue
|
||||
out.append({
|
||||
'name': cmd_name,
|
||||
'description': str(cmd_info.get('description', 'Plugin command')),
|
||||
'category': 'Plugin',
|
||||
'aliases': [],
|
||||
'args_hint': str(cmd_info.get('args_hint', '')),
|
||||
'subcommands': [],
|
||||
'cli_only': False,
|
||||
'gateway_only': False,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def execute_plugin_command(command: str) -> str:
|
||||
"""Execute a plugin-registered slash command and return printable output.
|
||||
|
||||
Unknown commands raise ``KeyError`` so the HTTP layer can return 404.
|
||||
Plugin handler failures are returned as output text instead of surfacing as
|
||||
transport errors, matching Hermes' existing slash-command UX.
|
||||
"""
|
||||
|
||||
raw = str(command or "").strip()
|
||||
if not raw:
|
||||
raise ValueError("command is required")
|
||||
|
||||
cmd_text = raw[1:] if raw.startswith("/") else raw
|
||||
cmd_parts = cmd_text.split(maxsplit=1)
|
||||
cmd_base = (cmd_parts[0] if cmd_parts else "").strip().lower()
|
||||
cmd_arg = cmd_parts[1] if len(cmd_parts) > 1 else ""
|
||||
if not cmd_base:
|
||||
raise ValueError("command is required")
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import (
|
||||
get_plugin_command_handler,
|
||||
resolve_plugin_command_result,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("plugin command runtime unavailable") from exc
|
||||
|
||||
try:
|
||||
handler = get_plugin_command_handler(cmd_base)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"plugin command lookup failed: {exc}") from exc
|
||||
|
||||
if not handler:
|
||||
raise KeyError(cmd_base)
|
||||
|
||||
try:
|
||||
result = resolve_plugin_command_result(handler(cmd_arg))
|
||||
return str(result or "(no output)")
|
||||
except Exception as exc:
|
||||
return f"Plugin command error: {exc}"
|
||||
|
||||
@@ -4539,6 +4539,22 @@ def handle_post(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/clarify/respond":
|
||||
return _handle_clarify_respond(handler, body)
|
||||
|
||||
# ── Commands (POST) ──
|
||||
if parsed.path == "/api/commands/exec":
|
||||
from api.commands import execute_plugin_command
|
||||
|
||||
command = str(body.get("command", "") or "").strip()
|
||||
if not command:
|
||||
return bad(handler, "command is required")
|
||||
try:
|
||||
return j(handler, {"output": execute_plugin_command(command)})
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e), 400)
|
||||
except KeyError:
|
||||
return bad(handler, "Plugin command not found", 404)
|
||||
except RuntimeError as e:
|
||||
return bad(handler, _sanitize_error(e), 500)
|
||||
|
||||
# ── Skills (POST) ──
|
||||
if parsed.path == "/api/skills/save":
|
||||
return _handle_skill_save(handler, body)
|
||||
|
||||
+30
-2
@@ -79,6 +79,18 @@ function getMatchingCommands(prefix){
|
||||
matches.push(skill);
|
||||
seen.add(skill.name);
|
||||
}
|
||||
// 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)continue;
|
||||
matches.push({
|
||||
name,
|
||||
desc:String(cmd&&cmd.description||'').trim()||'Agent command',
|
||||
source:cmd.category==='Plugin'?'plugin':'agent',
|
||||
});
|
||||
seen.add(name);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
@@ -191,9 +203,10 @@ function _getSlashSubArgOptions(spec){
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
let _agentCommandCacheReady=false;
|
||||
async function loadAgentCommandMetadata(force=false){
|
||||
if(_agentCommandCache&&!force) return _agentCommandCache;
|
||||
if(_agentCommandCachePromise&&!force) return _agentCommandCachePromise;
|
||||
if(_agentCommandCacheReady&&!force)return _agentCommandCache||[];
|
||||
if(_agentCommandCachePromise&&!force)return _agentCommandCachePromise;
|
||||
_agentCommandCachePromise=(async()=>{
|
||||
try{
|
||||
const data=await api('/api/commands');
|
||||
@@ -201,6 +214,7 @@ async function loadAgentCommandMetadata(force=false){
|
||||
}catch(_){
|
||||
_agentCommandCache=[];
|
||||
}finally{
|
||||
_agentCommandCacheReady=true;
|
||||
_agentCommandCachePromise=null;
|
||||
}
|
||||
return _agentCommandCache;
|
||||
@@ -229,6 +243,16 @@ function cliOnlyCommandResponse(cmdName, meta){
|
||||
return `\`/${name}\` is a Hermes CLI-only command and cannot run inside the WebUI.${detail}${extra}`;
|
||||
}
|
||||
|
||||
async function executeAgentPluginCommand(text,_meta){
|
||||
const command=String(text||'').trim();
|
||||
if(!command) throw new Error('command is required');
|
||||
const data=await api('/api/commands/exec',{
|
||||
method:'POST',
|
||||
body:JSON.stringify({command})
|
||||
});
|
||||
return String(data&&data.output||'(no output)');
|
||||
}
|
||||
|
||||
function _parseSlashAutocomplete(text){
|
||||
if(!text.startsWith('/')||text.indexOf('\n')!==-1) return null;
|
||||
const raw=text.slice(1);
|
||||
@@ -1105,6 +1129,10 @@ function refreshSlashCommandDropdown(){
|
||||
function ensureSkillCommandsLoadedForAutocomplete(){
|
||||
if(_skillCommandCacheReady||_skillCommandLoadPromise)return;
|
||||
loadSkillCommands().then(()=>{refreshSlashCommandDropdown();});
|
||||
// Also preload agent/plugin command metadata for autocomplete
|
||||
if(!_agentCommandCacheReady&&!_agentCommandCachePromise){
|
||||
loadAgentCommandMetadata().then(()=>{refreshSlashCommandDropdown();});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete dropdown ───────────────────────────────────────────────────
|
||||
|
||||
@@ -166,6 +166,21 @@ async function send(){
|
||||
renderMessages();
|
||||
$('msg').value='';autoResize();hideCmdDropdown();return;
|
||||
}
|
||||
if(_agentCmd&&_agentCmd.category==='Plugin'){
|
||||
if(!S.session){await newSession();await renderSessionList();}
|
||||
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
|
||||
let _pluginOutput='(no output)';
|
||||
try{
|
||||
_pluginOutput=typeof executeAgentPluginCommand==='function'
|
||||
? await executeAgentPluginCommand(text,_agentCmd)
|
||||
: 'Plugin command runtime unavailable in WebUI.';
|
||||
}catch(e){
|
||||
_pluginOutput=`Plugin command error: ${e&&e.message||e}`;
|
||||
}
|
||||
S.messages.push({role:'assistant',content:String(_pluginOutput||'(no output)'),_ts:Date.now()/1000});
|
||||
renderMessages();
|
||||
$('msg').value='';autoResize();hideCmdDropdown();return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!S.session){await newSession();await renderSessionList();}
|
||||
|
||||
@@ -170,14 +170,16 @@ def test_send_intercepts_cli_only_commands_before_agent_round_trip():
|
||||
|
||||
|
||||
def test_unknown_slash_commands_still_fall_through_to_agent():
|
||||
"""Only known cli_only commands should be intercepted."""
|
||||
"""Only explicitly supported metadata-backed commands should be intercepted."""
|
||||
intercept_idx = MESSAGES_JS.find("Slash command intercept")
|
||||
normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx)
|
||||
intercept = MESSAGES_JS[intercept_idx:normal_send_idx]
|
||||
|
||||
assert "if(_agentCmd&&_agentCmd.cli_only)" in intercept
|
||||
assert "if(_agentCmd&&_agentCmd.category==='Plugin')" in intercept
|
||||
assert "if(_parsedCmd&&!_cmd)" in intercept
|
||||
assert "if(!_agentCmd" not in intercept
|
||||
assert "if(_agentCmd){" not in intercept
|
||||
assert "else" not in intercept[intercept.find("if(_agentCmd&&_agentCmd.cli_only)") :]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user