mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 08:21:50 +08:00
Compare commits
6 Commits
opencode-p
...
austin/fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e455de97b3 | ||
|
|
19667c8740 | ||
|
|
08955d59aa | ||
|
|
1deeb06fca | ||
|
|
dcf99b55a3 | ||
|
|
7947b89710 |
@@ -15,13 +15,18 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
from hermes_cli.config import cfg_get
|
from hermes_cli.config import cfg_get
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginOperationError(Exception):
|
||||||
|
"""Recoverable plugin install/update failure (CLI exits; HTTP maps to 4xx)."""
|
||||||
|
|
||||||
|
|
||||||
# Minimum manifest version this installer understands.
|
# Minimum manifest version this installer understands.
|
||||||
# Plugins may declare ``manifest_version: 1`` in plugin.yaml;
|
# Plugins may declare ``manifest_version: 1`` in plugin.yaml;
|
||||||
# future breaking changes to the manifest schema bump this.
|
# future breaking changes to the manifest schema bump this.
|
||||||
@@ -150,6 +155,24 @@ def _copy_example_files(plugin_dir: Path, console) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_requires_env_names(manifest: dict) -> list[str]:
|
||||||
|
"""Return declared ``requires_env`` names that are unset in ``~/.hermes/.env``."""
|
||||||
|
requires_env = manifest.get("requires_env") or []
|
||||||
|
if not requires_env:
|
||||||
|
return []
|
||||||
|
|
||||||
|
from hermes_cli.config import get_env_value
|
||||||
|
|
||||||
|
env_specs: list[dict] = []
|
||||||
|
for entry in requires_env:
|
||||||
|
if isinstance(entry, str):
|
||||||
|
env_specs.append({"name": entry})
|
||||||
|
elif isinstance(entry, dict) and entry.get("name"):
|
||||||
|
env_specs.append(entry)
|
||||||
|
|
||||||
|
return [s["name"] for s in env_specs if s.get("name") and not get_env_value(s["name"])]
|
||||||
|
|
||||||
|
|
||||||
def _prompt_plugin_env_vars(manifest: dict, console) -> None:
|
def _prompt_plugin_env_vars(manifest: dict, console) -> None:
|
||||||
"""Prompt for required environment variables declared in plugin.yaml.
|
"""Prompt for required environment variables declared in plugin.yaml.
|
||||||
|
|
||||||
@@ -283,6 +306,95 @@ def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, str]:
|
||||||
|
"""Clone Git plugin into ``~/.hermes/plugins``.
|
||||||
|
|
||||||
|
Returns ``(target_dir, installed_manifest, canonical_name)``.
|
||||||
|
Raises ``PluginOperationError`` on failure.
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
try:
|
||||||
|
git_url = _resolve_git_url(identifier)
|
||||||
|
except ValueError as e:
|
||||||
|
raise PluginOperationError(str(e)) from e
|
||||||
|
|
||||||
|
plugins_dir = _plugins_dir()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp_target = Path(tmp) / "plugin"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "clone", "--depth", "1", git_url, str(tmp_target)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise PluginOperationError(
|
||||||
|
"git is not installed or not in PATH.",
|
||||||
|
) from e
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
raise PluginOperationError(
|
||||||
|
"Git clone timed out after 60 seconds.",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
err = (result.stderr or result.stdout or "").strip()
|
||||||
|
raise PluginOperationError(f"Git clone failed:\n{err}")
|
||||||
|
|
||||||
|
manifest = _read_manifest(tmp_target)
|
||||||
|
plugin_name = manifest.get("name") or _repo_name_from_url(git_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
target = _sanitize_plugin_name(plugin_name, plugins_dir)
|
||||||
|
except ValueError as e:
|
||||||
|
raise PluginOperationError(str(e)) from e
|
||||||
|
|
||||||
|
mv = manifest.get("manifest_version")
|
||||||
|
if mv is not None:
|
||||||
|
try:
|
||||||
|
mv_int = int(mv)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise PluginOperationError(
|
||||||
|
f"Plugin '{plugin_name}' has invalid manifest_version "
|
||||||
|
f"'{mv}' (expected an integer).",
|
||||||
|
) from None
|
||||||
|
if mv_int > _SUPPORTED_MANIFEST_VERSION:
|
||||||
|
from hermes_cli.config import recommended_update_command
|
||||||
|
|
||||||
|
raise PluginOperationError(
|
||||||
|
f"Plugin '{plugin_name}' requires manifest_version {mv}, "
|
||||||
|
f"but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}. "
|
||||||
|
f"Run {recommended_update_command()} to update Hermes.",
|
||||||
|
) from None
|
||||||
|
|
||||||
|
if target.exists():
|
||||||
|
if not force:
|
||||||
|
raise PluginOperationError(
|
||||||
|
f"Plugin '{plugin_name}' already exists. Use force reinstall "
|
||||||
|
f"or run `hermes plugins update {plugin_name}`.",
|
||||||
|
)
|
||||||
|
shutil.rmtree(target)
|
||||||
|
|
||||||
|
shutil.move(str(tmp_target), str(target))
|
||||||
|
|
||||||
|
has_yaml = (target / "plugin.yaml").exists() or (target / "plugin.yml").exists()
|
||||||
|
if not has_yaml and not (target / "__init__.py").exists():
|
||||||
|
logger.warning(
|
||||||
|
"%s has no plugin.yaml / __init__.py; may not be a valid plugin",
|
||||||
|
plugin_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
_copy_example_files(target, Console())
|
||||||
|
installed_manifest = _read_manifest(target)
|
||||||
|
installed_name = installed_manifest.get("name") or target.name
|
||||||
|
return target, installed_manifest, installed_name
|
||||||
|
|
||||||
|
|
||||||
def cmd_install(
|
def cmd_install(
|
||||||
identifier: str,
|
identifier: str,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
@@ -293,7 +405,6 @@ def cmd_install(
|
|||||||
After install, prompt "Enable now? [y/N]" unless *enable* is provided
|
After install, prompt "Enable now? [y/N]" unless *enable* is provided
|
||||||
(True = auto-enable without prompting, False = install disabled).
|
(True = auto-enable without prompting, False = install disabled).
|
||||||
"""
|
"""
|
||||||
import tempfile
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -304,114 +415,41 @@ def cmd_install(
|
|||||||
console.print(f"[red]Error:[/red] {e}")
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Warn about insecure / local URL schemes
|
|
||||||
if git_url.startswith(("http://", "file://")):
|
if git_url.startswith(("http://", "file://")):
|
||||||
console.print(
|
console.print(
|
||||||
"[yellow]Warning:[/yellow] Using insecure/local URL scheme. "
|
"[yellow]Warning:[/yellow] Using insecure/local URL scheme. "
|
||||||
"Consider using https:// or git@ for production installs."
|
"Consider using https:// or git@ for production installs.",
|
||||||
)
|
)
|
||||||
|
|
||||||
plugins_dir = _plugins_dir()
|
console.print(f"[dim]Cloning {git_url}...[/dim]")
|
||||||
|
|
||||||
# Clone into a temp directory first so we can read plugin.yaml for the name
|
try:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
target, installed_manifest, installed_name = _install_plugin_core(
|
||||||
tmp_target = Path(tmp) / "plugin"
|
identifier,
|
||||||
console.print(f"[dim]Cloning {git_url}...[/dim]")
|
force=force,
|
||||||
|
)
|
||||||
|
except PluginOperationError as e:
|
||||||
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
try:
|
if not (target / "plugin.yaml").exists() and not (target / "plugin.yml").exists() and not (
|
||||||
result = subprocess.run(
|
target / "__init__.py"
|
||||||
["git", "clone", "--depth", "1", git_url, str(tmp_target)],
|
).exists():
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
console.print("[red]Error:[/red] git is not installed or not in PATH.")
|
|
||||||
sys.exit(1)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
console.print("[red]Error:[/red] Git clone timed out after 60 seconds.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
console.print(
|
|
||||||
f"[red]Error:[/red] Git clone failed:\n{result.stderr.strip()}"
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Read manifest
|
|
||||||
manifest = _read_manifest(tmp_target)
|
|
||||||
plugin_name = manifest.get("name") or _repo_name_from_url(git_url)
|
|
||||||
|
|
||||||
# Sanitize plugin name against path traversal
|
|
||||||
try:
|
|
||||||
target = _sanitize_plugin_name(plugin_name, plugins_dir)
|
|
||||||
except ValueError as e:
|
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Check manifest_version compatibility
|
|
||||||
mv = manifest.get("manifest_version")
|
|
||||||
if mv is not None:
|
|
||||||
try:
|
|
||||||
mv_int = int(mv)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
console.print(
|
|
||||||
f"[red]Error:[/red] Plugin '{plugin_name}' has invalid "
|
|
||||||
f"manifest_version '{mv}' (expected an integer)."
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
if mv_int > _SUPPORTED_MANIFEST_VERSION:
|
|
||||||
from hermes_cli.config import recommended_update_command
|
|
||||||
console.print(
|
|
||||||
f"[red]Error:[/red] Plugin '{plugin_name}' requires manifest_version "
|
|
||||||
f"{mv}, but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}.\n"
|
|
||||||
f"Run [bold]{recommended_update_command()}[/bold] to get a newer installer."
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if target.exists():
|
|
||||||
if not force:
|
|
||||||
console.print(
|
|
||||||
f"[red]Error:[/red] Plugin '{plugin_name}' already exists at {target}.\n"
|
|
||||||
f"Use [bold]--force[/bold] to remove and reinstall, or "
|
|
||||||
f"[bold]hermes plugins update {plugin_name}[/bold] to pull latest."
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
console.print(f"[dim] Removing existing {plugin_name}...[/dim]")
|
|
||||||
shutil.rmtree(target)
|
|
||||||
|
|
||||||
# Move from temp to final location
|
|
||||||
shutil.move(str(tmp_target), str(target))
|
|
||||||
|
|
||||||
# Validate it looks like a plugin
|
|
||||||
if not (target / "plugin.yaml").exists() and not (target / "__init__.py").exists():
|
|
||||||
console.print(
|
console.print(
|
||||||
f"[yellow]Warning:[/yellow] {plugin_name} doesn't contain plugin.yaml "
|
f"[yellow]Warning:[/yellow] {installed_name} doesn't contain plugin.yaml "
|
||||||
f"or __init__.py. It may not be a valid Hermes plugin."
|
f"or __init__.py. It may not be a valid Hermes plugin.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Copy .example files to their real names (e.g. config.yaml.example → config.yaml)
|
|
||||||
_copy_example_files(target, console)
|
|
||||||
|
|
||||||
# Re-read manifest from installed location (for env var prompting)
|
|
||||||
installed_manifest = _read_manifest(target)
|
|
||||||
|
|
||||||
# Prompt for required environment variables before showing after-install docs
|
|
||||||
_prompt_plugin_env_vars(installed_manifest, console)
|
_prompt_plugin_env_vars(installed_manifest, console)
|
||||||
|
|
||||||
_display_after_install(target, identifier)
|
_display_after_install(target, identifier)
|
||||||
|
|
||||||
# Determine the canonical plugin name for enable-list bookkeeping.
|
|
||||||
installed_name = installed_manifest.get("name") or target.name
|
|
||||||
|
|
||||||
# Decide whether to enable: explicit flag > interactive prompt > default off
|
|
||||||
should_enable = enable
|
should_enable = enable
|
||||||
if should_enable is None:
|
if should_enable is None:
|
||||||
# Interactive prompt unless stdin isn't a TTY (scripted install).
|
|
||||||
if sys.stdin.isatty() and sys.stdout.isatty():
|
if sys.stdin.isatty() and sys.stdout.isatty():
|
||||||
try:
|
try:
|
||||||
answer = input(
|
answer = input(
|
||||||
f" Enable '{installed_name}' now? [y/N]: "
|
f" Enable '{installed_name}' now? [y/N]: ",
|
||||||
).strip().lower()
|
).strip().lower()
|
||||||
should_enable = answer in ("y", "yes")
|
should_enable = answer in ("y", "yes")
|
||||||
except (EOFError, KeyboardInterrupt):
|
except (EOFError, KeyboardInterrupt):
|
||||||
@@ -427,12 +465,12 @@ def cmd_install(
|
|||||||
_save_enabled_set(enabled)
|
_save_enabled_set(enabled)
|
||||||
_save_disabled_set(disabled)
|
_save_disabled_set(disabled)
|
||||||
console.print(
|
console.print(
|
||||||
f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled."
|
f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled.",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
console.print(
|
console.print(
|
||||||
f"[dim]Plugin installed but not enabled. "
|
f"[dim]Plugin installed but not enabled. "
|
||||||
f"Run `hermes plugins enable {installed_name}` to activate.[/dim]"
|
f"Run `hermes plugins enable {installed_name}` to activate.[/dim]",
|
||||||
)
|
)
|
||||||
|
|
||||||
console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]")
|
console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]")
|
||||||
@@ -462,36 +500,22 @@ def cmd_update(name: str) -> None:
|
|||||||
|
|
||||||
console.print(f"[dim]Updating {name}...[/dim]")
|
console.print(f"[dim]Updating {name}...[/dim]")
|
||||||
|
|
||||||
try:
|
ok, output = _git_pull_plugin_dir(target)
|
||||||
result = subprocess.run(
|
if not ok:
|
||||||
["git", "pull", "--ff-only"],
|
console.print(f"[red]Error:[/red] {output}")
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=60,
|
|
||||||
cwd=str(target),
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
console.print("[red]Error:[/red] git is not installed or not in PATH.")
|
|
||||||
sys.exit(1)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
console.print("[red]Error:[/red] Git pull timed out after 60 seconds.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
console.print(f"[red]Error:[/red] Git pull failed:\n{result.stderr.strip()}")
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Copy any new .example files
|
# Copy any new .example files
|
||||||
_copy_example_files(target, console)
|
_copy_example_files(target, console)
|
||||||
|
|
||||||
output = result.stdout.strip()
|
out = output.strip()
|
||||||
if "Already up to date" in output:
|
if "Already up to date" in out:
|
||||||
console.print(
|
console.print(
|
||||||
f"[green]✓[/green] Plugin [bold]{name}[/bold] is already up to date."
|
f"[green]✓[/green] Plugin [bold]{name}[/bold] is already up to date."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] updated.")
|
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] updated.")
|
||||||
console.print(f"[dim]{output}[/dim]")
|
console.print(f"[dim]{out}[/dim]")
|
||||||
|
|
||||||
|
|
||||||
def cmd_remove(name: str) -> None:
|
def cmd_remove(name: str) -> None:
|
||||||
@@ -1244,6 +1268,247 @@ def _run_composite_fallback(plugin_names, plugin_labels, plugin_selected,
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_install_plugin(
|
||||||
|
identifier: str,
|
||||||
|
*,
|
||||||
|
force: bool,
|
||||||
|
enable: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
|
||||||
|
warnings: list[str] = []
|
||||||
|
try:
|
||||||
|
git_url = _resolve_git_url(identifier)
|
||||||
|
if git_url.startswith(("http://", "file://")):
|
||||||
|
warnings.append(
|
||||||
|
"Insecure URL scheme; prefer https:// or git@ for production installs.",
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
target, installed_manifest, installed_name = _install_plugin_core(
|
||||||
|
identifier,
|
||||||
|
force=force,
|
||||||
|
)
|
||||||
|
except PluginOperationError as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
missing_env = _missing_requires_env_names(installed_manifest)
|
||||||
|
if enable:
|
||||||
|
en = _get_enabled_set()
|
||||||
|
dis = _get_disabled_set()
|
||||||
|
en.add(installed_name)
|
||||||
|
dis.discard(installed_name)
|
||||||
|
_save_enabled_set(en)
|
||||||
|
_save_disabled_set(dis)
|
||||||
|
|
||||||
|
hint: str | None = None
|
||||||
|
ap = target / "after-install.md"
|
||||||
|
if ap.exists():
|
||||||
|
hint = str(ap)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"plugin_name": installed_name,
|
||||||
|
"warnings": warnings,
|
||||||
|
"missing_env": missing_env,
|
||||||
|
"after_install_path": hint,
|
||||||
|
"enabled": enable,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_plugin_toolset_key(name: str) -> Optional[str]:
|
||||||
|
"""Return the toolset key a plugin registers its tools under, or None.
|
||||||
|
|
||||||
|
Queries the live tool registry — the plugin must already be loaded.
|
||||||
|
Falls back to reading ``provides_tools`` from plugin.yaml and looking
|
||||||
|
up the toolset from the registry for the first tool name found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from tools.registry import registry
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check the plugin manager for tools this plugin registered
|
||||||
|
try:
|
||||||
|
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
||||||
|
discover_plugins() # idempotent — ensures plugins are loaded
|
||||||
|
manager = get_plugin_manager()
|
||||||
|
for _key, loaded in manager._plugins.items():
|
||||||
|
if loaded.manifest.name == name or _key == name:
|
||||||
|
for tool_name in loaded.tools_registered:
|
||||||
|
entry = registry.get_entry(tool_name)
|
||||||
|
if entry and entry.toolset:
|
||||||
|
return entry.toolset
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: read provides_tools from manifest on disk and query registry
|
||||||
|
try:
|
||||||
|
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||||
|
for base in (get_bundled_plugins_dir(), _plugins_dir()):
|
||||||
|
if not base.is_dir():
|
||||||
|
continue
|
||||||
|
candidate = base / name
|
||||||
|
if candidate.is_dir():
|
||||||
|
manifest = _read_manifest(candidate)
|
||||||
|
for tool_name in manifest.get("provides_tools") or []:
|
||||||
|
entry = registry.get_entry(tool_name)
|
||||||
|
if entry and entry.toolset:
|
||||||
|
return entry.toolset
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _toggle_plugin_toolset(name: str, *, enable: bool) -> None:
|
||||||
|
"""Add or remove a plugin's toolset from platform_toolsets for all platforms.
|
||||||
|
|
||||||
|
Only acts if the plugin actually provides tools (has a toolset key).
|
||||||
|
"""
|
||||||
|
toolset_key = _get_plugin_toolset_key(name)
|
||||||
|
if not toolset_key:
|
||||||
|
return
|
||||||
|
|
||||||
|
from hermes_cli.config import load_config, save_config
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
platform_toolsets = config.get("platform_toolsets")
|
||||||
|
if not isinstance(platform_toolsets, dict):
|
||||||
|
platform_toolsets = {}
|
||||||
|
config["platform_toolsets"] = platform_toolsets
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
for platform, ts_list in platform_toolsets.items():
|
||||||
|
if not isinstance(ts_list, list):
|
||||||
|
continue
|
||||||
|
if enable:
|
||||||
|
if toolset_key not in ts_list:
|
||||||
|
ts_list.append(toolset_key)
|
||||||
|
changed = True
|
||||||
|
else:
|
||||||
|
if toolset_key in ts_list:
|
||||||
|
ts_list.remove(toolset_key)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# If enabling and no platforms have toolset lists yet, add to "cli" at minimum
|
||||||
|
if enable and not changed and not platform_toolsets:
|
||||||
|
platform_toolsets["cli"] = [toolset_key]
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_set_agent_plugin_enabled(name: str, *, enabled: bool) -> dict[str, Any]:
|
||||||
|
"""Enable or disable a plugin in ``config.yaml`` (runtime allow/deny lists).
|
||||||
|
|
||||||
|
For plugins that provide tools (toolsets), also toggles the toolset in
|
||||||
|
``platform_toolsets`` so the agent actually sees the tools in sessions.
|
||||||
|
"""
|
||||||
|
if not _plugin_exists(name):
|
||||||
|
return {"ok": False, "error": f"Plugin '{name}' is not installed or bundled."}
|
||||||
|
|
||||||
|
en = _get_enabled_set()
|
||||||
|
dis = _get_disabled_set()
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
if name in en and name not in dis:
|
||||||
|
return {"ok": True, "name": name, "unchanged": True}
|
||||||
|
en.add(name)
|
||||||
|
dis.discard(name)
|
||||||
|
_save_enabled_set(en)
|
||||||
|
_save_disabled_set(dis)
|
||||||
|
_toggle_plugin_toolset(name, enable=True)
|
||||||
|
return {"ok": True, "name": name, "unchanged": False}
|
||||||
|
|
||||||
|
if name not in en and name in dis:
|
||||||
|
return {"ok": True, "name": name, "unchanged": True}
|
||||||
|
|
||||||
|
en.discard(name)
|
||||||
|
dis.add(name)
|
||||||
|
_save_enabled_set(en)
|
||||||
|
_save_disabled_set(dis)
|
||||||
|
_toggle_plugin_toolset(name, enable=False)
|
||||||
|
return {"ok": True, "name": name, "unchanged": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_installed_plugin_dir(name: str) -> Optional[Path]:
|
||||||
|
"""Resolved path under ``~/.hermes/plugins/<name>`` if it exists."""
|
||||||
|
plugins_dir = _plugins_dir()
|
||||||
|
try:
|
||||||
|
target = _sanitize_plugin_name(name, plugins_dir)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return target if target.is_dir() else None
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_update_user_plugin(name: str) -> dict[str, Any]:
|
||||||
|
"""``git pull`` inside ``~/.hermes/plugins/<name>``."""
|
||||||
|
target = _user_installed_plugin_dir(name)
|
||||||
|
if target is None:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": f"Plugin '{name}' was not found under {_plugins_dir()}.",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not (target / ".git").exists():
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": f"Plugin '{name}' is not a git checkout; cannot pull updates.",
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, msg = _git_pull_plugin_dir(target)
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "error": msg}
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
_copy_example_files(target, Console())
|
||||||
|
unchanged = "Already up to date" in msg
|
||||||
|
return {"ok": True, "name": name, "output": msg, "unchanged": unchanged}
|
||||||
|
|
||||||
|
|
||||||
|
def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "pull", "--ff-only"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
cwd=str(target),
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, "git is not installed or not in PATH."
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Git pull timed out after 60 seconds."
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
err = (result.stderr or "").strip() or result.stdout.strip()
|
||||||
|
return False, err or "git pull failed."
|
||||||
|
return True, result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_remove_user_plugin(name: str) -> dict[str, Any]:
|
||||||
|
"""Delete a plugin tree under ``~/.hermes/plugins/`` only."""
|
||||||
|
plugins_dir = _plugins_dir()
|
||||||
|
for n, _ver, _d, src, _path in _discover_all_plugins():
|
||||||
|
if n == name and src == "bundled":
|
||||||
|
return {"ok": False, "error": "Bundled plugins cannot be removed from the dashboard."}
|
||||||
|
|
||||||
|
target = _user_installed_plugin_dir(name)
|
||||||
|
if target is None:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": f"Plugin '{name}' was not found under {plugins_dir}.",
|
||||||
|
}
|
||||||
|
|
||||||
|
shutil.rmtree(target)
|
||||||
|
return {"ok": True, "name": name}
|
||||||
|
|
||||||
|
|
||||||
def plugins_command(args) -> None:
|
def plugins_command(args) -> None:
|
||||||
"""Dispatch hermes plugins subcommands."""
|
"""Dispatch hermes plugins subcommands."""
|
||||||
action = getattr(args, "plugins_action", None)
|
action = getattr(args, "plugins_action", None)
|
||||||
|
|||||||
@@ -3617,12 +3617,16 @@ def _get_dashboard_plugins(force_rescan: bool = False) -> list:
|
|||||||
|
|
||||||
@app.get("/api/dashboard/plugins")
|
@app.get("/api/dashboard/plugins")
|
||||||
async def get_dashboard_plugins():
|
async def get_dashboard_plugins():
|
||||||
"""Return discovered dashboard plugins."""
|
"""Return discovered dashboard plugins (excludes user-hidden ones)."""
|
||||||
plugins = _get_dashboard_plugins()
|
plugins = _get_dashboard_plugins()
|
||||||
# Strip internal fields before sending to frontend.
|
# Read user's hidden plugins list from config.
|
||||||
|
config = load_config()
|
||||||
|
hidden: list = cfg_get(config, "dashboard", "hidden_plugins", default=[]) or []
|
||||||
|
# Strip internal fields before sending to frontend and filter out hidden.
|
||||||
return [
|
return [
|
||||||
{k: v for k, v in p.items() if not k.startswith("_")}
|
{k: v for k, v in p.items() if not k.startswith("_")}
|
||||||
for p in plugins
|
for p in plugins
|
||||||
|
if p["name"] not in hidden
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -3633,6 +3637,268 @@ async def rescan_dashboard_plugins():
|
|||||||
return {"ok": True, "count": len(plugins)}
|
return {"ok": True, "count": len(plugins)}
|
||||||
|
|
||||||
|
|
||||||
|
class _AgentPluginInstallBody(BaseModel):
|
||||||
|
identifier: str
|
||||||
|
force: bool = False
|
||||||
|
enable: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_dashboard_manifest(p: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {k: v for k, v in p.items() if not k.startswith("_")}
|
||||||
|
|
||||||
|
|
||||||
|
def _merged_plugins_hub() -> Dict[str, Any]:
|
||||||
|
"""Agent discovery + dashboard manifests + optional provider picker metadata."""
|
||||||
|
from hermes_cli.plugins_cmd import (
|
||||||
|
_discover_all_plugins,
|
||||||
|
_get_current_context_engine,
|
||||||
|
_get_current_memory_provider,
|
||||||
|
_discover_context_engines,
|
||||||
|
_discover_memory_providers,
|
||||||
|
_get_disabled_set,
|
||||||
|
_get_enabled_set,
|
||||||
|
_read_manifest as _read_plugin_manifest_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
dashboard_list = _get_dashboard_plugins()
|
||||||
|
dash_by_name = {str(p["name"]): p for p in dashboard_list}
|
||||||
|
|
||||||
|
disabled_set = _get_disabled_set()
|
||||||
|
enabled_set = _get_enabled_set()
|
||||||
|
|
||||||
|
# Read user-hidden plugins from config for the user_hidden field.
|
||||||
|
config = load_config()
|
||||||
|
hidden_plugins: list = cfg_get(config, "dashboard", "hidden_plugins", default=[]) or []
|
||||||
|
|
||||||
|
plugins_root_resolved = (get_hermes_home() / "plugins").resolve()
|
||||||
|
rows: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for name, version, description, source, dir_str in _discover_all_plugins():
|
||||||
|
if name in disabled_set:
|
||||||
|
runtime_status = "disabled"
|
||||||
|
elif name in enabled_set:
|
||||||
|
runtime_status = "enabled"
|
||||||
|
else:
|
||||||
|
runtime_status = "inactive"
|
||||||
|
|
||||||
|
dir_path = Path(dir_str)
|
||||||
|
dm = dash_by_name.get(name)
|
||||||
|
has_dash_manifest = dm is not None or (dir_path / "dashboard" / "manifest.json").exists()
|
||||||
|
|
||||||
|
under_user_tree = False
|
||||||
|
try:
|
||||||
|
dir_path.resolve().relative_to(plugins_root_resolved)
|
||||||
|
under_user_tree = True
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
can_remove_update = (
|
||||||
|
source in ("user", "git") and under_user_tree and Path(dir_str).is_dir()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if this plugin provides tools that require auth
|
||||||
|
auth_required = False
|
||||||
|
auth_command = ""
|
||||||
|
manifest_data = _read_plugin_manifest_at(dir_path)
|
||||||
|
provides_tools = manifest_data.get("provides_tools") or []
|
||||||
|
if provides_tools:
|
||||||
|
try:
|
||||||
|
from tools.registry import registry
|
||||||
|
for tname in provides_tools:
|
||||||
|
entry = registry.get_entry(tname)
|
||||||
|
if entry and entry.check_fn and not entry.check_fn():
|
||||||
|
auth_required = True
|
||||||
|
auth_command = f"hermes auth {name}"
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
rows.append({
|
||||||
|
"name": name,
|
||||||
|
"version": version or "",
|
||||||
|
"description": description or "",
|
||||||
|
"source": source,
|
||||||
|
"runtime_status": runtime_status,
|
||||||
|
"has_dashboard_manifest": has_dash_manifest,
|
||||||
|
"dashboard_manifest": _strip_dashboard_manifest(dm) if dm else None,
|
||||||
|
"path": dir_str,
|
||||||
|
"can_remove": can_remove_update,
|
||||||
|
"can_update_git": can_remove_update and (Path(dir_str) / ".git").exists(),
|
||||||
|
"auth_required": auth_required,
|
||||||
|
"auth_command": auth_command,
|
||||||
|
"user_hidden": name in hidden_plugins,
|
||||||
|
})
|
||||||
|
|
||||||
|
agent_names = {r["name"] for r in rows}
|
||||||
|
orphan_dashboard = [
|
||||||
|
_strip_dashboard_manifest(p)
|
||||||
|
for p in dashboard_list
|
||||||
|
if str(p["name"]) not in agent_names
|
||||||
|
]
|
||||||
|
|
||||||
|
memory_providers: List[Dict[str, str]] = []
|
||||||
|
try:
|
||||||
|
for n, desc in _discover_memory_providers():
|
||||||
|
memory_providers.append({"name": n, "description": desc})
|
||||||
|
except Exception:
|
||||||
|
memory_providers = []
|
||||||
|
|
||||||
|
context_engines: List[Dict[str, str]] = []
|
||||||
|
try:
|
||||||
|
for n, desc in _discover_context_engines():
|
||||||
|
context_engines.append({"name": n, "description": desc})
|
||||||
|
except Exception:
|
||||||
|
context_engines = []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"plugins": rows,
|
||||||
|
"orphan_dashboard_plugins": orphan_dashboard,
|
||||||
|
"providers": {
|
||||||
|
"memory_provider": _get_current_memory_provider() or "",
|
||||||
|
"memory_options": memory_providers,
|
||||||
|
"context_engine": _get_current_context_engine(),
|
||||||
|
"context_options": context_engines,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/dashboard/plugins/hub")
|
||||||
|
async def get_plugins_hub(request: Request):
|
||||||
|
"""Unified agent plugins + dashboard extension metadata (session protected)."""
|
||||||
|
_require_token(request)
|
||||||
|
try:
|
||||||
|
return _merged_plugins_hub()
|
||||||
|
except Exception as exc:
|
||||||
|
_log.warning("plugins/hub failed: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/dashboard/agent-plugins/install")
|
||||||
|
async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody):
|
||||||
|
_require_token(request)
|
||||||
|
from hermes_cli.plugins_cmd import dashboard_install_plugin
|
||||||
|
|
||||||
|
result = dashboard_install_plugin(
|
||||||
|
body.identifier.strip(),
|
||||||
|
force=body.force,
|
||||||
|
enable=body.enable,
|
||||||
|
)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=result.get("error") or "Install failed.",
|
||||||
|
)
|
||||||
|
_get_dashboard_plugins(force_rescan=True)
|
||||||
|
# Strip internal paths from the response
|
||||||
|
result.pop("after_install_path", None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_plugin_name(name: str) -> str:
|
||||||
|
"""Reject path-traversal attempts in plugin name URL parameters."""
|
||||||
|
if not name or "/" in name or "\\" in name or ".." in name:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid plugin name.")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/dashboard/agent-plugins/{name}/enable")
|
||||||
|
async def post_agent_plugin_enable(request: Request, name: str):
|
||||||
|
_require_token(request)
|
||||||
|
name = _validate_plugin_name(name)
|
||||||
|
from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled
|
||||||
|
|
||||||
|
result = dashboard_set_agent_plugin_enabled(name, enabled=True)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise HTTPException(status_code=400, detail=result.get("error") or "Enable failed.")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/dashboard/agent-plugins/{name}/disable")
|
||||||
|
async def post_agent_plugin_disable(request: Request, name: str):
|
||||||
|
_require_token(request)
|
||||||
|
name = _validate_plugin_name(name)
|
||||||
|
from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled
|
||||||
|
|
||||||
|
result = dashboard_set_agent_plugin_enabled(name, enabled=False)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise HTTPException(status_code=400, detail=result.get("error") or "Disable failed.")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/dashboard/agent-plugins/{name}/update")
|
||||||
|
async def post_agent_plugin_update(request: Request, name: str):
|
||||||
|
_require_token(request)
|
||||||
|
name = _validate_plugin_name(name)
|
||||||
|
from hermes_cli.plugins_cmd import dashboard_update_user_plugin
|
||||||
|
|
||||||
|
result = dashboard_update_user_plugin(name)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise HTTPException(status_code=400, detail=result.get("error") or "Update failed.")
|
||||||
|
_get_dashboard_plugins(force_rescan=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/dashboard/agent-plugins/{name}")
|
||||||
|
async def delete_agent_plugin(request: Request, name: str):
|
||||||
|
_require_token(request)
|
||||||
|
name = _validate_plugin_name(name)
|
||||||
|
from hermes_cli.plugins_cmd import dashboard_remove_user_plugin
|
||||||
|
|
||||||
|
result = dashboard_remove_user_plugin(name)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise HTTPException(status_code=400, detail=result.get("error") or "Remove failed.")
|
||||||
|
_get_dashboard_plugins(force_rescan=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class _PluginProvidersPutBody(BaseModel):
|
||||||
|
memory_provider: Optional[str] = None
|
||||||
|
context_engine: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/dashboard/plugin-providers")
|
||||||
|
async def put_plugin_providers(request: Request, body: _PluginProvidersPutBody):
|
||||||
|
"""Persist memory provider / context engine selection (writes config.yaml)."""
|
||||||
|
_require_token(request)
|
||||||
|
from hermes_cli.plugins_cmd import (
|
||||||
|
_save_context_engine,
|
||||||
|
_save_memory_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
if body.memory_provider is not None:
|
||||||
|
_save_memory_provider(body.memory_provider)
|
||||||
|
if body.context_engine is not None:
|
||||||
|
_save_context_engine(body.context_engine)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class _PluginVisibilityBody(BaseModel):
|
||||||
|
hidden: bool
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/dashboard/plugins/{name}/visibility")
|
||||||
|
async def post_plugin_visibility(request: Request, name: str, body: _PluginVisibilityBody):
|
||||||
|
"""Toggle a plugin's sidebar visibility (persists to config.yaml dashboard.hidden_plugins)."""
|
||||||
|
_require_token(request)
|
||||||
|
name = _validate_plugin_name(name)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
if "dashboard" not in config or not isinstance(config.get("dashboard"), dict):
|
||||||
|
config["dashboard"] = {}
|
||||||
|
hidden_list: list = config["dashboard"].get("hidden_plugins") or []
|
||||||
|
if not isinstance(hidden_list, list):
|
||||||
|
hidden_list = []
|
||||||
|
|
||||||
|
if body.hidden and name not in hidden_list:
|
||||||
|
hidden_list.append(name)
|
||||||
|
elif not body.hidden and name in hidden_list:
|
||||||
|
hidden_list.remove(name)
|
||||||
|
|
||||||
|
config["dashboard"]["hidden_plugins"] = hidden_list
|
||||||
|
save_config(config)
|
||||||
|
return {"ok": True, "name": name, "hidden": body.hidden}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/dashboard-plugins/{plugin_name}/{file_path:path}")
|
@app.get("/dashboard-plugins/{plugin_name}/{file_path:path}")
|
||||||
async def serve_plugin_asset(plugin_name: str, file_path: str):
|
async def serve_plugin_asset(plugin_name: str, file_path: str):
|
||||||
"""Serve static assets from a dashboard plugin directory.
|
"""Serve static assets from a dashboard plugin directory.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ let
|
|||||||
src = ../ui-tui;
|
src = ../ui-tui;
|
||||||
npmDeps = pkgs.fetchNpmDeps {
|
npmDeps = pkgs.fetchNpmDeps {
|
||||||
inherit src;
|
inherit src;
|
||||||
hash = "sha256-Chz+NW9NXqboXHOa6PKwf5bhAkkcFtKNhvKWwg2XSPc=";
|
hash = "sha256-a/HGI9OgVcTnZrMXA7xFMGnFoVxyHe95fulVz+WNYB0=";
|
||||||
};
|
};
|
||||||
|
|
||||||
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||||
|
|||||||
41
ui-tui/package-lock.json
generated
41
ui-tui/package-lock.json
generated
@@ -124,6 +124,7 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -501,31 +502,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
@@ -1700,6 +1676,7 @@
|
|||||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.19.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
@@ -1710,6 +1687,7 @@
|
|||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1720,6 +1698,7 @@
|
|||||||
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
|
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@typescript-eslint/scope-manager": "8.58.1",
|
"@typescript-eslint/scope-manager": "8.58.1",
|
||||||
@@ -1749,6 +1728,7 @@
|
|||||||
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.58.1",
|
"@typescript-eslint/scope-manager": "8.58.1",
|
||||||
"@typescript-eslint/types": "8.58.1",
|
"@typescript-eslint/types": "8.58.1",
|
||||||
@@ -2066,6 +2046,7 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2468,6 +2449,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -3203,6 +3185,7 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3334,6 +3317,7 @@
|
|||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -4242,6 +4226,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
|
||||||
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
|
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"type-fest": "^4.18.2"
|
"type-fest": "^4.18.2"
|
||||||
@@ -5678,6 +5663,7 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5787,6 +5773,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -6611,6 +6598,7 @@
|
|||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -6737,6 +6725,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -6846,6 +6835,7 @@
|
|||||||
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
|
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
@@ -7261,6 +7251,7 @@
|
|||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
169
web/src/App.tsx
169
web/src/App.tsx
@@ -65,10 +65,12 @@ import ModelsPage from "@/pages/ModelsPage";
|
|||||||
import CronPage from "@/pages/CronPage";
|
import CronPage from "@/pages/CronPage";
|
||||||
import ProfilesPage from "@/pages/ProfilesPage";
|
import ProfilesPage from "@/pages/ProfilesPage";
|
||||||
import SkillsPage from "@/pages/SkillsPage";
|
import SkillsPage from "@/pages/SkillsPage";
|
||||||
|
import PluginsPage from "@/pages/PluginsPage";
|
||||||
import ChatPage from "@/pages/ChatPage";
|
import ChatPage from "@/pages/ChatPage";
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
|
import type { Translations } from "@/i18n/types";
|
||||||
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
|
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
|
||||||
import type { PluginManifest } from "@/plugins";
|
import type { PluginManifest } from "@/plugins";
|
||||||
import { useTheme } from "@/themes";
|
import { useTheme } from "@/themes";
|
||||||
@@ -102,6 +104,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
|||||||
"/logs": LogsPage,
|
"/logs": LogsPage,
|
||||||
"/cron": CronPage,
|
"/cron": CronPage,
|
||||||
"/skills": SkillsPage,
|
"/skills": SkillsPage,
|
||||||
|
"/plugins": PluginsPage,
|
||||||
"/profiles": ProfilesPage,
|
"/profiles": ProfilesPage,
|
||||||
"/config": ConfigPage,
|
"/config": ConfigPage,
|
||||||
"/env": EnvPage,
|
"/env": EnvPage,
|
||||||
@@ -138,6 +141,7 @@ const BUILTIN_NAV_REST: NavItem[] = [
|
|||||||
{ path: "/logs", labelKey: "logs", label: "Logs", icon: FileText },
|
{ path: "/logs", labelKey: "logs", label: "Logs", icon: FileText },
|
||||||
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
|
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
|
||||||
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
|
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
|
||||||
|
{ path: "/plugins", labelKey: "plugins", label: "Plugins", icon: Puzzle },
|
||||||
{ path: "/profiles", labelKey: "profiles", label: "Profiles", icon: Users },
|
{ path: "/profiles", labelKey: "profiles", label: "Profiles", icon: Users },
|
||||||
{ path: "/config", labelKey: "config", label: "Config", icon: Settings },
|
{ path: "/config", labelKey: "config", label: "Config", icon: Settings },
|
||||||
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
|
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
|
||||||
@@ -213,6 +217,22 @@ function buildNavItems(
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Split merged nav into built-in sidebar entries vs plugin tabs, preserving plugin order hints. */
|
||||||
|
function partitionSidebarNav(
|
||||||
|
builtIn: NavItem[],
|
||||||
|
manifests: PluginManifest[],
|
||||||
|
): { coreItems: NavItem[]; pluginItems: NavItem[] } {
|
||||||
|
const merged = buildNavItems(builtIn, manifests);
|
||||||
|
const builtinPaths = new Set(builtIn.map((i) => i.path));
|
||||||
|
const coreItems: NavItem[] = [];
|
||||||
|
const pluginItems: NavItem[] = [];
|
||||||
|
for (const item of merged) {
|
||||||
|
if (builtinPaths.has(item.path)) coreItems.push(item);
|
||||||
|
else pluginItems.push(item);
|
||||||
|
}
|
||||||
|
return { coreItems, pluginItems };
|
||||||
|
}
|
||||||
|
|
||||||
function buildRoutes(
|
function buildRoutes(
|
||||||
builtinRoutes: Record<string, ComponentType>,
|
builtinRoutes: Record<string, ComponentType>,
|
||||||
manifests: PluginManifest[],
|
manifests: PluginManifest[],
|
||||||
@@ -253,6 +273,7 @@ function buildRoutes(
|
|||||||
|
|
||||||
for (const m of addons) {
|
for (const m of addons) {
|
||||||
if (m.tab.hidden) continue;
|
if (m.tab.hidden) continue;
|
||||||
|
if (m.tab.path === "/plugins") continue;
|
||||||
if (builtinRoutes[m.tab.path]) continue;
|
if (builtinRoutes[m.tab.path]) continue;
|
||||||
routes.push({
|
routes.push({
|
||||||
key: `plugin:${m.name}`,
|
key: `plugin:${m.name}`,
|
||||||
@@ -263,6 +284,7 @@ function buildRoutes(
|
|||||||
|
|
||||||
for (const m of manifests) {
|
for (const m of manifests) {
|
||||||
if (!m.tab.hidden) continue;
|
if (!m.tab.hidden) continue;
|
||||||
|
if (m.tab.path === "/plugins") continue;
|
||||||
if (builtinRoutes[m.tab.path] || m.tab.override) continue;
|
if (builtinRoutes[m.tab.path] || m.tab.override) continue;
|
||||||
routes.push({
|
routes.push({
|
||||||
key: `plugin:hidden:${m.name}`,
|
key: `plugin:hidden:${m.name}`,
|
||||||
@@ -322,8 +344,8 @@ export default function App() {
|
|||||||
[embeddedChat],
|
[embeddedChat],
|
||||||
);
|
);
|
||||||
|
|
||||||
const navItems = useMemo(
|
const sidebarNav = useMemo(
|
||||||
() => buildNavItems(builtinNav, manifests),
|
() => partitionSidebarNav(builtinNav, manifests),
|
||||||
[builtinNav, manifests],
|
[builtinNav, manifests],
|
||||||
);
|
);
|
||||||
const routes = useMemo(
|
const routes = useMemo(
|
||||||
@@ -476,56 +498,44 @@ export default function App() {
|
|||||||
aria-label={t.app.navigation}
|
aria-label={t.app.navigation}
|
||||||
>
|
>
|
||||||
<ul className="flex flex-col">
|
<ul className="flex flex-col">
|
||||||
{navItems.map(({ path, label, labelKey, icon: Icon }) => {
|
{sidebarNav.coreItems.map((item) => (
|
||||||
const navLabel = labelKey
|
<SidebarNavLink
|
||||||
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
|
closeMobile={closeMobile}
|
||||||
: label;
|
item={item}
|
||||||
return (
|
key={item.path}
|
||||||
<li key={path}>
|
t={t}
|
||||||
<NavLink
|
/>
|
||||||
to={path}
|
))}
|
||||||
end={path === "/sessions"}
|
|
||||||
onClick={closeMobile}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
cn(
|
|
||||||
"group relative flex items-center gap-3",
|
|
||||||
"px-5 py-2.5",
|
|
||||||
"font-mondwest text-[0.8rem] tracking-[0.12em]",
|
|
||||||
"whitespace-nowrap transition-colors cursor-pointer",
|
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
|
||||||
isActive
|
|
||||||
? "text-midground"
|
|
||||||
: "opacity-60 hover:opacity-100",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
clipPath: "var(--component-tab-clip-path)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<>
|
|
||||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{navLabel}</span>
|
|
||||||
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isActive && (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
|
||||||
style={{ mixBlendMode: "plus-lighter" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{sidebarNav.pluginItems.length > 0 && (
|
||||||
|
<div
|
||||||
|
aria-labelledby="hermes-sidebar-plugin-nav-heading"
|
||||||
|
className="flex flex-col border-t border-current/10 pb-2"
|
||||||
|
role="group"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-5 pt-2.5 pb-1",
|
||||||
|
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
|
||||||
|
)}
|
||||||
|
id="hermes-sidebar-plugin-nav-heading"
|
||||||
|
>
|
||||||
|
{t.app.pluginNavSection}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{sidebarNav.pluginItems.map((item) => (
|
||||||
|
<SidebarNavLink
|
||||||
|
closeMobile={closeMobile}
|
||||||
|
item={item}
|
||||||
|
key={item.path}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<SidebarSystemActions onNavigate={closeMobile} />
|
<SidebarSystemActions onNavigate={closeMobile} />
|
||||||
@@ -615,6 +625,57 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
|
||||||
|
const { path, label, labelKey, icon: Icon } = item;
|
||||||
|
|
||||||
|
const navLabel = labelKey
|
||||||
|
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
|
||||||
|
: label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to={path}
|
||||||
|
end={path === "/sessions"}
|
||||||
|
onClick={closeMobile}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"group relative flex items-center gap-3",
|
||||||
|
"px-5 py-2.5",
|
||||||
|
"font-mondwest text-[0.8rem] tracking-[0.12em]",
|
||||||
|
"whitespace-nowrap transition-colors cursor-pointer",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||||
|
isActive ? "text-midground" : "opacity-60 hover:opacity-100",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
clipPath: "var(--component-tab-clip-path)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{navLabel}</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||||
|
style={{ mixBlendMode: "plus-lighter" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
|
function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -733,6 +794,12 @@ interface NavItem {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SidebarNavLinkProps {
|
||||||
|
closeMobile: () => void;
|
||||||
|
item: NavItem;
|
||||||
|
t: Translations;
|
||||||
|
}
|
||||||
|
|
||||||
interface SystemActionItem {
|
interface SystemActionItem {
|
||||||
action: SystemAction;
|
action: SystemAction;
|
||||||
icon: ComponentType<{ className?: string }>;
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const en: Translations = {
|
|||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
models: "Models",
|
models: "Models",
|
||||||
profiles: "profiles : multi agents",
|
profiles: "profiles : multi agents",
|
||||||
|
plugins: "Plugins",
|
||||||
sessions: "Sessions",
|
sessions: "Sessions",
|
||||||
skills: "Skills",
|
skills: "Skills",
|
||||||
},
|
},
|
||||||
@@ -84,6 +85,7 @@ export const en: Translations = {
|
|||||||
navigation: "Navigation",
|
navigation: "Navigation",
|
||||||
openDocumentation: "Open documentation in a new tab",
|
openDocumentation: "Open documentation in a new tab",
|
||||||
openNavigation: "Open navigation",
|
openNavigation: "Open navigation",
|
||||||
|
pluginNavSection: "Plugins",
|
||||||
sessionsActiveCount: "{count} active",
|
sessionsActiveCount: "{count} active",
|
||||||
statusOverview: "Status overview",
|
statusOverview: "Status overview",
|
||||||
system: "System",
|
system: "System",
|
||||||
@@ -256,6 +258,47 @@ export const en: Translations = {
|
|||||||
renamed: "Renamed",
|
renamed: "Renamed",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
pluginsPage: {
|
||||||
|
contextEngineLabel: "Context engine",
|
||||||
|
dashboardSlots: "Dashboard slots",
|
||||||
|
disableRuntime: "Disable",
|
||||||
|
enableAfterInstall: "Enable after install",
|
||||||
|
enableRuntime: "Enable",
|
||||||
|
forceReinstall: "Force reinstall (delete existing folder first)",
|
||||||
|
headline:
|
||||||
|
"Discover, install, enable, and update Hermes plugins (`hermes plugins` parity).",
|
||||||
|
identifierLabel: "Git URL or owner/repo",
|
||||||
|
inactive: "inactive",
|
||||||
|
installBtn: "Install from Git",
|
||||||
|
installHeading: "Install from GitHub / Git URL",
|
||||||
|
installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL.",
|
||||||
|
memoryProviderLabel: "Memory provider",
|
||||||
|
missingEnvWarn: "Set these in Keys before the plugin can run:",
|
||||||
|
noDashboardTab: "No dashboard tab",
|
||||||
|
openTab: "Open",
|
||||||
|
orphanHeading: "Dashboard-only extensions (no agent plugin.yaml match)",
|
||||||
|
pluginListHeading: "Installed plugins",
|
||||||
|
providerDefaults: "built-in / default",
|
||||||
|
providersHeading: "Runtime provider plugins",
|
||||||
|
providersHint:
|
||||||
|
"Writes memory.provider (empty = built-in) and context.engine to config.yaml. Takes effect next session.",
|
||||||
|
refreshDashboard: "Rescan dashboard extensions",
|
||||||
|
removeConfirm: "Remove this plugin from ~/.hermes/plugins/?",
|
||||||
|
removeHint: "Only user-installed plugins under ~/.hermes/plugins can be removed.",
|
||||||
|
rescanHeading: "SPA plugin registry",
|
||||||
|
rescanHint: "Rescan after adding files on disk so the dashboard sidebar picks up new manifests.",
|
||||||
|
runtimeHeading: "Gateway runtime (YAML plugins)",
|
||||||
|
saveProviders: "Save provider settings",
|
||||||
|
savedProviders: "Provider settings saved.",
|
||||||
|
sourceBadge: "Source",
|
||||||
|
authRequired: "Auth required",
|
||||||
|
authRequiredHint: "Run this command to authenticate:",
|
||||||
|
updateGit: "Git pull",
|
||||||
|
versionBadge: "Version",
|
||||||
|
showInSidebar: "Show in sidebar",
|
||||||
|
hideFromSidebar: "Hide from sidebar",
|
||||||
|
},
|
||||||
|
|
||||||
skills: {
|
skills: {
|
||||||
title: "Skills",
|
title: "Skills",
|
||||||
searchPlaceholder: "Search skills and toolsets...",
|
searchPlaceholder: "Search skills and toolsets...",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export interface Translations {
|
|||||||
logs: string;
|
logs: string;
|
||||||
models: string;
|
models: string;
|
||||||
profiles: string;
|
profiles: string;
|
||||||
|
plugins: string;
|
||||||
sessions: string;
|
sessions: string;
|
||||||
skills: string;
|
skills: string;
|
||||||
};
|
};
|
||||||
@@ -84,6 +85,7 @@ export interface Translations {
|
|||||||
navigation: string;
|
navigation: string;
|
||||||
openDocumentation: string;
|
openDocumentation: string;
|
||||||
openNavigation: string;
|
openNavigation: string;
|
||||||
|
pluginNavSection: string;
|
||||||
sessionsActiveCount: string;
|
sessionsActiveCount: string;
|
||||||
statusOverview: string;
|
statusOverview: string;
|
||||||
system: string;
|
system: string;
|
||||||
@@ -228,6 +230,46 @@ export interface Translations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Plugins page ──
|
||||||
|
pluginsPage: {
|
||||||
|
contextEngineLabel: string;
|
||||||
|
dashboardSlots: string;
|
||||||
|
disableRuntime: string;
|
||||||
|
enableAfterInstall: string;
|
||||||
|
enableRuntime: string;
|
||||||
|
forceReinstall: string;
|
||||||
|
headline: string;
|
||||||
|
identifierLabel: string;
|
||||||
|
inactive: string;
|
||||||
|
installBtn: string;
|
||||||
|
installHeading: string;
|
||||||
|
installHint: string;
|
||||||
|
memoryProviderLabel: string;
|
||||||
|
missingEnvWarn: string;
|
||||||
|
noDashboardTab: string;
|
||||||
|
openTab: string;
|
||||||
|
orphanHeading: string;
|
||||||
|
pluginListHeading: string;
|
||||||
|
providerDefaults: string;
|
||||||
|
providersHeading: string;
|
||||||
|
providersHint: string;
|
||||||
|
refreshDashboard: string;
|
||||||
|
removeConfirm: string;
|
||||||
|
removeHint: string;
|
||||||
|
rescanHeading: string;
|
||||||
|
rescanHint: string;
|
||||||
|
runtimeHeading: string;
|
||||||
|
saveProviders: string;
|
||||||
|
savedProviders: string;
|
||||||
|
sourceBadge: string;
|
||||||
|
authRequired: string;
|
||||||
|
authRequiredHint: string;
|
||||||
|
updateGit: string;
|
||||||
|
versionBadge: string;
|
||||||
|
showInSidebar: string;
|
||||||
|
hideFromSidebar: string;
|
||||||
|
};
|
||||||
|
|
||||||
// ── Profiles page ──
|
// ── Profiles page ──
|
||||||
profiles: {
|
profiles: {
|
||||||
newProfile: string;
|
newProfile: string;
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export const zh: Translations = {
|
|||||||
logs: "日志",
|
logs: "日志",
|
||||||
models: "模型",
|
models: "模型",
|
||||||
profiles: "多Agent配置",
|
profiles: "多Agent配置",
|
||||||
|
plugins: "插件管理",
|
||||||
sessions: "会话",
|
sessions: "会话",
|
||||||
skills: "技能",
|
skills: "技能",
|
||||||
},
|
},
|
||||||
@@ -83,6 +84,7 @@ export const zh: Translations = {
|
|||||||
navigation: "导航",
|
navigation: "导航",
|
||||||
openDocumentation: "在新标签页中打开文档",
|
openDocumentation: "在新标签页中打开文档",
|
||||||
openNavigation: "打开导航",
|
openNavigation: "打开导航",
|
||||||
|
pluginNavSection: "插件",
|
||||||
sessionsActiveCount: "{count} 个活跃",
|
sessionsActiveCount: "{count} 个活跃",
|
||||||
statusOverview: "状态概览",
|
statusOverview: "状态概览",
|
||||||
system: "系统",
|
system: "系统",
|
||||||
@@ -253,6 +255,46 @@ export const zh: Translations = {
|
|||||||
renamed: "已重命名",
|
renamed: "已重命名",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
pluginsPage: {
|
||||||
|
contextEngineLabel: "上下文引擎",
|
||||||
|
dashboardSlots: "面板插槽",
|
||||||
|
disableRuntime: "禁用",
|
||||||
|
enableAfterInstall: "安装后启用",
|
||||||
|
enableRuntime: "启用",
|
||||||
|
forceReinstall: "强制重装(先删除已有目录)",
|
||||||
|
headline: "发现、安装、启用和更新 Hermes 插件(对齐 `hermes plugins` CLI)。",
|
||||||
|
identifierLabel: "Git 地址或 owner/repo",
|
||||||
|
inactive: "未启用",
|
||||||
|
installBtn: "从 Git 安装",
|
||||||
|
installHeading: "从 GitHub / Git 地址安装",
|
||||||
|
installHint: "使用 owner/repo 简写或完整的 https:// / git@ 克隆地址。",
|
||||||
|
memoryProviderLabel: "记忆提供方",
|
||||||
|
missingEnvWarn: "在「密钥」页面设置以下变量后再运行插件:",
|
||||||
|
noDashboardTab: "无仪表盘标签",
|
||||||
|
openTab: "打开",
|
||||||
|
orphanHeading: "仅仪表盘扩展(无匹配的 agent plugin.yaml)",
|
||||||
|
pluginListHeading: "已安装插件",
|
||||||
|
providerDefaults: "内置 / 默认",
|
||||||
|
providersHeading: "运行时提供方插件",
|
||||||
|
providersHint:
|
||||||
|
"写入 config.yaml:memory.provider(留空为内置)、context.engine。下次会话生效。",
|
||||||
|
refreshDashboard: "重新扫描仪表盘扩展",
|
||||||
|
removeConfirm: "从 ~/.hermes/plugins/ 删除此插件?",
|
||||||
|
removeHint: "仅可移除用户安装在 ~/.hermes/plugins 下的插件。",
|
||||||
|
rescanHeading: "SPA 插件注册表",
|
||||||
|
rescanHint: "在磁盘新增文件后扫描,使侧边栏载入新 manifest。",
|
||||||
|
runtimeHeading: "网关运行时(YAML 插件)",
|
||||||
|
saveProviders: "保存提供方设置",
|
||||||
|
savedProviders: "提供方设置已保存。",
|
||||||
|
sourceBadge: "来源",
|
||||||
|
authRequired: "需要认证",
|
||||||
|
authRequiredHint: "运行此命令以完成认证:",
|
||||||
|
updateGit: "git pull",
|
||||||
|
versionBadge: "版本",
|
||||||
|
showInSidebar: "在侧边栏显示",
|
||||||
|
hideFromSidebar: "从侧边栏隐藏",
|
||||||
|
},
|
||||||
|
|
||||||
skills: {
|
skills: {
|
||||||
title: "技能",
|
title: "技能",
|
||||||
searchPlaceholder: "搜索技能和工具集...",
|
searchPlaceholder: "搜索技能和工具集...",
|
||||||
|
|||||||
@@ -259,6 +259,56 @@ export const api = {
|
|||||||
rescanPlugins: () =>
|
rescanPlugins: () =>
|
||||||
fetchJSON<{ ok: boolean; count: number }>("/api/dashboard/plugins/rescan"),
|
fetchJSON<{ ok: boolean; count: number }>("/api/dashboard/plugins/rescan"),
|
||||||
|
|
||||||
|
getPluginsHub: () => fetchJSON<PluginsHubResponse>("/api/dashboard/plugins/hub"),
|
||||||
|
|
||||||
|
installAgentPlugin: (body: AgentPluginInstallRequest) =>
|
||||||
|
fetchJSON<AgentPluginInstallResponse>("/api/dashboard/agent-plugins/install", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...body }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
enableAgentPlugin: (name: string) =>
|
||||||
|
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
|
||||||
|
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/enable`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
|
||||||
|
disableAgentPlugin: (name: string) =>
|
||||||
|
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
|
||||||
|
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/disable`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
|
||||||
|
updateAgentPlugin: (name: string) =>
|
||||||
|
fetchJSON<AgentPluginUpdateResponse>(
|
||||||
|
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/update`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
|
||||||
|
removeAgentPlugin: (name: string) =>
|
||||||
|
fetchJSON<{ ok: boolean; name: string }>(
|
||||||
|
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
),
|
||||||
|
|
||||||
|
savePluginProviders: (body: PluginProvidersPutRequest) =>
|
||||||
|
fetchJSON<{ ok: boolean }>("/api/dashboard/plugin-providers", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
|
setPluginVisibility: (name: string, hidden: boolean) =>
|
||||||
|
fetchJSON<{ ok: boolean; name: string; hidden: boolean }>(
|
||||||
|
`/api/dashboard/plugins/${encodeURIComponent(name)}/visibility`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ hidden }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// Dashboard themes
|
// Dashboard themes
|
||||||
getThemes: () =>
|
getThemes: () =>
|
||||||
fetchJSON<DashboardThemesResponse>("/api/dashboard/themes"),
|
fetchJSON<DashboardThemesResponse>("/api/dashboard/themes"),
|
||||||
@@ -668,8 +718,67 @@ export interface PluginManifestResponse {
|
|||||||
override?: string;
|
override?: string;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
};
|
};
|
||||||
|
slots?: string[];
|
||||||
entry: string;
|
entry: string;
|
||||||
css?: string | null;
|
css?: string | null;
|
||||||
has_api: boolean;
|
has_api: boolean;
|
||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HubAgentPluginRow {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
source: string;
|
||||||
|
runtime_status: "disabled" | "enabled" | "inactive";
|
||||||
|
has_dashboard_manifest: boolean;
|
||||||
|
dashboard_manifest: PluginManifestResponse | null;
|
||||||
|
path: string;
|
||||||
|
can_remove: boolean;
|
||||||
|
can_update_git: boolean;
|
||||||
|
auth_required: boolean;
|
||||||
|
auth_command: string;
|
||||||
|
user_hidden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginsHubProviders {
|
||||||
|
memory_provider: string;
|
||||||
|
memory_options: Array<{ name: string; description: string }>;
|
||||||
|
context_engine: string;
|
||||||
|
context_options: Array<{ name: string; description: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginsHubResponse {
|
||||||
|
plugins: HubAgentPluginRow[];
|
||||||
|
orphan_dashboard_plugins: PluginManifestResponse[];
|
||||||
|
providers: PluginsHubProviders;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentPluginInstallRequest {
|
||||||
|
identifier: string;
|
||||||
|
force?: boolean;
|
||||||
|
enable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentPluginInstallResponse {
|
||||||
|
ok: boolean;
|
||||||
|
plugin_name?: string;
|
||||||
|
warnings?: string[];
|
||||||
|
missing_env?: string[];
|
||||||
|
after_install_path?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentPluginUpdateResponse {
|
||||||
|
ok: boolean;
|
||||||
|
name?: string;
|
||||||
|
output?: string;
|
||||||
|
unchanged?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginProvidersPutRequest {
|
||||||
|
memory_provider?: string;
|
||||||
|
context_engine?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = {
|
|||||||
"/logs": "logs",
|
"/logs": "logs",
|
||||||
"/cron": "cron",
|
"/cron": "cron",
|
||||||
"/skills": "skills",
|
"/skills": "skills",
|
||||||
|
"/plugins": "plugins",
|
||||||
"/config": "config",
|
"/config": "config",
|
||||||
"/env": "keys",
|
"/env": "keys",
|
||||||
"/docs": "documentation",
|
"/docs": "documentation",
|
||||||
|
|||||||
581
web/src/pages/PluginsPage.tsx
Normal file
581
web/src/pages/PluginsPage.tsx
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ExternalLink, RefreshCw, Puzzle, Trash2, Eye, EyeOff } from "lucide-react";
|
||||||
|
import type { Translations } from "@/i18n/types";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { HubAgentPluginRow, PluginsHubResponse } from "@/lib/api";
|
||||||
|
import { Button } from "@nous-research/ui/ui/components/button";
|
||||||
|
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||||
|
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||||
|
import { Switch } from "@nous-research/ui/ui/components/switch";
|
||||||
|
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||||
|
import { CommandBlock } from "@nous-research/ui/ui/components/command-block";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
import { Toast } from "@/components/Toast";
|
||||||
|
import { useI18n } from "@/i18n";
|
||||||
|
import { PluginSlot } from "@/plugins";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
|
|
||||||
|
/** Select value for built-in memory (`config` uses empty string). Never use `""` — UI Select maps empty value to an empty label. */
|
||||||
|
const MEMORY_PROVIDER_BUILTIN = "__hermes_memory_builtin__";
|
||||||
|
|
||||||
|
export default function PluginsPage() {
|
||||||
|
const [hub, setHub] = useState<PluginsHubResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [installId, setInstallId] = useState("");
|
||||||
|
const [installForce, setInstallForce] = useState(false);
|
||||||
|
const [installEnable, setInstallEnable] = useState(true);
|
||||||
|
const [installBusy, setInstallBusy] = useState(false);
|
||||||
|
const [rescanBusy, setRescanBusy] = useState(false);
|
||||||
|
const [memorySel, setMemorySel] = useState(MEMORY_PROVIDER_BUILTIN);
|
||||||
|
const [contextSel, setContextSel] = useState("compressor");
|
||||||
|
const [providerBusy, setProviderBusy] = useState(false);
|
||||||
|
const [rowBusy, setRowBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { toast, showToast } = useToast();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { setEnd } = usePageHeader();
|
||||||
|
|
||||||
|
const loadHub = useCallback(() => {
|
||||||
|
return api
|
||||||
|
.getPluginsHub()
|
||||||
|
.then((h) => {
|
||||||
|
setHub(h);
|
||||||
|
const p = h.providers;
|
||||||
|
setMemorySel(p.memory_provider ? p.memory_provider : MEMORY_PROVIDER_BUILTIN);
|
||||||
|
setContextSel(p.context_engine || "compressor");
|
||||||
|
})
|
||||||
|
.catch(() => showToast(t.common.loading, "error"));
|
||||||
|
}, [showToast, t.common.loading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
void loadHub().finally(() => setLoading(false));
|
||||||
|
}, [loadHub]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEnd(
|
||||||
|
<Button
|
||||||
|
ghost
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0 gap-2"
|
||||||
|
disabled={loading || rescanBusy}
|
||||||
|
onClick={() => void onRescan()}
|
||||||
|
>
|
||||||
|
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||||
|
{t.pluginsPage.refreshDashboard}
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
return () => setEnd(null);
|
||||||
|
}, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]);
|
||||||
|
|
||||||
|
const onInstall = async () => {
|
||||||
|
const id = installId.trim();
|
||||||
|
if (!id) {
|
||||||
|
showToast(t.pluginsPage.installHint, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInstallBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await api.installAgentPlugin({
|
||||||
|
identifier: id,
|
||||||
|
force: installForce,
|
||||||
|
enable: installEnable,
|
||||||
|
});
|
||||||
|
showToast(`${r.plugin_name ?? id} installed`, "success");
|
||||||
|
if ((r.warnings?.length ?? 0) > 0) showToast(r.warnings!.join(" "), "error");
|
||||||
|
if ((r.missing_env?.length ?? 0) > 0)
|
||||||
|
showToast(`${t.pluginsPage.missingEnvWarn} ${r.missing_env!.join(", ")}`, "error");
|
||||||
|
setInstallId("");
|
||||||
|
await loadHub();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e instanceof Error ? e.message : "Install failed", "error");
|
||||||
|
} finally {
|
||||||
|
setInstallBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRescan = async () => {
|
||||||
|
setRescanBusy(true);
|
||||||
|
try {
|
||||||
|
const rc = await api.rescanPlugins();
|
||||||
|
showToast(
|
||||||
|
`${t.pluginsPage.refreshDashboard} (${rc.count})`,
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
await loadHub();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e instanceof Error ? e.message : "Rescan failed", "error");
|
||||||
|
} finally {
|
||||||
|
setRescanBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSaveProviders = async () => {
|
||||||
|
setProviderBusy(true);
|
||||||
|
try {
|
||||||
|
await api.savePluginProviders({
|
||||||
|
memory_provider:
|
||||||
|
memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel,
|
||||||
|
context_engine: contextSel,
|
||||||
|
});
|
||||||
|
showToast(t.pluginsPage.savedProviders, "success");
|
||||||
|
await loadHub();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e instanceof Error ? e.message : "Save failed", "error");
|
||||||
|
} finally {
|
||||||
|
setProviderBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRuntimeLoading = async (name: string, fn: () => Promise<unknown>) => {
|
||||||
|
setRowBusy(name);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
await loadHub();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e instanceof Error ? e.message : "Failed", "error");
|
||||||
|
} finally {
|
||||||
|
setRowBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = hub?.plugins ?? [];
|
||||||
|
const providers = hub?.providers;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<PluginSlot name="plugins:top" />
|
||||||
|
|
||||||
|
<div className={cn("flex w-full flex-col gap-8")}>
|
||||||
|
|
||||||
|
{providers && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t.pluginsPage.providersHeading}</CardTitle>
|
||||||
|
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case">
|
||||||
|
{t.pluginsPage.providersHint}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 max-w-full">
|
||||||
|
<div className="grid gap-2 min-w-0">
|
||||||
|
<Label htmlFor="mem-provider">{t.pluginsPage.memoryProviderLabel}</Label>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
id="mem-provider"
|
||||||
|
className="w-full"
|
||||||
|
value={memorySel}
|
||||||
|
onValueChange={setMemorySel}
|
||||||
|
>
|
||||||
|
<SelectOption value={MEMORY_PROVIDER_BUILTIN}>
|
||||||
|
{`(${t.pluginsPage.providerDefaults})`}
|
||||||
|
</SelectOption>
|
||||||
|
|
||||||
|
{providers.memory_options.map((o) => (
|
||||||
|
<SelectOption key={o.name} value={o.name}>
|
||||||
|
{o.name}
|
||||||
|
</SelectOption>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 min-w-0">
|
||||||
|
<Label htmlFor="ctx-engine">{t.pluginsPage.contextEngineLabel}</Label>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
id="ctx-engine"
|
||||||
|
className="w-full"
|
||||||
|
value={contextSel}
|
||||||
|
onValueChange={setContextSel}
|
||||||
|
>
|
||||||
|
<SelectOption value="compressor">compressor</SelectOption>
|
||||||
|
|
||||||
|
{providers.context_options
|
||||||
|
.filter((o) => o.name !== "compressor")
|
||||||
|
.map((o) => (
|
||||||
|
<SelectOption key={o.name} value={o.name}>
|
||||||
|
{o.name}
|
||||||
|
</SelectOption>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-fit gap-2"
|
||||||
|
size="sm"
|
||||||
|
disabled={providerBusy}
|
||||||
|
onClick={() => void onSaveProviders()}
|
||||||
|
>
|
||||||
|
{providerBusy ? <Spinner /> : null}
|
||||||
|
{t.pluginsPage.saveProviders}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t.pluginsPage.installHeading}</CardTitle>
|
||||||
|
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case">
|
||||||
|
{t.pluginsPage.installHint}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
|
||||||
|
<Label htmlFor="install-url">{t.pluginsPage.identifierLabel}</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
className="normal-case font-sans lowercase"
|
||||||
|
id="install-url"
|
||||||
|
placeholder="owner/repo or https://..."
|
||||||
|
spellCheck={false}
|
||||||
|
value={installId}
|
||||||
|
onChange={(e) => setInstallId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-8">
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
|
||||||
|
<Switch checked={installForce} onCheckedChange={setInstallForce} />
|
||||||
|
|
||||||
|
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case">
|
||||||
|
{t.pluginsPage.forceReinstall}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
|
||||||
|
<Switch checked={installEnable} onCheckedChange={setInstallEnable} />
|
||||||
|
|
||||||
|
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case">
|
||||||
|
{t.pluginsPage.enableAfterInstall}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-fit gap-2"
|
||||||
|
size="sm"
|
||||||
|
disabled={installBusy}
|
||||||
|
onClick={() => void onInstall()}
|
||||||
|
>
|
||||||
|
{installBusy ? <Spinner /> : <Puzzle className="h-3.5 w-3.5" />}
|
||||||
|
{t.pluginsPage.installBtn}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case">
|
||||||
|
{t.pluginsPage.rescanHint}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case">
|
||||||
|
{t.pluginsPage.removeHint}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
|
||||||
|
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midground/85">
|
||||||
|
{t.pluginsPage.pluginListHeading}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 py-8 text-[0.8rem] text-midforeground/65">
|
||||||
|
|
||||||
|
<Spinner />
|
||||||
|
<span>{t.common.loading}</span>
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
|
||||||
|
<p className="text-[0.75rem] text-midforeground/55 normal-case">{t.common.noResults}</p>
|
||||||
|
) : (
|
||||||
|
|
||||||
|
<ul className="flex flex-col gap-3">
|
||||||
|
|
||||||
|
{rows.map((row: HubAgentPluginRow) => (
|
||||||
|
|
||||||
|
<li key={row.name}>
|
||||||
|
|
||||||
|
|
||||||
|
<PluginRowCard
|
||||||
|
{...{ row, rowBusy, setRuntimeLoading, showToast, t }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(hub?.orphan_dashboard_plugins?.length ?? 0) > 0 ? (
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 opacity-95">
|
||||||
|
|
||||||
|
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midforeground/85">
|
||||||
|
{t.pluginsPage.orphanHeading}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<ul className="flex flex-col gap-2 rounded border border-current/15 p-4">
|
||||||
|
|
||||||
|
{hub!.orphan_dashboard_plugins.map((m) => (
|
||||||
|
|
||||||
|
<li className="text-[0.7rem] normal-case opacity-85" key={m.name}>
|
||||||
|
|
||||||
|
|
||||||
|
{m.label ?? m.name} — {m.description || m.tab?.path}
|
||||||
|
|
||||||
|
|
||||||
|
{!m.tab?.hidden ? (
|
||||||
|
|
||||||
|
|
||||||
|
<Link className="ml-3 inline-flex items-center gap-1 underline" to={m.tab.path}>
|
||||||
|
|
||||||
|
|
||||||
|
<ExternalLink className="h-3 w-3 opacity-65" />
|
||||||
|
|
||||||
|
{t.pluginsPage.openTab}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Toast toast={toast} />
|
||||||
|
<PluginSlot name="plugins:bottom" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginRowCardProps {
|
||||||
|
|
||||||
|
row: HubAgentPluginRow;
|
||||||
|
rowBusy: string | null;
|
||||||
|
setRuntimeLoading: (
|
||||||
|
name: string,
|
||||||
|
fn: () => Promise<unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
showToast: (msg: string, variant: "success" | "error") => void;
|
||||||
|
t: Translations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginRowCard(props: PluginRowCardProps) {
|
||||||
|
const {
|
||||||
|
row,
|
||||||
|
rowBusy,
|
||||||
|
setRuntimeLoading,
|
||||||
|
showToast,
|
||||||
|
t,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const dm = row.dashboard_manifest;
|
||||||
|
|
||||||
|
const tabPath = dm?.tab && !dm.tab.hidden ? dm.tab.override ?? dm.tab.path : null;
|
||||||
|
|
||||||
|
const busy = rowBusy === row.name;
|
||||||
|
|
||||||
|
const badgeTone =
|
||||||
|
row.runtime_status === "enabled"
|
||||||
|
? "success"
|
||||||
|
: row.runtime_status === "disabled"
|
||||||
|
? "destructive"
|
||||||
|
: "outline";
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<Card className={cn(busy ? "opacity-70" : undefined)}>
|
||||||
|
|
||||||
|
|
||||||
|
<CardContent className="flex flex-col gap-4 px-6 py-4">
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
|
||||||
|
<span className="truncate font-semibold">{row.name}</span>
|
||||||
|
|
||||||
|
<Badge tone="outline">
|
||||||
|
{t.pluginsPage.sourceBadge}: {row.source}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
|
||||||
|
<Badge tone="outline">v{row.version || "—"}</Badge>
|
||||||
|
|
||||||
|
<Badge tone={badgeTone}>{row.runtime_status}</Badge>
|
||||||
|
|
||||||
|
{row.auth_required ? (
|
||||||
|
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{row.description ? (
|
||||||
|
|
||||||
|
<p className="mt-2 max-w-2xl text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case">
|
||||||
|
{row.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||||
|
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={busy || row.runtime_status === "enabled"}
|
||||||
|
ghost
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
void setRuntimeLoading(row.name, async () => {
|
||||||
|
await api.enableAgentPlugin(row.name);
|
||||||
|
showToast(t.pluginsPage.enableRuntime, "success");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.pluginsPage.enableRuntime}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={busy || row.runtime_status === "disabled"}
|
||||||
|
ghost
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
void setRuntimeLoading(row.name, async () => {
|
||||||
|
await api.disableAgentPlugin(row.name);
|
||||||
|
showToast(t.pluginsPage.disableRuntime, "success");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.pluginsPage.disableRuntime}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{tabPath ? (
|
||||||
|
|
||||||
|
<Link
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-none px-3 py-1.5",
|
||||||
|
"border border-current/25 hover:bg-current/10",
|
||||||
|
"font-mondwest text-[0.65rem] tracking-[0.1em] uppercase",
|
||||||
|
)}
|
||||||
|
to={tabPath}
|
||||||
|
>
|
||||||
|
{t.pluginsPage.openTab}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{row.can_update_git ? (
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
ghost
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
void setRuntimeLoading(row.name, async () => {
|
||||||
|
await api.updateAgentPlugin(row.name);
|
||||||
|
showToast(t.pluginsPage.updateGit, "success");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? <Spinner /> : null}
|
||||||
|
{t.pluginsPage.updateGit}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{row.has_dashboard_manifest ? (
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
ghost
|
||||||
|
size="sm"
|
||||||
|
title={row.user_hidden ? t.pluginsPage.showInSidebar : t.pluginsPage.hideFromSidebar}
|
||||||
|
onClick={() => {
|
||||||
|
void setRuntimeLoading(row.name, async () => {
|
||||||
|
await api.setPluginVisibility(row.name, !row.user_hidden);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.user_hidden ? (
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{row.user_hidden ? t.pluginsPage.showInSidebar : t.pluginsPage.hideFromSidebar}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{row.can_remove ? (
|
||||||
|
|
||||||
|
|
||||||
|
<Button
|
||||||
|
destructive
|
||||||
|
disabled={busy}
|
||||||
|
ghost
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const ok =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.confirm(t.pluginsPage.removeConfirm)
|
||||||
|
: false;
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
void setRuntimeLoading(row.name, async () => {
|
||||||
|
await api.removeAgentPlugin(row.name);
|
||||||
|
showToast(`${row.name} removed`, "success");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
{busy ? <Spinner /> : <Trash2 className="h-3.5 w-3.5" />}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dm?.slots?.length ? (
|
||||||
|
|
||||||
|
<p className="text-[0.65rem] tracking-[0.05em] text-midforeground/55 normal-case">
|
||||||
|
{t.pluginsPage.dashboardSlots}: {dm.slots.join(", ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{row.auth_required ? (
|
||||||
|
<CommandBlock
|
||||||
|
label={t.pluginsPage.authRequiredHint}
|
||||||
|
code={row.auth_command}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!row.has_dashboard_manifest && !dm ? (
|
||||||
|
|
||||||
|
|
||||||
|
<p className="text-[0.65rem] italic text-midforeground/45 normal-case">
|
||||||
|
{t.pluginsPage.noDashboardTab}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ import React, { Fragment, useEffect, useState } from "react";
|
|||||||
* - `cron:bottom` — bottom of /cron page
|
* - `cron:bottom` — bottom of /cron page
|
||||||
* - `skills:top` — top of /skills page
|
* - `skills:top` — top of /skills page
|
||||||
* - `skills:bottom` — bottom of /skills page
|
* - `skills:bottom` — bottom of /skills page
|
||||||
|
* - `plugins:top` — top of /plugins page
|
||||||
|
* - `plugins:bottom` — bottom of /plugins page
|
||||||
* - `config:top` — top of /config page
|
* - `config:top` — top of /config page
|
||||||
* - `config:bottom` — bottom of /config page
|
* - `config:bottom` — bottom of /config page
|
||||||
* - `env:top` — top of /env (Keys) page
|
* - `env:top` — top of /env (Keys) page
|
||||||
@@ -78,6 +80,8 @@ export const KNOWN_SLOT_NAMES = [
|
|||||||
"cron:bottom",
|
"cron:bottom",
|
||||||
"skills:top",
|
"skills:top",
|
||||||
"skills:bottom",
|
"skills:bottom",
|
||||||
|
"plugins:top",
|
||||||
|
"plugins:bottom",
|
||||||
"config:top",
|
"config:top",
|
||||||
"config:bottom",
|
"config:bottom",
|
||||||
"env:top",
|
"env:top",
|
||||||
|
|||||||
Reference in New Issue
Block a user