mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Stage 298: PR #1663 — feat: add plugins visibility panel by @Michaelyklam
This commit is contained in:
@@ -1868,6 +1868,101 @@ def _handle_health(handler, parsed):
|
||||
return j(handler, payload)
|
||||
|
||||
|
||||
# ── Plugin visibility endpoint (#539) ───────────────────────────────────────
|
||||
_PLUGIN_VISIBILITY_HOOKS = (
|
||||
"pre_tool_call",
|
||||
"post_tool_call",
|
||||
"pre_llm_call",
|
||||
"post_llm_call",
|
||||
)
|
||||
_PLUGIN_VISIBILITY_HOOK_SET = set(_PLUGIN_VISIBILITY_HOOKS)
|
||||
|
||||
|
||||
def _get_plugin_manager_for_visibility():
|
||||
"""Return Hermes Agent's plugin manager for read-only WebUI visibility."""
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
|
||||
return get_plugin_manager()
|
||||
|
||||
|
||||
def _clean_plugin_visibility_text(value, *, limit=240) -> str:
|
||||
"""Return bounded display text without path/callback-like internals."""
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value).replace("\x00", "").strip()
|
||||
# Display metadata should be plain labels/descriptions. Drop multiline text
|
||||
# and common path separators rather than risk leaking local plugin paths.
|
||||
text = " ".join(text.split())
|
||||
if len(text) > limit:
|
||||
text = text[: limit - 1].rstrip() + "…"
|
||||
return text
|
||||
|
||||
|
||||
def _plugin_visibility_payload(manager=None) -> dict:
|
||||
"""Build a sanitized plugin/hook visibility payload for Settings.
|
||||
|
||||
The Hermes Agent manager stores manifests and callback objects internally.
|
||||
This endpoint intentionally exposes only safe, user-facing metadata and the
|
||||
four lifecycle hook names called out by the Settings visibility MVP. It
|
||||
never includes plugin source paths, callback names, callback reprs, or raw
|
||||
load errors because those can contain private filesystem details.
|
||||
"""
|
||||
manager = manager or _get_plugin_manager_for_visibility()
|
||||
manager.discover_and_load(force=False)
|
||||
|
||||
plugins = []
|
||||
raw_plugins = getattr(manager, "_plugins", {}) or {}
|
||||
for key, loaded in sorted(raw_plugins.items(), key=lambda item: str(item[0])):
|
||||
manifest = getattr(loaded, "manifest", None)
|
||||
if manifest is None:
|
||||
continue
|
||||
plugin_key = _clean_plugin_visibility_text(
|
||||
getattr(manifest, "key", None) or key or getattr(manifest, "name", ""),
|
||||
limit=120,
|
||||
)
|
||||
name = _clean_plugin_visibility_text(getattr(manifest, "name", "") or plugin_key, limit=120)
|
||||
version = _clean_plugin_visibility_text(getattr(manifest, "version", ""), limit=80)
|
||||
description = _clean_plugin_visibility_text(getattr(manifest, "description", ""), limit=280)
|
||||
registered = []
|
||||
for hook in list(getattr(manifest, "provides_hooks", []) or []) + list(getattr(loaded, "hooks_registered", []) or []):
|
||||
hook_name = str(hook or "").strip()
|
||||
if hook_name in _PLUGIN_VISIBILITY_HOOK_SET and hook_name not in registered:
|
||||
registered.append(hook_name)
|
||||
registered.sort(key=_PLUGIN_VISIBILITY_HOOKS.index)
|
||||
plugins.append({
|
||||
"name": name,
|
||||
"key": plugin_key or name,
|
||||
"version": version,
|
||||
"description": description,
|
||||
"enabled": bool(getattr(loaded, "enabled", False)),
|
||||
"hooks": registered,
|
||||
})
|
||||
|
||||
return {
|
||||
"plugins": plugins,
|
||||
"empty": not bool(plugins),
|
||||
"supported_hooks": list(_PLUGIN_VISIBILITY_HOOKS),
|
||||
"read_only": True,
|
||||
}
|
||||
|
||||
|
||||
def _handle_plugins(handler, parsed) -> bool:
|
||||
try:
|
||||
return j(handler, _plugin_visibility_payload())
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to build plugin visibility payload: %s", exc)
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"plugins": [],
|
||||
"empty": True,
|
||||
"supported_hooks": list(_PLUGIN_VISIBILITY_HOOKS),
|
||||
"read_only": True,
|
||||
"unavailable": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def handle_get(handler, parsed) -> bool:
|
||||
"""Handle all GET routes. Returns True if handled, False for 404."""
|
||||
|
||||
@@ -2003,6 +2098,10 @@ def handle_get(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/providers":
|
||||
return j(handler, get_providers())
|
||||
|
||||
# ── Plugins/hooks visibility (read-only, no callback/source internals) ──
|
||||
if parsed.path == "/api/plugins":
|
||||
return _handle_plugins(handler, parsed)
|
||||
|
||||
if parsed.path == "/api/settings":
|
||||
settings = load_settings()
|
||||
# Never expose the stored password hash to clients
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
@@ -246,6 +246,10 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||
<span data-i18n="providers_tab_title">Providers</span>
|
||||
</button>
|
||||
<button type="button" class="side-menu-item" data-settings-section="plugins" onclick="switchSettingsSection('plugins')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2l3 7h7l-5.5 4.3 2.1 7L12 16.2 5.4 20.3l2.1-7L2 9h7z"/></svg>
|
||||
<span>Plugins</span>
|
||||
</button>
|
||||
<button type="button" class="side-menu-item" data-settings-section="system" onclick="switchSettingsSection('system')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
|
||||
<span>System</span>
|
||||
@@ -977,6 +981,20 @@
|
||||
No configurable providers found.
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPanePlugins">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title">Plugins</div>
|
||||
<div class="settings-section-meta">View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pluginsList" style="display:flex;flex-direction:column;margin-top:4px">
|
||||
<!-- Populated dynamically by loadPluginsPanel() -->
|
||||
</div>
|
||||
<div id="pluginsEmpty" style="display:none;text-align:center;padding:32px 0;color:var(--muted);font-size:13px">
|
||||
No Hermes plugins are currently visible. Install or enable plugins from the Hermes CLI/config to see them here.
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPaneSystem">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
|
||||
+60
-4
@@ -3769,24 +3769,25 @@ let _settingsPreferencesAutosaveTimer = null;
|
||||
let _settingsPreferencesAutosaveRetryPayload = null;
|
||||
|
||||
function switchSettingsSection(name){
|
||||
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='system')?name:'conversation';
|
||||
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='plugins'||name==='system')?name:'conversation';
|
||||
_settingsSection=section;
|
||||
_currentSettingsSection=section;
|
||||
const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',providers:'Providers',system:'System'};
|
||||
const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',providers:'Providers',plugins:'Plugins',system:'System'};
|
||||
// Sidebar menu items
|
||||
document.querySelectorAll('#settingsMenu .side-menu-item').forEach(it=>{
|
||||
it.classList.toggle('active', it.dataset.settingsSection===section);
|
||||
});
|
||||
// Panes in main
|
||||
['conversation','appearance','preferences','providers','system'].forEach(key=>{
|
||||
['conversation','appearance','preferences','providers','plugins','system'].forEach(key=>{
|
||||
const pane=$('settingsPane'+map[key]);
|
||||
if(pane) pane.classList.toggle('active', key===section);
|
||||
});
|
||||
// Sync mobile dropdown
|
||||
const dd=$('settingsSectionDropdown');
|
||||
if(dd && dd.value!==section) dd.value=section;
|
||||
// Lazy-load providers when the tab is opened
|
||||
// Lazy-load integration panels when their tabs are opened
|
||||
if(section==='providers') loadProvidersPanel();
|
||||
if(section==='plugins') loadPluginsPanel();
|
||||
}
|
||||
|
||||
function _syncHermesPanelSessionActions(){
|
||||
@@ -4303,12 +4304,67 @@ async function loadSettingsPanel(){
|
||||
}
|
||||
_syncHermesPanelSessionActions();
|
||||
loadProvidersPanel(); // load provider cards in background
|
||||
loadPluginsPanel(); // load plugin/hook visibility in background
|
||||
switchSettingsSection(_settingsSection);
|
||||
}catch(e){
|
||||
showToast(t('settings_load_failed')+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Plugins panel (read-only plugin/hook visibility) ───────────────────────
|
||||
|
||||
async function loadPluginsPanel(){
|
||||
const list=$('pluginsList');
|
||||
const empty=$('pluginsEmpty');
|
||||
if(!list) return;
|
||||
try{
|
||||
const data=await api('/api/plugins');
|
||||
const plugins=Array.isArray((data||{}).plugins)?data.plugins:[];
|
||||
list.innerHTML='';
|
||||
if(plugins.length===0){
|
||||
list.style.display='none';
|
||||
if(empty) empty.style.display='';
|
||||
return;
|
||||
}
|
||||
if(empty) empty.style.display='none';
|
||||
list.style.display='';
|
||||
for(const plugin of plugins){
|
||||
list.appendChild(_buildPluginCard(plugin));
|
||||
}
|
||||
}catch(e){
|
||||
list.innerHTML='<div style="color:var(--error);padding:12px;font-size:13px">Failed to load plugins: '+esc(e.message||String(e))+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function _buildPluginCard(plugin){
|
||||
const card=document.createElement('div');
|
||||
card.className='provider-card plugin-card';
|
||||
card.dataset.plugin=(plugin&&plugin.key)||'';
|
||||
const hooks=Array.isArray(plugin&&plugin.hooks)?plugin.hooks:[];
|
||||
const hookHtml=hooks.length
|
||||
? hooks.map(h=>`<span class="plugin-hook-badge">${esc(h)}</span>`).join('')
|
||||
: '<span class="plugin-hook-empty">No registered lifecycle hooks</span>';
|
||||
const version=(plugin&&plugin.version)?` · v${esc(plugin.version)}`:'';
|
||||
const desc=(plugin&&plugin.description)?esc(plugin.description):'No description provided.';
|
||||
const enabled=plugin&&plugin.enabled!==false;
|
||||
card.innerHTML=`
|
||||
<div class="provider-card-header plugin-card-header">
|
||||
<div class="provider-card-info">
|
||||
<div class="provider-card-name">${esc((plugin&&plugin.name)||'Unnamed plugin')}</div>
|
||||
<div class="provider-card-meta">${esc((plugin&&plugin.key)||'plugin')}${version}</div>
|
||||
</div>
|
||||
<span class="provider-card-badge ${enabled?'':'plugin-card-badge-disabled'}">${enabled?'Enabled':'Disabled'}</span>
|
||||
</div>
|
||||
<div class="provider-card-body plugin-card-body">
|
||||
<div class="provider-card-hint">${desc}</div>
|
||||
<div class="provider-card-label">Registered hooks</div>
|
||||
<div class="plugin-hook-list">${hookHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── Providers panel ───────────────────────────────────────────────────────
|
||||
|
||||
const _providerCardEls = new Map(); // providerId → {card, statusDot, input, saveBtn, removeBtn}
|
||||
|
||||
@@ -2407,6 +2407,17 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||
background:color-mix(in srgb, var(--error) 10%, transparent);
|
||||
}
|
||||
|
||||
|
||||
/* ── Plugin visibility cards ── */
|
||||
#pluginsList{gap:12px;}
|
||||
.plugin-card .provider-card-body{display:block;}
|
||||
.plugin-card-header{cursor:default;}
|
||||
.plugin-card-header:hover{background:transparent;}
|
||||
.plugin-card-badge-disabled{background:var(--surface);color:var(--muted);}
|
||||
.plugin-hook-list{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;}
|
||||
.plugin-hook-badge{display:inline-flex;align-items:center;border:1px solid var(--border2);background:var(--code-bg);color:var(--text);border-radius:999px;padding:3px 8px;font-size:11px;font-family:var(--font-mono);}
|
||||
.plugin-hook-empty{font-size:12px;color:var(--muted);font-style:italic;}
|
||||
|
||||
/* ── Provider model tags ── */
|
||||
.provider-card-models{
|
||||
margin-bottom:10px;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Regression coverage for issue #539: Settings plugin/hook visibility."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
from pathlib import Path
|
||||
return Path(path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class _FakeManifest:
|
||||
def __init__(self, *, name, key, version="", description="", provides_hooks=None, path=None):
|
||||
self.name = name
|
||||
self.key = key
|
||||
self.version = version
|
||||
self.description = description
|
||||
self.provides_hooks = provides_hooks or []
|
||||
self.path = path
|
||||
self.source = "user"
|
||||
self.kind = "standalone"
|
||||
|
||||
|
||||
class _FakeLoadedPlugin:
|
||||
def __init__(self, manifest, *, enabled=True, hooks_registered=None, error=None):
|
||||
self.manifest = manifest
|
||||
self.enabled = enabled
|
||||
self.hooks_registered = hooks_registered or []
|
||||
self.error = error
|
||||
|
||||
|
||||
class _FakePluginManager:
|
||||
def __init__(self, plugins):
|
||||
self._plugins = plugins
|
||||
self.discover_calls = []
|
||||
|
||||
def discover_and_load(self, force=False):
|
||||
self.discover_calls.append(force)
|
||||
|
||||
|
||||
class TestPluginsApi:
|
||||
def _capture_plugins_response(self, manager):
|
||||
import api.routes as routes
|
||||
captured = {}
|
||||
|
||||
def fake_j(handler, payload, status=200, extra_headers=None):
|
||||
captured["payload"] = payload
|
||||
captured["status"] = status
|
||||
return True
|
||||
|
||||
handler = MagicMock()
|
||||
with patch("api.routes.j", side_effect=fake_j), \
|
||||
patch("api.routes._get_plugin_manager_for_visibility", return_value=manager):
|
||||
handled = routes.handle_get(handler, urlparse("/api/plugins"))
|
||||
|
||||
assert handled is True
|
||||
assert captured.get("status") == 200
|
||||
return captured["payload"]
|
||||
|
||||
def test_api_plugins_exposes_sanitized_metadata_and_hook_names(self):
|
||||
manager = _FakePluginManager({
|
||||
"guard": _FakeLoadedPlugin(
|
||||
_FakeManifest(
|
||||
name="guard",
|
||||
key="guard",
|
||||
version="1.2.3",
|
||||
description="Blocks unsafe tool calls",
|
||||
path="/home/michael/.hermes/plugins/guard",
|
||||
),
|
||||
enabled=True,
|
||||
hooks_registered=["pre_tool_call", "post_tool_call"],
|
||||
)
|
||||
})
|
||||
|
||||
payload = self._capture_plugins_response(manager)
|
||||
|
||||
assert payload["supported_hooks"] == [
|
||||
"pre_tool_call",
|
||||
"post_tool_call",
|
||||
"pre_llm_call",
|
||||
"post_llm_call",
|
||||
]
|
||||
assert payload["plugins"] == [{
|
||||
"name": "guard",
|
||||
"key": "guard",
|
||||
"version": "1.2.3",
|
||||
"description": "Blocks unsafe tool calls",
|
||||
"enabled": True,
|
||||
"hooks": ["pre_tool_call", "post_tool_call"],
|
||||
}]
|
||||
serialized = repr(payload)
|
||||
assert "/home/michael" not in serialized
|
||||
assert "callback" not in serialized.lower()
|
||||
assert "source" not in payload["plugins"][0]
|
||||
assert "path" not in payload["plugins"][0]
|
||||
assert manager.discover_calls == [False]
|
||||
|
||||
def test_api_plugins_empty_state_payload_when_no_plugins_loaded(self):
|
||||
payload = self._capture_plugins_response(_FakePluginManager({}))
|
||||
|
||||
assert payload["plugins"] == []
|
||||
assert payload["empty"] is True
|
||||
assert payload["supported_hooks"] == [
|
||||
"pre_tool_call",
|
||||
"post_tool_call",
|
||||
"pre_llm_call",
|
||||
"post_llm_call",
|
||||
]
|
||||
|
||||
def test_api_plugins_filters_non_visibility_hooks_and_manifest_paths(self):
|
||||
manager = _FakePluginManager({
|
||||
"mixed": _FakeLoadedPlugin(
|
||||
_FakeManifest(
|
||||
name="mixed",
|
||||
key="mixed",
|
||||
version="0.1",
|
||||
description="Mixed hooks",
|
||||
provides_hooks=["/tmp/not-a-hook", "pre_llm_call", "on_session_end"],
|
||||
path="/secret/plugin.py",
|
||||
),
|
||||
enabled=False,
|
||||
hooks_registered=["post_llm_call", "pre_gateway_dispatch", "post_llm_call"],
|
||||
)
|
||||
})
|
||||
|
||||
payload = self._capture_plugins_response(manager)
|
||||
|
||||
plugin = payload["plugins"][0]
|
||||
assert plugin["hooks"] == ["pre_llm_call", "post_llm_call"]
|
||||
assert plugin["enabled"] is False
|
||||
assert "/tmp/not-a-hook" not in repr(payload)
|
||||
assert "/secret" not in repr(payload)
|
||||
|
||||
|
||||
class TestPluginsSettingsUi:
|
||||
def test_settings_sidebar_has_plugins_section(self):
|
||||
html = read("static/index.html")
|
||||
js = read("static/panels.js")
|
||||
|
||||
assert 'data-settings-section="plugins"' in html
|
||||
assert "settingsPanePlugins" in html
|
||||
assert "'plugins'" in js
|
||||
assert "loadPluginsPanel()" in js
|
||||
|
||||
def test_plugins_panel_has_list_and_empty_state(self):
|
||||
html = read("static/index.html")
|
||||
|
||||
assert 'id="pluginsList"' in html
|
||||
assert 'id="pluginsEmpty"' in html
|
||||
assert "No Hermes plugins are currently visible" in html
|
||||
|
||||
def test_plugins_panel_fetches_api_and_renders_hook_badges_safely(self):
|
||||
js = read("static/panels.js")
|
||||
|
||||
assert "api('/api/plugins')" in js
|
||||
assert "_buildPluginCard" in js
|
||||
assert "plugin-hook-badge" in js
|
||||
assert "esc(plugin.description" in js
|
||||
segment = js[js.find("function _buildPluginCard"):js.find("// ── Providers panel")]
|
||||
assert ".path" not in segment
|
||||
assert ".callback" not in segment
|
||||
Reference in New Issue
Block a user