refactor: decompose ui and model list hotspots

This commit is contained in:
rookiestar28
2026-03-19 19:54:16 +08:00
parent e5e4af3657
commit a58cf621fa
10 changed files with 806 additions and 561 deletions
+63 -228
View File
@@ -7,7 +7,6 @@ from __future__ import annotations
import json
import logging
import time
if __package__ and "." in __package__:
from ..services.import_fallback import import_attrs_dual, import_module_dual
@@ -167,49 +166,42 @@ except Exception:
logger = logging.getLogger("ComfyUI-OpenClaw.api.config")
# R60: Bounded model list cache with TTL + LRU eviction.
# Key: (provider, base_url) -> (timestamp, models[])
# - TTL: entries older than _MODEL_LIST_TTL_SEC are treated as stale on read.
# - Size cap: at most _MODEL_LIST_MAX_ENTRIES; oldest entry evicted on insert.
from collections import OrderedDict
_MODEL_LIST_CACHE: OrderedDict = OrderedDict()
_MODEL_LIST_TTL_SEC = 600 # 10 minutes
_MODEL_LIST_MAX_ENTRIES = 16
def _build_model_cache_key(provider: str, base_url: str, tenant_id: str) -> tuple:
# S49: keep single-tenant key shape stable while isolating multi-tenant entries.
if str(tenant_id).strip().lower() in ("", "default"):
return (provider, base_url)
return (tenant_id, provider, base_url)
def _cache_put(key: tuple, models: list) -> None:
"""Insert into bounded cache, evicting oldest if over cap."""
if key in _MODEL_LIST_CACHE:
_MODEL_LIST_CACHE.move_to_end(key)
_MODEL_LIST_CACHE[key] = (time.time(), models)
while len(_MODEL_LIST_CACHE) > _MODEL_LIST_MAX_ENTRIES:
_MODEL_LIST_CACHE.popitem(last=False)
def _cache_get(key: tuple):
"""Return (timestamp, models) if fresh, else None.
Expired entries are NOT removed — they remain available for fallback
on network failure (handler reads _MODEL_LIST_CACHE directly).
Eviction is handled only by the size cap in _cache_put.
"""
entry = _MODEL_LIST_CACHE.get(key)
if entry is None:
return None
ts, models = entry
if (time.time() - ts) >= _MODEL_LIST_TTL_SEC:
return None
# Touch for LRU
_MODEL_LIST_CACHE.move_to_end(key)
return entry
(
_MODEL_LIST_CACHE,
_MODEL_LIST_MAX_ENTRIES,
_MODEL_LIST_TTL_SEC,
_build_model_cache_key,
_cache_get,
_cache_put,
_extract_models_from_payload,
_format_llm_ssrf_error,
_get_llm_allowed_hosts,
_llm_insecure_override_enabled,
fetch_remote_model_list,
get_stale_cached_models,
resolve_model_list_target,
validate_model_list_target,
) = import_attrs_dual(
__package__,
"..services.llm_model_list",
"services.llm_model_list",
(
"_MODEL_LIST_CACHE",
"_MODEL_LIST_MAX_ENTRIES",
"_MODEL_LIST_TTL_SEC",
"build_model_cache_key",
"cache_get",
"cache_put",
"extract_models_from_payload",
"format_llm_ssrf_error",
"get_llm_allowed_hosts",
"llm_insecure_override_enabled",
"fetch_remote_model_list",
"get_stale_cached_models",
"resolve_model_list_target",
"validate_model_list_target",
),
)
# S14/R98 / R64: Import Endpoint Metadata
@@ -345,90 +337,6 @@ async def config_get_handler(request: web.Request) -> web.Response:
)
def _env_flag(primary: str, legacy: str, default: bool = False) -> bool:
import os
val = os.environ.get(primary)
if val is None:
val = os.environ.get(legacy)
if val is None:
return default
return str(val).strip().lower() in ("1", "true", "yes", "y", "on")
def _get_llm_allowed_hosts() -> set:
import os
allowed_hosts_str = os.environ.get("OPENCLAW_LLM_ALLOWED_HOSTS") or os.environ.get(
"MOLTBOT_LLM_ALLOWED_HOSTS", ""
)
env_hosts = {h.lower().strip() for h in allowed_hosts_str.split(",") if h.strip()}
# Default allowlist: built-in provider public hosts.
# This makes core providers work out-of-the-box while keeping custom base URLs
# constrained to explicit allowlists (or OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1).
try:
from ..services.providers.catalog import get_default_public_llm_hosts
except ImportError: # pragma: no cover
from services.providers.catalog import (
get_default_public_llm_hosts, # type: ignore
)
return set(get_default_public_llm_hosts()) | env_hosts
def _format_llm_ssrf_error(exc: Exception) -> str:
detail = str(exc)
# IMPORTANT: keep this guidance aligned with services.runtime_config so Remote
# Admin config writes and model refreshes explain the same fail-closed policy.
return (
f"SSRF policy blocked outbound URL: {detail}. "
"OPENCLAW_LLM_ALLOWED_HOSTS only allows additional exact public hosts; "
"private/reserved IP targets still require "
"OPENCLAW_ALLOW_INSECURE_BASE_URL=1. Wildcard '*' entries are not "
"supported."
)
def _llm_insecure_override_enabled() -> bool:
return _env_flag(
"OPENCLAW_ALLOW_INSECURE_BASE_URL",
"MOLTBOT_ALLOW_INSECURE_BASE_URL",
default=False,
)
def _extract_models_from_payload(payload: dict) -> list:
"""
Extract model IDs from common provider responses.
Expected OpenAI format: {"data":[{"id":"..."}]}
"""
if not isinstance(payload, dict):
return []
data = payload.get("data")
if isinstance(data, list):
out = []
for item in data:
if isinstance(item, str):
out.append(item)
elif isinstance(item, dict) and isinstance(item.get("id"), str):
out.append(item["id"])
return sorted({m for m in out if m})
models = payload.get("models")
if isinstance(models, list):
out = []
for item in models:
if isinstance(item, str):
out.append(item)
elif isinstance(item, dict) and isinstance(item.get("id"), str):
out.append(item["id"])
return sorted({m for m in out if m})
return []
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW, # Read-only external fetch, but admin-gated
@@ -504,56 +412,26 @@ async def llm_models_handler(request: web.Request) -> web.Response:
provider_override = (request.query.get("provider") or "").strip().lower()
effective, _sources = get_effective_config(tenant_id=tenant.tenant_id)
provider = provider_override or (effective.get("provider") or "openai")
# Resolve Base URL (Runtime config > Catalog Default)
# Allows users to override base_url for standard providers (e.g. self-hosted OpenAI compat)
runtime_base_url = (effective.get("base_url") or "").strip()
try:
from ..services.providers.catalog import ProviderType, get_provider_info
from ..services.providers.keys import (
get_api_key_for_provider,
requires_api_key,
target = resolve_model_list_target(
provider_override,
effective,
tenant.tenant_id,
)
except ImportError:
from services.providers.catalog import ProviderType, get_provider_info
from services.providers.keys import (
get_api_key_for_provider,
requires_api_key,
)
info = get_provider_info(provider)
if not info:
except ValueError as e:
return web.json_response(
{"ok": False, "error": f"Unknown provider: {provider}"}, status=400
)
if info.api_type != ProviderType.OPENAI_COMPAT:
return web.json_response(
{
"ok": False,
"error": "Model list is only supported for OpenAI-compatible providers.",
},
{"ok": False, "error": str(e)},
status=400,
)
# Priority: Runtime URL -> Info Default
base_url = runtime_base_url if runtime_base_url else info.base_url
if not base_url:
except TypeError as e:
return web.json_response(
{
"ok": False,
"error": f"No base URL configured for provider '{provider}'.",
},
{"ok": False, "error": str(e)},
status=400,
)
# R60: Cache key includes provider + base_url to avoid cross-provider staleness.
cache_key = _build_model_cache_key(provider, base_url, tenant.tenant_id)
# R60: Check bounded TTL+LRU cache
cached_entry = _cache_get(cache_key)
cached_entry = _cache_get(target.cache_key)
if cached_entry:
_ts, models = cached_entry
if isinstance(models, list):
@@ -561,47 +439,32 @@ async def llm_models_handler(request: web.Request) -> web.Response:
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": provider,
"provider": target.provider,
"models": models,
"cached": True,
}
)
api_key = get_api_key_for_provider(provider, tenant_id=tenant.tenant_id)
# CRITICAL:
# Local providers (e.g. ollama/lmstudio) intentionally work without API keys.
# Do not change this gate back to `if not api_key`, or local model-list loading
# will regress with false 400 errors.
if requires_api_key(provider) and not api_key:
if target.requires_api_key and not target.api_key:
return web.json_response(
{
"ok": False,
"error": f"No API key configured for provider '{provider}'.",
"error": f"No API key configured for provider '{target.provider}'.",
},
status=400,
)
# SSRF policy
try:
try:
from ..services.safe_io import (
STANDARD_OUTBOUND_POLICY,
validate_outbound_url,
)
except ImportError:
from services.safe_io import ( # type: ignore
STANDARD_OUTBOUND_POLICY,
validate_outbound_url,
)
controls = get_llm_egress_controls(provider, base_url)
validate_outbound_url(
base_url,
allow_hosts=controls.get("allow_hosts"),
allow_any_public_host=bool(controls.get("allow_any_public_host")),
allow_loopback_hosts=controls.get("allow_loopback_hosts"),
controls = get_llm_egress_controls(target.provider, target.base_url)
validate_model_list_target(
target,
controls,
allow_insecure_base_url=_llm_insecure_override_enabled(),
policy=STANDARD_OUTBOUND_POLICY,
)
except Exception as e:
return web.json_response(
@@ -612,50 +475,22 @@ async def llm_models_handler(request: web.Request) -> web.Response:
# Fetch /models
try:
try:
from ..services.safe_io import (
STANDARD_OUTBOUND_POLICY,
SSRFError,
safe_request_json,
)
from ..services.safe_io import SSRFError
except ImportError:
from services.safe_io import ( # type: ignore
STANDARD_OUTBOUND_POLICY,
SSRFError,
safe_request_json,
)
from services.safe_io import SSRFError # type: ignore
url = f"{base_url.rstrip('/')}/models"
request_headers = {
"User-Agent": f"ComfyUI-OpenClaw/{PACK_VERSION}",
"Accept": "application/json",
}
if api_key:
request_headers["Authorization"] = f"Bearer {api_key}"
# S65: Enforce outbound policy via safe_io
payload = safe_request_json(
method="GET",
url=url,
json_body=None,
headers=request_headers,
timeout_sec=10,
policy=STANDARD_OUTBOUND_POLICY,
allow_hosts=controls.get("allow_hosts"),
allow_any_public_host=bool(controls.get("allow_any_public_host")),
allow_loopback_hosts=controls.get("allow_loopback_hosts"),
models = fetch_remote_model_list(
target,
controls,
pack_version=PACK_VERSION,
allow_insecure_base_url=_llm_insecure_override_enabled(),
)
models = _extract_models_from_payload(payload)
# R60: Insert/update bounded cache
_cache_put(cache_key, models)
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": provider,
"provider": target.provider,
"models": models,
"cached": False,
}
@@ -671,7 +506,7 @@ async def llm_models_handler(request: web.Request) -> web.Response:
str_e = str(e)
if "HTTP" in str_e:
# Fallback: serve stale cache entry (if any) on fetch failure
stale = _MODEL_LIST_CACHE.get(cache_key)
stale = get_stale_cached_models(target.cache_key)
if stale:
_ts, models = stale
warning = f"Using cached list (refresh failed: {str_e})"
@@ -679,7 +514,7 @@ async def llm_models_handler(request: web.Request) -> web.Response:
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": provider,
"provider": target.provider,
"models": models,
"cached": True,
"warning": warning,
@@ -691,7 +526,7 @@ async def llm_models_handler(request: web.Request) -> web.Response:
raise e
except Exception as e:
stale = _MODEL_LIST_CACHE.get(cache_key)
stale = get_stale_cached_models(target.cache_key)
if stale:
# IMPORTANT:
# Test path intentionally injects network failures to verify cache fallback.
@@ -705,7 +540,7 @@ async def llm_models_handler(request: web.Request) -> web.Response:
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": provider,
"provider": target.provider,
"models": models,
"cached": True,
"warning": warning,
+3 -1
View File
@@ -70,7 +70,9 @@ def emit_legacy_header_warning(
logger: Optional[logging.Logger] = None,
) -> None:
_increment_legacy_api_hits()
active_logger = logger or logging.getLogger("ComfyUI-OpenClaw.services.legacy_compat")
active_logger = logger or logging.getLogger(
"ComfyUI-OpenClaw.services.legacy_compat"
)
active_logger.warning(
"DEPRECATION WARNING: Legacy header %s used. Please migrate to %s.",
alias.legacy,
+222
View File
@@ -0,0 +1,222 @@
"""
R150 model-list helper seam.
Centralizes cache, provider resolution, and outbound fetch helpers so
`api.config` can stay focused on HTTP/auth flow while preserving its legacy
test compatibility surface.
"""
from __future__ import annotations
import os
import time
from collections import OrderedDict
from dataclasses import dataclass
if __package__ and "." in __package__:
from .import_fallback import import_module_dual
else:
from services.import_fallback import import_module_dual # type: ignore
_MODEL_LIST_CACHE: OrderedDict = OrderedDict()
_MODEL_LIST_TTL_SEC = 600
_MODEL_LIST_MAX_ENTRIES = 16
@dataclass(frozen=True)
class ModelListTarget:
provider: str
base_url: str
tenant_id: str
cache_key: tuple
api_key: str | None
requires_api_key: bool
def _providers_catalog_module():
return import_module_dual(
__package__,
".providers.catalog",
"services.providers.catalog",
)
def _provider_keys_module():
return import_module_dual(
__package__,
".providers.keys",
"services.providers.keys",
)
def _safe_io_module():
return import_module_dual(
__package__,
".safe_io",
"services.safe_io",
)
def build_model_cache_key(provider: str, base_url: str, tenant_id: str) -> tuple:
if str(tenant_id).strip().lower() in ("", "default"):
return (provider, base_url)
return (tenant_id, provider, base_url)
def cache_put(key: tuple, models: list) -> None:
if key in _MODEL_LIST_CACHE:
_MODEL_LIST_CACHE.move_to_end(key)
_MODEL_LIST_CACHE[key] = (time.time(), models)
while len(_MODEL_LIST_CACHE) > _MODEL_LIST_MAX_ENTRIES:
_MODEL_LIST_CACHE.popitem(last=False)
def cache_get(key: tuple):
entry = _MODEL_LIST_CACHE.get(key)
if entry is None:
return None
ts, models = entry
if (time.time() - ts) >= _MODEL_LIST_TTL_SEC:
return None
_MODEL_LIST_CACHE.move_to_end(key)
return entry
def get_stale_cached_models(key: tuple):
return _MODEL_LIST_CACHE.get(key)
def get_llm_allowed_hosts() -> set[str]:
allowed_hosts_str = os.environ.get("OPENCLAW_LLM_ALLOWED_HOSTS") or os.environ.get(
"MOLTBOT_LLM_ALLOWED_HOSTS", ""
)
env_hosts = {h.lower().strip() for h in allowed_hosts_str.split(",") if h.strip()}
catalog = _providers_catalog_module()
return set(catalog.get_default_public_llm_hosts()) | env_hosts
def format_llm_ssrf_error(exc: Exception) -> str:
detail = str(exc)
return (
f"SSRF policy blocked outbound URL: {detail}. "
"OPENCLAW_LLM_ALLOWED_HOSTS only allows additional exact public hosts; "
"private/reserved IP targets still require "
"OPENCLAW_ALLOW_INSECURE_BASE_URL=1. Wildcard '*' entries are not "
"supported."
)
def llm_insecure_override_enabled() -> bool:
val = os.environ.get("OPENCLAW_ALLOW_INSECURE_BASE_URL")
if val is None:
val = os.environ.get("MOLTBOT_ALLOW_INSECURE_BASE_URL")
if val is None:
return False
return str(val).strip().lower() in ("1", "true", "yes", "y", "on")
def extract_models_from_payload(payload: dict) -> list[str]:
if not isinstance(payload, dict):
return []
data = payload.get("data")
if isinstance(data, list):
out = []
for item in data:
if isinstance(item, str):
out.append(item)
elif isinstance(item, dict) and isinstance(item.get("id"), str):
out.append(item["id"])
return sorted({model for model in out if model})
models = payload.get("models")
if isinstance(models, list):
out = []
for item in models:
if isinstance(item, str):
out.append(item)
elif isinstance(item, dict) and isinstance(item.get("id"), str):
out.append(item["id"])
return sorted({model for model in out if model})
return []
def resolve_model_list_target(
provider_override: str,
effective: dict,
tenant_id: str,
) -> ModelListTarget:
catalog = _providers_catalog_module()
keys = _provider_keys_module()
provider = provider_override or str(effective.get("provider") or "openai").lower()
runtime_base_url = str(effective.get("base_url") or "").strip()
info = catalog.get_provider_info(provider)
if not info:
raise ValueError(f"Unknown provider: {provider}")
if info.api_type != catalog.ProviderType.OPENAI_COMPAT:
raise TypeError("Model list is only supported for OpenAI-compatible providers.")
base_url = runtime_base_url if runtime_base_url else info.base_url
if not base_url:
raise ValueError(f"No base URL configured for provider '{provider}'.")
return ModelListTarget(
provider=provider,
base_url=base_url,
tenant_id=tenant_id,
cache_key=build_model_cache_key(provider, base_url, tenant_id),
api_key=keys.get_api_key_for_provider(provider, tenant_id=tenant_id),
requires_api_key=bool(keys.requires_api_key(provider)),
)
def validate_model_list_target(
target: ModelListTarget,
controls: dict,
*,
allow_insecure_base_url: bool,
) -> None:
safe_io = _safe_io_module()
safe_io.validate_outbound_url(
target.base_url,
allow_hosts=controls.get("allow_hosts"),
allow_any_public_host=bool(controls.get("allow_any_public_host")),
allow_loopback_hosts=controls.get("allow_loopback_hosts"),
allow_insecure_base_url=allow_insecure_base_url,
policy=safe_io.STANDARD_OUTBOUND_POLICY,
)
def fetch_remote_model_list(
target: ModelListTarget,
controls: dict,
*,
pack_version: str,
allow_insecure_base_url: bool,
) -> list[str]:
safe_io = _safe_io_module()
request_headers = {
"User-Agent": f"ComfyUI-OpenClaw/{pack_version}",
"Accept": "application/json",
}
if target.api_key:
request_headers["Authorization"] = f"Bearer {target.api_key}"
payload = safe_io.safe_request_json(
method="GET",
url=f"{target.base_url.rstrip('/')}/models",
json_body=None,
headers=request_headers,
timeout_sec=10,
policy=safe_io.STANDARD_OUTBOUND_POLICY,
allow_hosts=controls.get("allow_hosts"),
allow_any_public_host=bool(controls.get("allow_any_public_host")),
allow_loopback_hosts=controls.get("allow_loopback_hosts"),
allow_insecure_base_url=allow_insecure_base_url,
)
models = extract_models_from_payload(payload)
cache_put(target.cache_key, models)
return models
+1 -1
View File
@@ -3,8 +3,8 @@ from unittest.mock import MagicMock, patch
from services.legacy_compat import (
ADMIN_TOKEN_HEADERS,
OPENCLAW_API_PREFIX,
LEGACY_API_PREFIX,
OPENCLAW_API_PREFIX,
get_api_path_candidates,
get_header_alias_value,
)
+56
View File
@@ -0,0 +1,56 @@
import unittest
from unittest.mock import patch
from services.llm_model_list import (
ModelListTarget,
fetch_remote_model_list,
resolve_model_list_target,
)
class LlmModelListServiceTests(unittest.TestCase):
@patch("services.providers.keys.requires_api_key", return_value=False)
@patch("services.providers.keys.get_api_key_for_provider", return_value=None)
def test_resolve_target_uses_runtime_base_url(self, _mock_key, _mock_requires_key):
target = resolve_model_list_target(
provider_override="custom",
effective={"provider": "custom", "base_url": "https://custom.example/v1"},
tenant_id="tenant-a",
)
self.assertEqual(target.provider, "custom")
self.assertEqual(target.base_url, "https://custom.example/v1")
self.assertEqual(
target.cache_key,
("tenant-a", "custom", "https://custom.example/v1"),
)
@patch("services.safe_io.safe_request_json")
def test_fetch_remote_model_list_builds_auth_header(self, mock_safe_request):
mock_safe_request.return_value = {"data": [{"id": "gpt-4o-mini"}]}
target = ModelListTarget(
provider="openai",
base_url="https://api.openai.com/v1",
tenant_id="default",
cache_key=("openai", "https://api.openai.com/v1"),
api_key="sk-test",
requires_api_key=True,
)
models = fetch_remote_model_list(
target,
{"allow_hosts": {"api.openai.com"}},
pack_version="0.1.0",
allow_insecure_base_url=False,
)
self.assertEqual(models, ["gpt-4o-mini"])
self.assertEqual(
mock_safe_request.call_args.kwargs["headers"]["Authorization"],
"Bearer sk-test",
)
if __name__ == "__main__":
unittest.main()
+187
View File
@@ -0,0 +1,187 @@
import { tabManager } from "./openclaw_tabs.js";
import { openclawApi } from "./openclaw_api.js";
import { buildDoctorAdvisoryBanner } from "./openclaw_security_advisory.js";
/**
* F51: Unified Action Router.
* Centralizes navigation and command logic for key operator tasks.
*/
export class OpenClawActions {
constructor(ui, deps = {}) {
this.ui = ui;
this.api = deps.api || openclawApi;
this.tabs = deps.tabs || tabManager;
this.bannerBuilder = deps.bannerBuilder || buildDoctorAdvisoryBanner;
this.documentRef = deps.documentRef || document;
this.windowRef = deps.windowRef || window;
this.setTimeoutRef = deps.setTimeoutRef || window.setTimeout.bind(window);
if (Object.prototype.hasOwnProperty.call(deps, "capabilities")) {
this.capabilities = deps.capabilities;
this._initPromise = Promise.resolve(this.capabilities);
} else {
this.capabilities = null;
this._initPromise = this._fetchCapabilities();
}
}
async _fetchCapabilities() {
try {
const res = await this.api.getCapabilities();
if (res.ok) {
this.capabilities = res.data;
}
} catch (e) {
console.warn("OpenClawActions: Failed to fetch capabilities", e);
}
return this.capabilities;
}
dispatch(actionId, context = null) {
switch (actionId) {
case "doctor":
return this.openDoctor();
case "queue":
return this.openQueue();
case "settings":
return this.openSettings();
case "inspect":
return this.openExplorer();
default:
console.warn("Unknown action:", actionId, context);
return undefined;
}
}
_checkAction(actionName) {
if (!this.capabilities || !this.capabilities.actions) {
return { enabled: true, mutating: false };
}
const cap = this.capabilities.actions[actionName] || {
enabled: false,
mutating: false,
};
if (!cap.enabled && cap.blocked_reason) {
this._showBlockedToast(actionName, cap.blocked_reason);
}
return cap;
}
_showBlockedToast(actionName, reason) {
const toast = this.documentRef.createElement("div");
toast.className = "openclaw-blocked-toast";
toast.style.cssText = `
position: fixed; bottom: 20px; right: 20px; z-index: 99999;
background: #1e1e2e; border: 1px solid #f59e0b;
border-radius: 8px; padding: 12px 16px; max-width: 380px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
font-family: 'Inter', sans-serif; font-size: 13px;
color: #e0e0e0; animation: slideIn 0.3s ease;
`;
toast.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span style="font-size:16px;">!</span>
<strong style="color:#f59e0b;">Action Blocked - Split Mode</strong>
</div>
<div style="margin-bottom:4px;"><code>${actionName}</code> is not available in the current deployment mode.</div>
<div style="font-size:11px;color:#999;">${reason || "Use the external control plane for this operation."}</div>
`;
this.documentRef.body.appendChild(toast);
this.setTimeoutRef(() => toast.remove(), 6000);
}
async _runGuarded(actionName, fn) {
await this._initPromise;
const cap = this._checkAction(actionName);
if (!cap.enabled) {
this.ui.showBanner("warning", `Action '${actionName}' is disabled by policy.`);
return;
}
if (cap.mutating) {
this.ui.showConfirm({
title: "Confirm Action",
message: `This action (${actionName}) will modify system state. Proceed?`,
onConfirm: fn,
});
return;
}
return fn();
}
openSettings(section = "general") {
this.tabs.activateTab("settings");
return section;
}
openQueue(filter = "all") {
if (this.tabs.tabs["job-monitor"]) {
this.tabs.activateTab("job-monitor");
return filter;
}
this.tabs.activateTab("explorer");
return filter;
}
openDoctor() {
return this._runGuarded("doctor", async () => {
await this._openDoctorImpl();
});
}
async _openDoctorImpl() {
this.tabs.activateTab("settings");
try {
const res = await this.api.fetch(this.api._path("/security/doctor"));
if (res.ok && res.data) {
const report = res.data.report || res.data;
const advisoryBanner = this.bannerBuilder(report);
if (advisoryBanner) {
this.ui.showBanner(advisoryBanner);
return;
}
const issueCount = Array.isArray(report?.checks)
? report.checks.filter(
(check) => check?.severity === "warn" || check?.severity === "fail"
).length
: 0;
this.ui.showBanner(
issueCount > 0 ? "warning" : "success",
issueCount > 0
? `Doctor found ${issueCount} issues. See Settings for details.`
: "Doctor check passed."
);
return;
}
} catch (_err) {
// Capability fallback below.
}
this.ui.showBanner(
"info",
"Doctor diagnostics endpoint unavailable. Open Settings for manual checks."
);
}
openExplorer(nodeType = null) {
this.tabs.activateTab("explorer");
return nodeType;
}
openCompare(node = null) {
this.tabs.activateTab("parameter-lab");
if (node) {
console.log("OpenClaw: Compare requested for", node.title || node.type);
this.setTimeoutRef(() => {
this.windowRef.dispatchEvent(
new CustomEvent("openclaw:lab:compare", { detail: { node } })
);
this.windowRef.dispatchEvent(
new CustomEvent("moltbot:lab:compare", { detail: { node } })
);
}, 0);
}
}
}
+120
View File
@@ -0,0 +1,120 @@
import { openclawApi } from "./openclaw_api.js";
/**
* F48/F49: Queue Lifecycle Monitor.
* Consumes R71 events (SSE) with polling fallback to show deduplicated status banners.
* Handles disconnected state and recovery based on B-Strict/B-Loose contracts.
*/
export class QueueMonitor {
constructor(ui, deps = {}) {
this.ui = ui;
this.api = deps.api || openclawApi;
this.setIntervalRef = deps.setIntervalRef || window.setInterval.bind(window);
this.now = deps.now || (() => Date.now());
this.lastBannerTime = 0;
this.lastStatusId = null;
this.bannerTTL = 5000;
this.es = null;
this.isConnected = true;
}
start() {
this.connectSSE();
this.setIntervalRef(() => this.checkHealth(), 10000);
}
connectSSE() {
if (this.es) {
this.es.close();
}
this.es = this.api.subscribeEvents(
(data) => this.handleEvent(data),
(err) => this.handleConnectionError(err)
);
}
handleEvent(data) {
if (!this.isConnected) {
this.isConnected = true;
this.showBanner("success", "\u2705 OpenClaw Backend Connected", "connection_restored", 3000);
}
const type = data.event_type;
const pid = data.prompt_id ? data.prompt_id.slice(0, 8) : "???";
switch (type) {
case "queued":
this.showBanner("info", `\u23F3 Job ${pid} queued`, `job_${type}`, 2000);
break;
case "running":
this.showBanner("info", `\u25B6 Job ${pid} running...`, `job_${type}`, 5000);
break;
case "failed":
this.showBanner("error", `\u274C Job ${pid} failed`, `job_${type}`, 10000);
break;
case "completed":
break;
}
}
handleConnectionError(err) {
if (this.isConnected) {
this.isConnected = false;
this.showBanner("error", "\u26A0\uFE0F Backend Disconnected. Retrying...", "connection_lost");
}
return err;
}
async checkHealth() {
try {
const res = await this.api.getHealth();
if (res.ok && res.data) {
if (!this.isConnected) {
this.isConnected = true;
this.showBanner("success", "\u2705 Connection Restored", "connection_restored", 3000);
if (!this.es || this.es.readyState === 2) {
this.connectSSE();
}
}
const stats = res.data.stats || {};
const obs = stats.observability || {};
if (obs.total_dropped > 0) {
this.showBanner(
"warning",
`\u26A0\uFE0F High load: ${obs.total_dropped} events dropped.`,
"backpressure"
);
}
} else if (this.isConnected) {
this.isConnected = false;
this.showBanner("error", "\u26A0\uFE0F Backend Unreachable", "health_check_failed");
}
} catch (_err) {
if (this.isConnected) {
this.isConnected = false;
this.showBanner("error", "\u26A0\uFE0F Connection Error", "health_check_exception");
}
}
}
showBanner(type, message, statusId, ttl = this.bannerTTL) {
const now = this.now();
if (this.lastStatusId === statusId && (now - this.lastBannerTime < ttl)) {
return;
}
this.lastStatusId = statusId;
this.lastBannerTime = now;
this.ui.showBanner({
id: statusId || "monitor_" + now,
severity: type,
message,
source: "QueueMonitor",
ttl_ms: ttl,
dismissible: true,
});
}
}
+4 -331
View File
@@ -5,8 +5,9 @@
import { tabManager } from "./openclaw_tabs.js";
import { ErrorBoundary } from "./ErrorBoundary.js";
import { openclawApi } from "./openclaw_api.js";
import { buildDoctorAdvisoryBanner } from "./openclaw_security_advisory.js";
import { normalizeLegacyClassNames } from "./openclaw_utils.js";
import { OpenClawActions } from "./openclaw_actions.js";
import { QueueMonitor } from "./openclaw_queue_monitor.js";
export class OpenClawUI {
constructor() {
@@ -388,338 +389,10 @@ export class OpenClawUI {
}
}
/**
* F48/F49: Queue Lifecycle Monitor.
* Consumes R71 events (SSE) with polling fallback to show deduplicated status banners.
* Handles disconnected state and recovery based on B-Strict/B-Loose contracts.
*/
class QueueMonitor {
constructor(ui) {
this.ui = ui;
this.lastBannerTime = 0;
this.lastStatusId = null;
this.bannerTTL = 5000; // 5s debounce for transient statuses
this.es = null;
this.isConnected = true; // Assume connected initially
}
start() {
// 1. Start SSE subscription
this.connectSSE();
// 2. Poll health periodically (backup & backpressure check)
setInterval(() => this.checkHealth(), 10000);
}
connectSSE() {
if (this.es) {
this.es.close();
}
this.es = openclawApi.subscribeEvents(
(data) => this.handleEvent(data),
(err) => this.handleConnectionError(err)
);
}
handleEvent(data) {
// Recovered connection if we get an event
if (!this.isConnected) {
this.isConnected = true;
this.showBanner("success", "\u2705 OpenClaw Backend Connected", "connection_restored", 3000);
}
const type = data.event_type;
const pid = data.prompt_id ? data.prompt_id.slice(0, 8) : "???";
switch (type) {
case "queued":
this.showBanner("info", `\u23F3 Job ${pid} queued`, `job_${type}`, 2000);
break;
case "running":
this.showBanner("info", `\u25B6 Job ${pid} running...`, `job_${type}`, 5000);
break;
case "failed":
this.showBanner("error", `\u274C Job ${pid} failed`, `job_${type}`, 10000);
break;
case "completed":
// Optional: distinct success banner or silent
// this.showBanner("success", `\u2705 Job ${pid} completed`, `job_${type}`, 3000);
break;
}
}
handleConnectionError(err) {
// EventSource will retry automatically, but we flag UI state
// Only show disconnected if it persists (debounce?)
// For now, strict feedback:
if (this.isConnected) {
this.isConnected = false;
this.showBanner("error", "\u26A0\uFE0F Backend Disconnected. Retrying...", "connection_lost");
}
}
async checkHealth() {
try {
const res = await openclawApi.getHealth();
if (res.ok && res.data) {
if (!this.isConnected) {
this.isConnected = true;
this.showBanner("success", "\u2705 Connection Restored", "connection_restored", 3000);
// Reconnect SSE if it was closed or dead
if (!this.es || this.es.readyState === 2) {
this.connectSSE();
}
}
const stats = res.data.stats || {};
const obs = stats.observability || {};
// R87: Backpressure
if (obs.total_dropped > 0) {
this.showBanner(
"warning",
`\u26A0\uFE0F High load: ${obs.total_dropped} events dropped.`,
"backpressure"
);
}
} else {
if (this.isConnected) {
this.isConnected = false;
this.showBanner("error", "\u26A0\uFE0F Backend Unreachable", "health_check_failed");
}
}
} catch (e) {
if (this.isConnected) {
this.isConnected = false;
this.showBanner("error", "\u26A0\uFE0F Connection Error", "health_check_exception");
}
}
}
showBanner(type, message, statusId, ttl = this.bannerTTL) {
const now = Date.now();
// Dedupe at Monitor level (still useful to avoid spamming UI)
if (this.lastStatusId === statusId && (now - this.lastBannerTime < ttl)) {
return;
}
// Update state
this.lastStatusId = statusId;
this.lastBannerTime = now;
// F49: Delegate to UI with full schema
this.ui.showBanner({
id: statusId || "monitor_" + now,
severity: type,
message: message,
source: "QueueMonitor",
ttl_ms: ttl,
dismissible: true
});
}
}
/**
* F51: Unified Action Router.
* Centralizes navigation and command logic for key operator tasks.
*/
export class OpenClawActions {
constructor(ui) {
this.ui = ui;
this.capabilities = null;
this._initPromise = this._fetchCapabilities();
}
async _fetchCapabilities() {
try {
const res = await openclawApi.getCapabilities();
if (res.ok) {
this.capabilities = res.data;
}
} catch (e) {
console.warn("OpenClawActions: Failed to fetch capabilities", e);
}
}
/**
* F51: Universal dispatcher for string-based action IDs.
*/
dispatch(actionId, context = null) {
switch (actionId) {
case "doctor": this.openDoctor(); break;
case "queue": this.openQueue(); break;
case "settings": this.openSettings(); break;
case "inspect": this.openExplorer(); break;
default: console.warn("Unknown action:", actionId);
}
}
/**
* Check if an action is allowed/mutating.
* F55: Enhanced with split-mode blocking UX.
*/
_checkAction(actionName) {
if (!this.capabilities || !this.capabilities.actions) return { enabled: true, mutating: false };
const cap = this.capabilities.actions[actionName] || { enabled: false, mutating: false };
// F55: If blocked in split mode, show remediation toast
if (!cap.enabled && cap.blocked_reason) {
this._showBlockedToast(actionName, cap.blocked_reason);
}
return cap;
}
/**
* F55: Show a toast notification when an action is blocked by surface guard.
*/
_showBlockedToast(actionName, reason) {
const toast = document.createElement("div");
toast.className = "openclaw-blocked-toast";
toast.style.cssText = `
position: fixed; bottom: 20px; right: 20px; z-index: 99999;
background: #1e1e2e; border: 1px solid #f59e0b;
border-radius: 8px; padding: 12px 16px; max-width: 380px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
font-family: 'Inter', sans-serif; font-size: 13px;
color: #e0e0e0; animation: slideIn 0.3s ease;
`;
toast.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span style="font-size:16px;">!</span>
<strong style="color:#f59e0b;">Action Blocked - Split Mode</strong>
</div>
<div style="margin-bottom:4px;"><code>${actionName}</code> is not available in the current deployment mode.</div>
<div style="font-size:11px;color:#999;">${reason || "Use the external control plane for this operation."}</div>
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 6000);
}
async _runGuarded(actionName, fn) {
await this._initPromise;
const cap = this._checkAction(actionName);
if (!cap.enabled) {
this.ui.showBanner("warning", `Action '${actionName}' is disabled by policy.`);
return;
}
if (cap.mutating) {
this.ui.showConfirm({
title: "Confirm Action",
message: `This action (${actionName}) will modify system state. Proceed?`,
onConfirm: fn
});
} else {
fn();
}
}
/**
* Open Settings tab, optionally scrolling to a specific section.
*/
openSettings(section = "general") {
tabManager.activateTab("settings");
// Future: signal settings tab to scroll to section
}
/**
* Open Queue/Jobs view.
* Currently mapped to "Queue" or "Jobs" tab if it exists, or just sidebar.
* For MVP, we don't have a dedicated Jobs tab yet (it's part of Explorer or separate).
* We'll map to Explorer for now as it has "Jobs" sub-view concept in plan.
*/
openQueue(filter = "all") {
if (tabManager.tabs["job-monitor"]) {
tabManager.activateTab("job-monitor");
return;
}
tabManager.activateTab("explorer");
}
/**
* Run Doctor diagnostics.
* Opens Doctor view (in Explorer or Settings).
*/
async openDoctor() {
this._runGuarded("doctor", async () => {
await this._openDoctorImpl();
});
}
async _openDoctorImpl() {
tabManager.activateTab("settings");
try {
const res = await openclawApi.fetch(openclawApi._path("/security/doctor"));
if (res.ok && res.data) {
const report = res.data.report || res.data;
const advisoryBanner = buildDoctorAdvisoryBanner(report);
if (advisoryBanner) {
this.ui.showBanner(advisoryBanner);
return;
}
const issueCount = Array.isArray(report?.checks)
? report.checks.filter(
(check) => check?.severity === "warn" || check?.severity === "fail"
).length
: 0;
this.ui.showBanner(
issueCount > 0 ? "warning" : "success",
issueCount > 0
? `Doctor found ${issueCount} issues. See Settings for details.`
: "Doctor check passed."
);
return;
}
} catch (_err) {
// Capability fallback below.
}
this.ui.showBanner(
"info",
"Doctor diagnostics endpoint unavailable. Open Settings for manual checks."
);
}
/**
* Open Explorer, optionally filtering by node type.
*/
openExplorer(nodeType = null) {
tabManager.activateTab("explorer");
}
/**
* Open Parameter Lab for comparison.
* Sets the lab to Compare mode for the given node.
*/
openCompare(node = null) {
tabManager.activateTab("parameter-lab");
// F50: Signal Lab to init comparison for this node
// We'll rely on global accessible tab instance or event bus
// For now, let's assume tabManager can give us the instance if we need to call methods directly
// or we just open the tab and let the user set it up (MVP)
if (node) {
console.log("OpenClaw: Compare requested for", node.title || node.type);
// Dispatch after tab activation tick so listeners are ready.
setTimeout(() => {
window.dispatchEvent(
new CustomEvent("openclaw:lab:compare", { detail: { node } })
);
// Legacy event name for compatibility with older listeners.
window.dispatchEvent(
new CustomEvent("moltbot:lab:compare", { detail: { node } })
);
}, 0);
}
}
}
export const openclawUI = new OpenClawUI();
export const openclawActions = new OpenClawActions(openclawUI);
export { OpenClawActions } from "./openclaw_actions.js";
export { QueueMonitor } from "./openclaw_queue_monitor.js";
const monitor = new QueueMonitor(openclawUI);
monitor.start();
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../openclaw_api.js", () => ({
openclawApi: {
getCapabilities: vi.fn(),
fetch: vi.fn(),
_path: vi.fn((path) => path),
},
}));
vi.mock("../../openclaw_tabs.js", () => ({
tabManager: {
tabs: {},
activateTab: vi.fn(),
},
}));
const { OpenClawActions } = await import("../../openclaw_actions.js");
describe("OpenClawActions", () => {
beforeEach(() => {
document.body.innerHTML = "";
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("shows a blocked toast and warning banner when an action is disabled", async () => {
const ui = {
showBanner: vi.fn(),
showConfirm: vi.fn(),
};
const tabs = {
tabs: { "job-monitor": true },
activateTab: vi.fn(),
};
const actions = new OpenClawActions(ui, {
tabs,
capabilities: {
actions: {
doctor: {
enabled: false,
mutating: false,
blocked_reason: "Use control plane.",
},
},
},
});
await actions.openDoctor();
expect(ui.showBanner).toHaveBeenCalledWith(
"warning",
"Action 'doctor' is disabled by policy."
);
expect(document.body.querySelector(".openclaw-blocked-toast")).not.toBeNull();
});
it("dispatches compare events for both modern and legacy listeners", () => {
const ui = {
showBanner: vi.fn(),
showConfirm: vi.fn(),
};
const tabs = {
tabs: {},
activateTab: vi.fn(),
};
const modernListener = vi.fn();
const legacyListener = vi.fn();
window.addEventListener("openclaw:lab:compare", modernListener);
window.addEventListener("moltbot:lab:compare", legacyListener);
const actions = new OpenClawActions(ui, {
tabs,
capabilities: { actions: {} },
});
actions.openCompare({ id: 7, title: "Sampler" });
vi.runAllTimers();
expect(tabs.activateTab).toHaveBeenCalledWith("parameter-lab");
expect(modernListener).toHaveBeenCalledTimes(1);
expect(legacyListener).toHaveBeenCalledTimes(1);
window.removeEventListener("openclaw:lab:compare", modernListener);
window.removeEventListener("moltbot:lab:compare", legacyListener);
});
});
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../openclaw_api.js", () => ({
openclawApi: {
getHealth: vi.fn(),
subscribeEvents: vi.fn(),
},
}));
const { QueueMonitor } = await import("../../openclaw_queue_monitor.js");
describe("QueueMonitor", () => {
it("deduplicates repeated status banners within the ttl window", () => {
const ui = { showBanner: vi.fn() };
let nowValue = 1000;
const monitor = new QueueMonitor(ui, {
api: {},
now: () => nowValue,
setIntervalRef: vi.fn(),
});
monitor.showBanner("info", "Queued", "job_queued", 5000);
nowValue = 2000;
monitor.showBanner("info", "Queued", "job_queued", 5000);
nowValue = 7000;
monitor.showBanner("info", "Queued", "job_queued", 5000);
expect(ui.showBanner).toHaveBeenCalledTimes(2);
expect(ui.showBanner.mock.calls[0][0].severity).toBe("info");
});
it("reconnects the event stream when health checks recover from a disconnect", async () => {
const ui = { showBanner: vi.fn() };
const closedStream = { readyState: 2, close: vi.fn() };
const subscribeEvents = vi.fn(() => ({ readyState: 1, close: vi.fn() }));
const monitor = new QueueMonitor(ui, {
api: {
getHealth: vi.fn().mockResolvedValue({
ok: true,
data: { stats: { observability: { total_dropped: 0 } } },
}),
subscribeEvents,
},
setIntervalRef: vi.fn(),
});
monitor.isConnected = false;
monitor.es = closedStream;
await monitor.checkHealth();
expect(subscribeEvents).toHaveBeenCalledTimes(1);
expect(ui.showBanner).toHaveBeenCalledWith(
expect.objectContaining({
id: "connection_restored",
severity: "success",
})
);
});
});