Files
webui-develop/static/commands.js
2026-04-20 10:43:30 +02:00

380 lines
15 KiB
JavaScript

// ── Slash commands ──────────────────────────────────────────────────────────
// Commands are loaded dynamically from GET /api/commands (Hermes COMMAND_REGISTRY).
// Tier-2 Agent commands and passthrough handlers are added client-side.
// Each command either runs locally or is forwarded as a message to the agent.
let COMMANDS=[]; // Loaded async via loadCommands()
// Map Hermes passthrough command names to their fn.
// These commands are forwarded to the agent as-is.
const _PASSTHROUGH=['retry','undo','title','branch','stop','background','btw',
'queue','status','profile','resume','snapshot','rollback','provider',
'yolo','reasoning','fast','voice','reload','reload-mcp','cron','browser',
'plugins','insights','platforms','debug','update','image','inbox'];
function _fnFor(name){
if(name==='help'||name==='commands') return cmdHelp;
if(name==='clear') return cmdClear;
if(name==='compact'||name==='compress') return cmdCompact;
if(name==='model') return cmdModel;
if(name==='workspace') return cmdWorkspace;
if(name==='new') return cmdNew;
if(name==='usage') return cmdUsage;
if(name==='theme') return cmdTheme;
if(name==='skills') return cmdSkills;
if(name==='personality') return cmdPersonality;
if(_PASSTHROUGH.includes(name)) return cmdPassthrough;
// Fallback: passthrough unknown commands so new Hermes commands work without JS changes
return cmdPassthrough;
}
/**
* Fetch commands from Hermes COMMAND_REGISTRY and merge with WebUI-specific commands.
* Called once at boot time.
*/
async function loadCommands(){
try{
const data=await api('/api/commands');
if(data.error) throw new Error(data.error);
const cats=data.categories||{};
// Flatten all categories into COMMANDS
const merged=[];
for(const [catName,cmds] of Object.entries(cats)){
for(const c of cmds){
merged.push({name:c.name, desc:c.desc, arg:c.arg||'(none)',
aliases:c.aliases||[], fn:_fnFor(c.name)});
}
}
// ── Tier-2 Domain Agents (WebUI-specific, override API entries) ──
// Dedup: remove any API entries that would clash with Tier-2 agents
const _agentNames=['sunflower','lotus','forget-me-not','iris','ivy',
'dandelion','root','back','inbox'];
// Remove API entries for agent names (they may already be in the registry
// from the API if agents registered themselves as commands there)
const filtered=merged.filter(c=>!_agentNames.includes(c.name));
// Add Tier-2 agents (these override any API entries of the same name)
filtered.push(
{name:'sunflower', desc:'🌻 Finance, Wealth & Subscriptions', fn:cmdAgent, arg:'message'},
{name:'lotus', desc:'🪷 Health, Fitness & Recovery', fn:cmdAgent, arg:'message'},
{name:'forget-me-not', desc:'🌼 Calendar, Time & Social', fn:cmdAgent, arg:'message'},
{name:'iris', desc:'⚜️ Career, Learning & Focus', fn:cmdAgent, arg:'message'},
{name:'ivy', desc:'🌿 Smart Home & Environment', fn:cmdAgent, arg:'message'},
{name:'dandelion', desc:'🛡 Communication Triage & Gatekeeping',fn:cmdAgent, arg:'message'},
{name:'root', desc:'🌳 DevOps, Logs & System Health', fn:cmdAgent, arg:'message'},
{name:'back', desc:'🌹 Return to Rose (orchestrator)', fn:cmdAgent, arg:'message'},
);
COMMANDS=filtered;
}catch(e){
console.warn('[commands] Failed to load from API, using fallback:',e.message);
// Fallback: empty — user can still type commands manually
COMMANDS=[];
}
}
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};
}
function executeCommand(text){
const parsed=parseCommand(text);
if(!parsed)return false;
const cmd=COMMANDS.find(c=>c.name===parsed.name);
if(!cmd)return false;
cmd.fn(parsed.args);
return true;
}
function getMatchingCommands(prefix){
const q=prefix.toLowerCase();
return COMMANDS.filter(c=>{
if(c.name.startsWith(q)) return true;
// Also match aliases
if(c.aliases&&c.aliases.some(a=>a.startsWith(q))) return true;
return false;
});
}
// ── Generic passthrough: send command text directly to agent ────────────
function cmdPassthrough(args){
const parsed=parseCommand($('msg').value);
if(!parsed)return;
// Forward the raw command to the agent as a regular message
$('msg').value=$('msg').value; // keep as-is
send();
}
// ── Command handlers ────────────────────────────────────────────────────
function cmdHelp(){
// Infer categories from command names (backwards-compatible with hardcoded categories)
const categories={'Session':[],'Configuration':[],'Tools & Skills':[],'Info':[],'Agents':[]};
COMMANDS.forEach(c=>{
let cat='Info';
if(['new','clear','compact','compress','retry','undo','title','branch',
'stop','background','btw','queue','status','profile','resume',
'snapshot','rollback'].includes(c.name)) cat='Session';
else if(['model','provider','personality','workspace','theme','yolo',
'reasoning','fast','voice','reload','reload-mcp'].includes(c.name)) cat='Configuration';
else if(['skills','cron','browser','plugins'].includes(c.name)) cat='Tools & Skills';
else if(['sunflower','lotus','forget-me-not','iris','ivy','dandelion',
'root','back','inbox'].includes(c.name)) cat='Agents';
if(!categories[cat])categories[cat]=[];
categories[cat].push(c);
});
const lines=[];
for(const [cat,cmds] of Object.entries(categories)){
if(!cmds.length)continue;
lines.push(`\n**${cat}**`);
cmds.forEach(c=>{
const usage=c.arg&&c.arg!=='(none)'?` <${c.arg}>`:'';
lines.push(` /${c.name}${usage}${c.desc}`);
});
}
const msg={role:'assistant',content:'Available commands:\n'+lines.join('\n')};
S.messages.push(msg);
renderMessages();
showToast('Type / to see commands');
}
function cmdClear(){
if(!S.session)return;
S.messages=[];S.toolCalls=[];
clearLiveToolCards();
renderMessages();
$('emptyState').style.display='';
showToast(t('conversation_cleared'));
}
async function cmdModel(args){
if(!args){showToast('Usage: /model <model_name>');return;}
const sel=$('modelSelect');
if(!sel)return;
const q=args.toLowerCase();
let match=null;
for(const opt of sel.options){
if(opt.value.toLowerCase().includes(q)||opt.textContent.toLowerCase().includes(q)){
match=opt.value;break;
}
}
if(!match){showToast('No model matching "'+args+'"');return;}
sel.value=match;
await sel.onchange();
showToast(t('switched_to')+match);
}
async function cmdWorkspace(args){
if(!args){showToast('Usage: /workspace <name>');return;}
try{
const data=await api('/api/workspaces');
const q=args.toLowerCase();
const ws=(data.workspaces||[]).find(w=>
(w.name||'').toLowerCase().includes(q)||w.path.toLowerCase().includes(q)
);
if(!ws){showToast('No workspace matching "'+args+'"');return;}
if(typeof switchToWorkspace==='function') await switchToWorkspace(ws.path, ws.name||ws.path);
else showToast(t('switched_workspace')+(ws.name||ws.path));
}catch(e){showToast(t('workspace_switch_failed')+e.message);}
}
async function cmdNew(){
await newSession();
await renderSessionList();
$('msg').focus();
showToast(t('new_session'));
}
function cmdCompact(){
$('msg').value='Please compress and summarize the conversation context to free up space.';
send();
showToast(t('compressing'));
}
async function cmdUsage(){
const next=!window._showTokenUsage;
window._showTokenUsage=next;
try{
await api('/api/settings',{method:'POST',body:JSON.stringify({show_token_usage:next})});
}catch(e){}
const cb=$('settingsShowTokenUsage');
if(cb) cb.checked=next;
renderMessages();
showToast(next?t('token_usage_on'):t('token_usage_off'));
}
async function cmdTheme(args){
const themes=['system','dark','light','slate','solarized','monokai','nord','oled'];
if(!args||!themes.includes(args.toLowerCase())){
showToast('Themes: '+themes.join(' | '));
return;
}
const themeName=args.toLowerCase();
localStorage.setItem('hermes-theme',themeName);
_applyTheme(themeName);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:themeName})});}catch(e){}
const sel=$('settingsTheme');
if(sel)sel.value=themeName;
showToast(t('theme_set')+themeName);
}
async function cmdSkills(args){
try{
const data = await api('/api/skills');
let skills = data.skills || [];
if(args){
const q = args.toLowerCase();
skills = skills.filter(s =>
(s.name||'').toLowerCase().includes(q) ||
(s.description||'').toLowerCase().includes(q) ||
(s.category||'').toLowerCase().includes(q)
);
}
if(!skills.length){
const msg = {role:'assistant', content: args ? `No skills matching "${args}".` : 'No skills found.'};
S.messages.push(msg); renderMessages(); return;
}
const byCategory = {};
skills.forEach(s => {
const cat = s.category || 'General';
if(!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(s);
});
const lines = [];
for(const [cat, items] of Object.entries(byCategory).sort()){
lines.push(`**${cat}**`);
items.forEach(s => {
const desc = s.description ? `${s.description.slice(0,80)}${s.description.length>80?'...':''}` : '';
lines.push(` \`${s.name}\`${desc}`);
});
lines.push('');
}
const header = args
? `Skills matching "${args}" (${skills.length}):\n\n`
: `Available skills (${skills.length}):\n\n`;
S.messages.push({role:'assistant', content: header + lines.join('\n')});
renderMessages();
}catch(e){
showToast('Failed to load skills: '+e.message);
}
}
async function cmdPersonality(args){
if(!S.session){showToast(t('no_active_session'));return;}
if(!args){
try{
const data=await api('/api/personalities');
if(!data.personalities||!data.personalities.length){
showToast(t('no_personalities'));
return;
}
const list=data.personalities.map(p=>` **${p.name}**${p.description?' — '+p.description:''}`).join('\n');
S.messages.push({role:'assistant',content:t('available_personalities')+'\n\n'+list+'\n\nSwitch with: /personality <name>'});
renderMessages();
}catch(e){showToast(t('personalities_load_failed'));}
return;
}
const name=args.trim();
if(name.toLowerCase()==='none'||name.toLowerCase()==='default'||name.toLowerCase()==='clear'){
try{
await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name:''})});
showToast(t('personality_cleared'));
}catch(e){showToast(t('failed_colon')+e.message);}
return;
}
try{
const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})});
showToast(t('personality_set')+name);
}catch(e){showToast(t('failed_colon')+e.message);}
}
// ── Tier-2 Agent Command Handler ────────────────────────────────────────
const AGENT_INFO={
'sunflower': {emoji:'🌻', name:'Sunflower', file:'sunflower/soul.md', domain:'Finance, Wealth & Subscriptions'},
'lotus': {emoji:'🪷', name:'Lotus', file:'lotus/soul.md', domain:'Health, Fitness & Recovery'},
'forget-me-not': {emoji:'🌼', name:'Forget-me-not',file:'forget-me-not/soul.md', domain:'Calendar, Time & Social'},
'iris': {emoji:'⚜️', name:'Iris', file:'iris/soul.md', domain:'Career, Learning & Focus'},
'ivy': {emoji:'🌿', name:'Ivy', file:'ivy/soul.md', domain:'Smart Home & Environment'},
'dandelion': {emoji:'🛡', name:'Dandelion', file:'dandelion/soul.md', domain:'Communication Triage & Gatekeeping'},
'root': {emoji:'🌳', name:'Root', file:'root/soul.md', domain:'DevOps, Logs & System Health'},
'back': {emoji:'🌹', name:'Rose', file:'rose/soul.md', domain:'Orchestrator (return from agent)'},
};
function cmdAgent(args){
const parsed=parseCommand($('msg').value);
if(!parsed)return;
const agentKey=parsed.name;
const info=AGENT_INFO[agentKey];
if(!info){showToast('Unknown agent: '+agentKey);return;}
const userMsg=args||'';
const contextMsg=`[Agent Switch: ${info.emoji} ${info.name}]\nLoad ~/.hermes/agents/${info.file} and handle this request as ${info.name} (${info.domain}).${userMsg?'\n\nUser message: '+userMsg:''}`;
$('msg').value=contextMsg;
send();
}
// ── Autocomplete dropdown ───────────────────────────────────────────────
let _cmdSelectedIdx=-1;
function showCmdDropdown(matches){
const dd=$('cmdDropdown');
if(!dd)return;
dd.innerHTML='';
_cmdSelectedIdx=-1;
for(let i=0;i<matches.length;i++){
const c=matches[i];
const el=document.createElement('div');
el.className='cmd-item';
el.dataset.idx=i;
const usage=c.arg&&c.arg!=='(none)'?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
el.innerHTML=`<div class="cmd-item-name">/${esc(c.name)}${usage}</div><div class="cmd-item-desc">${esc(c.desc)}</div>`;
el.onmousedown=(e)=>{
e.preventDefault();
$('msg').value='/'+c.name+(c.arg&&c.arg!=='(none)'?' ':'');
hideCmdDropdown();
$('msg').focus();
};
dd.appendChild(el);
}
dd.classList.add('open');
}
function hideCmdDropdown(){
const dd=$('cmdDropdown');
if(dd)dd.classList.remove('open');
_cmdSelectedIdx=-1;
}
function navigateCmdDropdown(dir){
const dd=$('cmdDropdown');
if(!dd)return;
const items=dd.querySelectorAll('.cmd-item');
if(!items.length)return;
items.forEach(el=>el.classList.remove('selected'));
_cmdSelectedIdx+=dir;
if(_cmdSelectedIdx<0)_cmdSelectedIdx=items.length-1;
if(_cmdSelectedIdx>=items.length)_cmdSelectedIdx=0;
items[_cmdSelectedIdx].classList.add('selected');
}
function selectCmdDropdownItem(){
const dd=$('cmdDropdown');
if(!dd)return;
const items=dd.querySelectorAll('.cmd-item');
if(_cmdSelectedIdx>=0&&_cmdSelectedIdx<items.length){
items[_cmdSelectedIdx].onmousedown({preventDefault:()=>{}});
} else if(items.length===1){
items[0].onmousedown({preventDefault:()=>{}});
}
hideCmdDropdown();
}