From bf84b90f08b52310cde881b20c130bd4c2d538b5 Mon Sep 17 00:00:00 2001 From: rookiestar28 Date: Thu, 5 Mar 2026 02:49:33 +0800 Subject: [PATCH] F62: externalize planner profiles and prompt registry --- README.md | 16 ++ api/assist.py | 39 ++- api/routes.py | 6 + data/planner/profiles.json | 36 +++ data/planner/system_prompt.txt | 24 ++ nodes/prompt_planner.py | 14 +- services/planner.py | 49 +--- services/planner_registry.py | 320 ++++++++++++++++++++++ tests/e2e/specs/planner_profiles.spec.js | 44 +++ tests/test_api_assist.py | 49 ++++ tests/test_planner_registry.py | 154 +++++++++++ tests/test_prompt_planner_node.py | 36 +++ tests/test_s70_ssrf_pinning_regression.py | 8 +- web/docs/OpenClawPromptPlanner.md | 5 +- web/openclaw_api.js | 9 + web/tabs/planner_tab.js | 34 ++- web/tests/unit/planner_tab.test.js | 73 +++++ 17 files changed, 862 insertions(+), 54 deletions(-) create mode 100644 data/planner/profiles.json create mode 100644 data/planner/system_prompt.txt create mode 100644 services/planner_registry.py create mode 100644 tests/e2e/specs/planner_profiles.spec.js create mode 100644 tests/test_planner_registry.py create mode 100644 tests/test_prompt_planner_node.py create mode 100644 web/tests/unit/planner_tab.test.js diff --git a/README.md b/README.md index 5497904..ea81ecf 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,17 @@ Deployment profiles and hardening checklists:
+Planner registry externalization with runtime-safe profile alignment + +- Moved planner profiles and the planner system prompt into validated file-backed defaults under `data/planner/`, with state-dir override precedence for operator-managed customization without source edits. +- Added a planner profile list API so the Assist planner route, Prompt Planner node, and Planner tab resolve profiles from one synchronized source-of-truth. +- Kept runtime behavior fail-closed with schema validation, prompt placeholder validation, embedded fallback defaults, and lazy reload on planner file changes. +- Completed full verification gate pass on `dev` (detect-secrets, pre-commit, backend unit suites, adversarial/retry/real-backend lanes, and frontend Playwright E2E). + +
+ +
+ Frontend quality baseline for Library and Approvals surfaces - Canonicalized active frontend styling ownership around `openclaw-*`, including shell/tab-manager cleanup and a deterministic split of `web/openclaw.css` into core and legacy-alias modules. @@ -774,6 +785,7 @@ Access control: - `POST /openclaw/llm/chat` -connector chat completion path (admin boundary) - `GET /openclaw/llm/models` -fetch model list for selected provider/base URL - `POST /openclaw/assist/planner` -planner structured prompt generation (admin boundary) +- `GET /openclaw/assist/planner/profiles` -active planner profile registry metadata for node/UI alignment - `POST /openclaw/assist/refiner` -prompt refinement with optional image context (admin boundary) - `POST /openclaw/assist/planner/stream` -optional SSE-style planner streaming path (`text/event-stream`, admin boundary) - `POST /openclaw/assist/refiner/stream` -optional SSE-style refiner streaming path (`text/event-stream`, admin boundary) @@ -782,6 +794,10 @@ Notes: - Queue submission uses `OPENCLAW_COMFYUI_URL` (default `http://127.0.0.1:8188`). - Planner/Refiner UI uses capability-gated assist streaming when available and falls back to the non-stream endpoints automatically. +- Planner profiles/system prompt are file-backed: + - package defaults: `data/planner/profiles.json`, `data/planner/system_prompt.txt` + - operator overrides: `/planner/profiles.json`, `/planner/system_prompt.txt` + - invalid overrides fail closed to validated lower-precedence defaults - `PUT /openclaw/config` now returns apply metadata so callers can reason about what actually took effect: - `apply.ok`, `apply.requires_restart`, `apply.applied_keys` - `apply.effective_provider`, `apply.effective_model` diff --git a/api/assist.py b/api/assist.py index 9f058b4..322c9be 100644 --- a/api/assist.py +++ b/api/assist.py @@ -11,6 +11,7 @@ try: from ..services.async_utils import run_in_thread from ..services.automation_composer import AutomationComposerService from ..services.planner import PlannerService + from ..services.planner_registry import get_planner_registry from ..services.rate_limit import check_rate_limit from ..services.refiner import RefinerService except ImportError: @@ -19,6 +20,7 @@ except ImportError: from services.async_utils import run_in_thread from services.automation_composer import AutomationComposerService from services.planner import PlannerService + from services.planner_registry import get_planner_registry from services.rate_limit import check_rate_limit from services.refiner import RefinerService @@ -49,6 +51,22 @@ MAX_STREAM_PREVIEW_CHARS = 16_000 STREAM_KEEPALIVE_SEC = 1.0 +def _planner_profiles_payload() -> Dict[str, Any]: + registry = get_planner_registry() + return { + "profiles": [ + { + "id": profile.id, + "label": profile.label, + "description": profile.description, + "version": profile.version, + } + for profile in registry.list_profiles() + ], + "default_profile": registry.get_default_profile_id(), + } + + class AssistHandlers: def __init__(self): self.planner = PlannerService() @@ -81,7 +99,8 @@ class AssistHandlers: def _validate_planner_payload( self, data: dict ) -> tuple[Optional[dict], Optional[web.Response]]: - profile = data.get("profile", "SDXL-v1") + registry = get_planner_registry() + profile = data.get("profile", registry.get_default_profile_id()) requirements = data.get("requirements", "") style = data.get("style_directives", "") seed = data.get("seed", 0) @@ -90,6 +109,10 @@ class AssistHandlers: return None, web.json_response( {"error": "profile must be string"}, status=400 ) + if not registry.get_profile(profile): + return None, web.json_response( + {"error": f"Unknown profile: {profile}"}, status=400 + ) if not isinstance(requirements, str): return None, web.json_response( {"error": "requirements must be string"}, status=400 @@ -296,6 +319,20 @@ class AssistHandlers: await runner_task return response + @endpoint_metadata( + auth=AuthTier.ADMIN, + risk=RiskTier.LOW, + summary="List planner profiles", + description="Returns Prompt Planner profiles from the active registry.", + audit="assist.planner_profiles", + plane=RoutePlane.ADMIN, + ) + async def planner_profiles_handler(self, request): + auth_resp = await self._require_admin_and_rate_limit(request) + if auth_resp: + return auth_resp + return web.json_response(_planner_profiles_payload()) + @endpoint_metadata( auth=AuthTier.ADMIN, risk=RiskTier.MEDIUM, diff --git a/api/routes.py b/api/routes.py index f1a15a6..cc8667c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -850,6 +850,12 @@ def register_routes(server) -> None: # R84 Boot Boundary: CORE (Planner/Refiner part of core/assist) if assist: for prefix in prefixes: + register_dual_route( + server, + "GET", + f"{prefix}/assist/planner/profiles", + assist.planner_profiles_handler, + ) register_dual_route( server, "POST", f"{prefix}/assist/planner", assist.planner_handler ) diff --git a/data/planner/profiles.json b/data/planner/profiles.json new file mode 100644 index 0000000..071c1fa --- /dev/null +++ b/data/planner/profiles.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "default_profile": "SDXL-v1", + "profiles": [ + { + "id": "SDXL-v1", + "version": "1.0", + "label": "SDXL 1.0 Base", + "description": "Standard SDXL profile", + "prompt_guidance": "Width/height should target SDXL-friendly resolutions such as 1024x1024. Keep CFG around 7.0 and steps around 20-30 unless requirements strongly justify a deviation.", + "defaults": { + "width": 1024, + "height": 1024, + "steps": 24, + "cfg": 7.0, + "sampler_name": "euler", + "scheduler": "normal" + } + }, + { + "id": "Flux-Dev", + "version": "1.0", + "label": "Flux Dev", + "description": "Flux Dev profile (high steps, lower cfg)", + "prompt_guidance": "Flux variants generally prefer lower CFG than SDXL. Keep CFG around 1.0-4.0 and use moderately higher steps only when needed.", + "defaults": { + "width": 1024, + "height": 1024, + "steps": 28, + "cfg": 3.5, + "sampler_name": "euler", + "scheduler": "normal" + } + } + ] +} diff --git a/data/planner/system_prompt.txt b/data/planner/system_prompt.txt new file mode 100644 index 0000000..b639384 --- /dev/null +++ b/data/planner/system_prompt.txt @@ -0,0 +1,24 @@ +You are an expert stable diffusion prompt engineer. +Your goal is to generate a detailed JSON plan for an image generation job based on the user's requirements. + +Output strict JSON only. No markdown fences. +Expected JSON structure: +{ + "positive_prompt": "string", + "negative_prompt": "string", + "params": { + "width": int, + "height": int, + "steps": int, + "cfg": float, + "sampler_name": "euler" | "dpmpp_2m" | "...", + "scheduler": "normal" | "karras" | "..." + } +} + +Constraint Guidelines for {{profile_id}} ({{profile_label}}): +- {{profile_description}} +- {{prompt_guidance}} +- Preferred defaults JSON: {{defaults_json}} + +Never return commentary outside the JSON object. diff --git a/nodes/prompt_planner.py b/nodes/prompt_planner.py index b893b12..f0692fe 100644 --- a/nodes/prompt_planner.py +++ b/nodes/prompt_planner.py @@ -3,12 +3,14 @@ import logging from typing import Tuple try: - from ..services.planner import PROFILES, PlannerService + from ..services.planner import PlannerService + from ..services.planner_registry import get_planner_registry except ImportError as e: # Only fall back when ComfyUI loads this module without a proper package context. msg = str(e) if ("attempted relative import" in msg) or ("no known parent package" in msg): - from services.planner import PROFILES, PlannerService + from services.planner import PlannerService + from services.planner_registry import get_planner_registry else: raise @@ -27,11 +29,13 @@ class OpenClawPromptPlanner: @classmethod def INPUT_TYPES(cls): - # Use keys from shared PROFILES - profile_keys = list(PROFILES.keys()) + profile_keys = [ + profile.id for profile in get_planner_registry().list_profiles() + ] + default_profile = get_planner_registry().get_default_profile_id() return { "required": { - "profile": (profile_keys, {"default": "SDXL-v1"}), + "profile": (profile_keys, {"default": default_profile}), "requirements": ( "STRING", { diff --git a/services/planner.py b/services/planner.py index 01e2ea2..3a1c589 100644 --- a/services/planner.py +++ b/services/planner.py @@ -4,11 +4,12 @@ from typing import Any, Callable, Dict, Optional, Tuple from .llm_client import LLMClient from .llm_output import extract_json_object, sanitize_string +from .planner_registry import get_planner_registry try: - from ..models.schemas import GenerationParams, Profile + from ..models.schemas import GenerationParams except ImportError: - from models.schemas import GenerationParams, Profile + from models.schemas import GenerationParams from .metrics import metrics @@ -30,22 +31,6 @@ logger = logging.getLogger("ComfyUI-OpenClaw.services.planner") ALLOWED_RESPONSE_KEYS = {"positive_prompt", "negative_prompt", "params"} ALLOWED_PARAM_KEYS = {"width", "height", "steps", "cfg", "sampler_name", "scheduler"} -# Default Profiles -PROFILES = { - "SDXL-v1": Profile( - id="SDXL-v1", - version="1.0", - label="SDXL 1.0 Base", - description="Standard SDXL profile", - ), - "Flux-Dev": Profile( - id="Flux-Dev", - version="1.0", - label="Flux Dev", - description="Flux Dev profile (high steps, lower cfg)", - ), -} - class PlannerService: """Core logic for Prompt Planner (F8).""" @@ -77,9 +62,10 @@ class PlannerService: (positive_prompt, negative_prompt, params_dict) """ metrics.increment("planner_calls") + registry = get_planner_registry() # 1. Select Profile - selected_profile = PROFILES.get(profile_id) + selected_profile = registry.get_profile(profile_id) if not selected_profile: # Fallback or error? Node raises ValueError. # We'll default to SDXL if unknown, or raise. @@ -87,30 +73,7 @@ class PlannerService: raise ValueError(f"Unknown profile: {profile_id}") # 2. Construct System Prompt - system_prompt = f""" -You are an expert stable diffusion prompt engineer. -Your goal is to generate a detailed JSON plan for an image generation job based on the user's requirements. - -Output strict JSON only. No markdown fences. -Expected JSON structure: -{{ - "positive_prompt": "string", - "negative_prompt": "string", - "params": {{ - "width": int, - "height": int, - "steps": int, - "cfg": float, - "sampler_name": "euler" | "dpmpp_2m" | ..., - "scheduler": "normal" | "karras" | ... - }} -}} - -Constraint Guidelines for {selected_profile.id}: -- Width/Height should be optimized for this model (e.g. 1024x1024 for SDXL). -- Steps: Default around 20-30. -- CFG: 7.0 for SDXL, 1.0-4.0 for Flux. -""" + system_prompt = registry.render_system_prompt(selected_profile.id) # 3. Construct User Message user_message = f""" diff --git a/services/planner_registry.py b/services/planner_registry.py new file mode 100644 index 0000000..4ddc727 --- /dev/null +++ b/services/planner_registry.py @@ -0,0 +1,320 @@ +import json +import logging +import os +import re +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from .safe_io import safe_read_json, safe_read_text +from .state_dir import get_state_dir + +try: + from ..models.schemas import GenerationParams, Profile +except ImportError: + from models.schemas import GenerationParams, Profile + +logger = logging.getLogger("ComfyUI-OpenClaw.services.planner_registry") + +MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) +PACK_ROOT = os.path.dirname(MODULE_DIR) +PACKAGE_PLANNER_ROOT = os.path.join(PACK_ROOT, "data", "planner") +STATE_PLANNER_SUBDIR = "planner" +PROFILES_FILE = "profiles.json" +PROMPT_FILE = "system_prompt.txt" +MAX_PROFILES_BYTES = 256 * 1024 +MAX_PROMPT_BYTES = 64 * 1024 +ALLOWED_PROMPT_PLACEHOLDERS = { + "profile_id", + "profile_label", + "profile_description", + "prompt_guidance", + "defaults_json", +} +PLACEHOLDER_RE = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}") + +_EMBEDDED_FALLBACK_RAW = { + "version": 1, + "default_profile": "SDXL-v1", + "profiles": [ + { + "id": "SDXL-v1", + "version": "1.0", + "label": "SDXL 1.0 Base", + "description": "Standard SDXL profile", + "prompt_guidance": ( + "Width/height should target SDXL-friendly resolutions such as 1024x1024. " + "Keep CFG around 7.0 and steps around 20-30 unless requirements strongly " + "justify a deviation." + ), + "defaults": { + "width": 1024, + "height": 1024, + "steps": 24, + "cfg": 7.0, + "sampler_name": "euler", + "scheduler": "normal", + }, + }, + { + "id": "Flux-Dev", + "version": "1.0", + "label": "Flux Dev", + "description": "Flux Dev profile (high steps, lower cfg)", + "prompt_guidance": ( + "Flux variants generally prefer lower CFG than SDXL. Keep CFG around 1.0-4.0 " + "and use moderately higher steps only when needed." + ), + "defaults": { + "width": 1024, + "height": 1024, + "steps": 28, + "cfg": 3.5, + "sampler_name": "euler", + "scheduler": "normal", + }, + }, + ], +} + +_EMBEDDED_FALLBACK_PROMPT = """You are an expert stable diffusion prompt engineer. +Your goal is to generate a detailed JSON plan for an image generation job based on the user's requirements. + +Output strict JSON only. No markdown fences. +Expected JSON structure: +{ + "positive_prompt": "string", + "negative_prompt": "string", + "params": { + "width": int, + "height": int, + "steps": int, + "cfg": float, + "sampler_name": "euler" | "dpmpp_2m" | "...", + "scheduler": "normal" | "karras" | "..." + } +} + +Constraint Guidelines for {{profile_id}} ({{profile_label}}): +- {{profile_description}} +- {{prompt_guidance}} +- Preferred defaults JSON: {{defaults_json}} + +Never return commentary outside the JSON object. +""" + + +@dataclass +class PlannerRegistryState: + profiles: Dict[str, Profile] + default_profile: str + prompt_template: str + profile_source: str + prompt_source: str + last_profile_error: Optional[str] = None + last_prompt_error: Optional[str] = None + + +def _profile_from_entry(entry: Dict[str, Any]) -> Profile: + required = ("id", "version", "label") + missing = [key for key in required if not isinstance(entry.get(key), str) or not entry.get(key).strip()] + if missing: + raise ValueError(f"planner profile missing required string fields: {missing}") + defaults = entry.get("defaults", {}) + if defaults is None: + defaults = {} + if not isinstance(defaults, dict): + raise ValueError(f"planner profile '{entry.get('id')}' defaults must be an object") + validated_defaults = GenerationParams.from_dict(defaults).dict() + prompt_guidance = entry.get("prompt_guidance", "") + if prompt_guidance is None: + prompt_guidance = "" + if not isinstance(prompt_guidance, str): + raise ValueError( + f"planner profile '{entry.get('id')}' prompt_guidance must be a string" + ) + description = entry.get("description") + if description is not None and not isinstance(description, str): + raise ValueError( + f"planner profile '{entry.get('id')}' description must be a string or null" + ) + return Profile( + id=entry["id"].strip(), + version=entry["version"].strip(), + label=entry["label"].strip(), + description=description.strip() if isinstance(description, str) else None, + model_config_data={ + "prompt_guidance": prompt_guidance.strip(), + "defaults": validated_defaults, + }, + ) + + +def _parse_profiles_payload(payload: Dict[str, Any]) -> tuple[Dict[str, Profile], str]: + if not isinstance(payload, dict): + raise ValueError("planner profiles payload must be an object") + if payload.get("version") != 1: + raise ValueError(f"unsupported planner profiles version: {payload.get('version')}") + entries = payload.get("profiles") + if not isinstance(entries, list) or not entries: + raise ValueError("planner profiles payload must contain a non-empty profiles list") + profiles: Dict[str, Profile] = {} + for entry in entries: + if not isinstance(entry, dict): + raise ValueError("planner profile entries must be objects") + profile = _profile_from_entry(entry) + if profile.id in profiles: + raise ValueError(f"duplicate planner profile id: {profile.id}") + profiles[profile.id] = profile + default_profile = payload.get("default_profile") + if not isinstance(default_profile, str) or default_profile not in profiles: + raise ValueError("default_profile must reference an existing planner profile id") + return profiles, default_profile + + +def _validate_prompt_template(template: str) -> str: + placeholders = set(PLACEHOLDER_RE.findall(template)) + unknown = sorted(placeholders - ALLOWED_PROMPT_PLACEHOLDERS) + if unknown: + raise ValueError(f"unsupported planner prompt placeholders: {unknown}") + return template + + +class PlannerRegistry: + def __init__( + self, + *, + package_root: str = PACKAGE_PLANNER_ROOT, + state_root: Optional[str] = None, + ) -> None: + self.package_root = package_root + self.state_root = state_root or os.path.join(get_state_dir(), STATE_PLANNER_SUBDIR) + self._state: Optional[PlannerRegistryState] = None + self._watched_mtimes: Dict[str, Optional[float]] = {} + + def _file_mtime(self, path: str) -> Optional[float]: + try: + return os.path.getmtime(path) + except OSError: + return None + + def _current_watch_map(self) -> Dict[str, Optional[float]]: + return { + "package_profiles": self._file_mtime(os.path.join(self.package_root, PROFILES_FILE)), + "state_profiles": self._file_mtime(os.path.join(self.state_root, PROFILES_FILE)), + "package_prompt": self._file_mtime(os.path.join(self.package_root, PROMPT_FILE)), + "state_prompt": self._file_mtime(os.path.join(self.state_root, PROMPT_FILE)), + } + + def _load_profile_source(self) -> tuple[Dict[str, Profile], str, Optional[str]]: + candidates = [ + (self.state_root, PROFILES_FILE, "state"), + (self.package_root, PROFILES_FILE, "package"), + ] + last_error = None + for root, rel_path, source_name in candidates: + try: + payload = safe_read_json(root, rel_path, max_bytes=MAX_PROFILES_BYTES) + profiles, default_profile = _parse_profiles_payload(payload) + return profiles, default_profile, source_name, last_error + except FileNotFoundError: + continue + except Exception as exc: + last_error = f"{source_name}:{type(exc).__name__}:{exc}" + logger.warning("Planner profile source %s rejected: %s", source_name, exc) + profiles, default_profile = _parse_profiles_payload(_EMBEDDED_FALLBACK_RAW) + return profiles, default_profile, "embedded-fallback", last_error or "no_file" + + def _load_prompt_source(self) -> tuple[str, str, Optional[str]]: + candidates = [ + (self.state_root, PROMPT_FILE, "state"), + (self.package_root, PROMPT_FILE, "package"), + ] + last_error = None + for root, rel_path, source_name in candidates: + try: + template = safe_read_text(root, rel_path, max_bytes=MAX_PROMPT_BYTES) + return _validate_prompt_template(template), source_name, last_error + except FileNotFoundError: + continue + except Exception as exc: + last_error = f"{source_name}:{type(exc).__name__}:{exc}" + logger.warning("Planner prompt source %s rejected: %s", source_name, exc) + return ( + _validate_prompt_template(_EMBEDDED_FALLBACK_PROMPT), + "embedded-fallback", + last_error, + ) + + def _reload(self) -> None: + profiles, default_profile, profile_source, profile_error = self._load_profile_source() + prompt_template, prompt_source, prompt_error = self._load_prompt_source() + self._state = PlannerRegistryState( + profiles=profiles, + default_profile=default_profile, + prompt_template=prompt_template, + profile_source=profile_source, + prompt_source=prompt_source, + last_profile_error=profile_error, + last_prompt_error=prompt_error, + ) + self._watched_mtimes = self._current_watch_map() + + def _ensure_loaded(self) -> PlannerRegistryState: + current = self._current_watch_map() + if self._state is None or current != self._watched_mtimes: + self._reload() + assert self._state is not None + return self._state + + def list_profiles(self) -> list[Profile]: + state = self._ensure_loaded() + return [state.profiles[key] for key in sorted(state.profiles.keys())] + + def get_profile(self, profile_id: str) -> Optional[Profile]: + state = self._ensure_loaded() + return state.profiles.get(profile_id) + + def get_default_profile_id(self) -> str: + return self._ensure_loaded().default_profile + + def render_system_prompt(self, profile_id: str) -> str: + profile = self.get_profile(profile_id) + if profile is None: + raise ValueError(f"Unknown profile: {profile_id}") + state = self._ensure_loaded() + metadata = profile.model_config_data or {} + defaults = GenerationParams.from_dict(metadata.get("defaults", {})).dict() + replacements = { + "profile_id": profile.id, + "profile_label": profile.label, + "profile_description": profile.description or "No description provided.", + "prompt_guidance": metadata.get("prompt_guidance") or "Use stable defaults for this profile.", + "defaults_json": json.dumps(defaults, ensure_ascii=False, sort_keys=True), + } + rendered = state.prompt_template + for key, value in replacements.items(): + rendered = re.sub(r"{{\s*" + re.escape(key) + r"\s*}}", str(value), rendered) + return rendered + + def get_debug_info(self) -> Dict[str, Any]: + state = self._ensure_loaded() + return { + "package_root": self.package_root, + "state_root": self.state_root, + "profile_source": state.profile_source, + "prompt_source": state.prompt_source, + "default_profile": state.default_profile, + "profile_ids": [profile.id for profile in self.list_profiles()], + "last_profile_error": state.last_profile_error, + "last_prompt_error": state.last_prompt_error, + } + + +_REGISTRY: Optional[PlannerRegistry] = None + + +def get_planner_registry() -> PlannerRegistry: + global _REGISTRY + if _REGISTRY is None: + _REGISTRY = PlannerRegistry() + return _REGISTRY diff --git a/tests/e2e/specs/planner_profiles.spec.js b/tests/e2e/specs/planner_profiles.spec.js new file mode 100644 index 0000000..8625a17 --- /dev/null +++ b/tests/e2e/specs/planner_profiles.spec.js @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; +import { mockComfyUiCore, waitForOpenClawReady, clickTab } from '../utils/helpers.js'; + +test.describe('Planner profile registry', () => { + test.beforeEach(async ({ page }) => { + await mockComfyUiCore(page); + + await page.route('**/openclaw/config', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, config: {}, apply: {} }) }); + }); + await page.route('**/openclaw/logs/tail*', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, content: [] }) }); + }); + await page.route('**/openclaw/health', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, pack: { version: 'test' } }) }); + }); + await page.route('**/openclaw/assist/planner/profiles', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + profiles: [ + { id: 'Photo', label: 'Photo Real', description: 'photo', version: '1.0' }, + { id: 'Sketch', label: 'Sketch Draft', description: 'sketch', version: '1.0' }, + ], + default_profile: 'Sketch', + }), + }); + }); + + await page.goto('test-harness.html'); + await waitForOpenClawReady(page); + }); + + test('Planner tab populates profile dropdown from backend registry', async ({ page }) => { + await clickTab(page, 'Planner'); + + const select = page.locator('#planner-profile'); + await expect(select).toHaveValue('Sketch'); + await expect(select.locator('option')).toHaveCount(2); + await expect(select.locator('option').nth(0)).toHaveText('Photo Real'); + await expect(select.locator('option').nth(1)).toHaveText('Sketch Draft'); + }); +}); diff --git a/tests/test_api_assist.py b/tests/test_api_assist.py index 8e375e9..349aac7 100644 --- a/tests/test_api_assist.py +++ b/tests/test_api_assist.py @@ -64,6 +64,55 @@ class TestAssistAPI(unittest.IsolatedAsyncioTestCase): self.assertEqual(body["positive"], "pos") self.assertEqual(body["params"]["width"], 1024) + async def test_planner_profiles_success(self): + request = AsyncMock() + + class _Profile: + def __init__(self, profile_id, label): + self.id = profile_id + self.label = label + self.description = f"{label} desc" + self.version = "1.0" + + registry = MagicMock() + registry.list_profiles.return_value = [_Profile("P1", "Profile One")] + registry.get_default_profile_id.return_value = "P1" + + with ( + patch("api.assist.require_admin_token", return_value=(True, None)), + patch("api.assist.check_rate_limit", return_value=True), + patch("api.assist.get_planner_registry", return_value=registry), + ): + resp = await self.handler.planner_profiles_handler(request) + + self.assertEqual(resp.status, 200) + body = json.loads(resp.body) + self.assertEqual(body["default_profile"], "P1") + self.assertEqual(body["profiles"][0]["id"], "P1") + + async def test_planner_rejects_unknown_profile(self): + request = AsyncMock() + request.json = AsyncMock( + return_value={ + "profile": "missing", + "requirements": "cat", + "style_directives": "photorealistic", + } + ) + registry = MagicMock() + registry.get_default_profile_id.return_value = "SDXL-v1" + registry.get_profile.return_value = None + + with ( + patch("api.assist.require_admin_token", return_value=(True, None)), + patch("api.assist.get_planner_registry", return_value=registry), + ): + resp = await self.handler.planner_handler(request) + + self.assertEqual(resp.status, 400) + body = json.loads(resp.body) + self.assertEqual(body["error"], "Unknown profile: missing") + async def test_refiner_missing_image(self): """Test refiner rejects requests without image.""" request = AsyncMock() diff --git a/tests/test_planner_registry.py b/tests/test_planner_registry.py new file mode 100644 index 0000000..a21457b --- /dev/null +++ b/tests/test_planner_registry.py @@ -0,0 +1,154 @@ +import json +import os +import shutil +import sys +import tempfile +import time +import unittest + +sys.path.append(os.getcwd()) + +from services.planner_registry import PlannerRegistry + + +class TestPlannerRegistry(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.package_root = os.path.join(self.tmp, "package") + self.state_root = os.path.join(self.tmp, "state") + os.makedirs(self.package_root, exist_ok=True) + os.makedirs(self.state_root, exist_ok=True) + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _write_profiles(self, root, payload): + with open(os.path.join(root, "profiles.json"), "w", encoding="utf-8") as f: + json.dump(payload, f) + + def _write_prompt(self, root, text): + with open(os.path.join(root, "system_prompt.txt"), "w", encoding="utf-8") as f: + f.write(text) + + def test_state_override_takes_precedence(self): + self._write_profiles( + self.package_root, + { + "version": 1, + "default_profile": "pkg", + "profiles": [ + {"id": "pkg", "version": "1", "label": "Package", "defaults": {}} + ], + }, + ) + self._write_prompt(self.package_root, "Profile {{profile_id}}") + self._write_profiles( + self.state_root, + { + "version": 1, + "default_profile": "state", + "profiles": [ + {"id": "state", "version": "2", "label": "State", "defaults": {}} + ], + }, + ) + self._write_prompt(self.state_root, "State {{profile_label}}") + + registry = PlannerRegistry( + package_root=self.package_root, state_root=self.state_root + ) + + self.assertEqual(registry.get_default_profile_id(), "state") + self.assertEqual([profile.id for profile in registry.list_profiles()], ["state"]) + self.assertIn("State", registry.render_system_prompt("state")) + self.assertEqual(registry.get_debug_info()["profile_source"], "state") + + def test_invalid_state_override_falls_back_to_package(self): + self._write_profiles( + self.package_root, + { + "version": 1, + "default_profile": "pkg", + "profiles": [ + {"id": "pkg", "version": "1", "label": "Package", "defaults": {}} + ], + }, + ) + self._write_prompt(self.package_root, "Profile {{profile_id}}") + self._write_profiles(self.state_root, {"version": 1, "default_profile": "bad"}) + self._write_prompt(self.state_root, "State {{profile_id}}") + + registry = PlannerRegistry( + package_root=self.package_root, state_root=self.state_root + ) + + self.assertEqual(registry.get_default_profile_id(), "pkg") + self.assertEqual([profile.id for profile in registry.list_profiles()], ["pkg"]) + self.assertEqual(registry.get_debug_info()["profile_source"], "package") + self.assertIn("state:", registry.get_debug_info()["last_profile_error"]) + + def test_invalid_prompt_template_falls_back_to_package_prompt(self): + self._write_profiles( + self.package_root, + { + "version": 1, + "default_profile": "pkg", + "profiles": [ + {"id": "pkg", "version": "1", "label": "Package", "defaults": {}} + ], + }, + ) + self._write_prompt(self.package_root, "Package {{profile_id}}") + self._write_profiles( + self.state_root, + { + "version": 1, + "default_profile": "pkg", + "profiles": [ + {"id": "pkg", "version": "1", "label": "Package", "defaults": {}} + ], + }, + ) + self._write_prompt(self.state_root, "Broken {{unknown_placeholder}}") + + registry = PlannerRegistry( + package_root=self.package_root, state_root=self.state_root + ) + + self.assertEqual(registry.render_system_prompt("pkg"), "Package pkg") + self.assertEqual(registry.get_debug_info()["prompt_source"], "package") + self.assertIn("state:", registry.get_debug_info()["last_prompt_error"]) + + def test_hot_reload_picks_up_new_state_profile(self): + self._write_profiles( + self.package_root, + { + "version": 1, + "default_profile": "pkg", + "profiles": [ + {"id": "pkg", "version": "1", "label": "Package", "defaults": {}} + ], + }, + ) + self._write_prompt(self.package_root, "Profile {{profile_id}}") + registry = PlannerRegistry( + package_root=self.package_root, state_root=self.state_root + ) + self.assertEqual(registry.get_default_profile_id(), "pkg") + + time.sleep(1.1) + self._write_profiles( + self.state_root, + { + "version": 1, + "default_profile": "hot", + "profiles": [ + {"id": "hot", "version": "1", "label": "Hot", "defaults": {}} + ], + }, + ) + os.utime(os.path.join(self.state_root, "profiles.json"), None) + + self.assertEqual(registry.get_default_profile_id(), "hot") + self.assertEqual([profile.id for profile in registry.list_profiles()], ["hot"]) + diff --git a/tests/test_prompt_planner_node.py b/tests/test_prompt_planner_node.py new file mode 100644 index 0000000..cfa36be --- /dev/null +++ b/tests/test_prompt_planner_node.py @@ -0,0 +1,36 @@ +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.append(os.getcwd()) + +from nodes.prompt_planner import OpenClawPromptPlanner + + +class _Profile: + def __init__(self, profile_id): + self.id = profile_id + + +class _Registry: + def __init__(self, ids, default_id): + self._ids = ids + self._default_id = default_id + + def list_profiles(self): + return [_Profile(profile_id) for profile_id in self._ids] + + def get_default_profile_id(self): + return self._default_id + + +class TestPromptPlannerNode(unittest.TestCase): + def test_input_types_reads_registry_profiles(self): + registry = _Registry(["Alpha", "Beta"], "Beta") + with patch("nodes.prompt_planner.get_planner_registry", return_value=registry): + input_types = OpenClawPromptPlanner.INPUT_TYPES() + + profile_meta = input_types["required"]["profile"] + self.assertEqual(profile_meta[0], ["Alpha", "Beta"]) + self.assertEqual(profile_meta[1]["default"], "Beta") diff --git a/tests/test_s70_ssrf_pinning_regression.py b/tests/test_s70_ssrf_pinning_regression.py index 732089e..1cbd285 100644 --- a/tests/test_s70_ssrf_pinning_regression.py +++ b/tests/test_s70_ssrf_pinning_regression.py @@ -28,7 +28,9 @@ class TestS70SSRFPinningRegression(unittest.TestCase): captured["kwargs"] = kwargs raise RuntimeError("captured") - with patch.object(urllib.request.AbstractHTTPHandler, "do_open", _capture_do_open): + with patch.object( + urllib.request.AbstractHTTPHandler, "do_open", _capture_do_open + ): with self.assertRaises(RuntimeError) as ctx: method(request) @@ -87,7 +89,9 @@ class TestS70SSRFPinningRegression(unittest.TestCase): "socket.create_connection", side_effect=[OSError("first ip down"), raw_sock], ) as mock_create, - patch.object(context, "wrap_socket", return_value=wrapped_sock) as mock_wrap, + patch.object( + context, "wrap_socket", return_value=wrapped_sock + ) as mock_wrap, ): conn.connect() diff --git a/web/docs/OpenClawPromptPlanner.md b/web/docs/OpenClawPromptPlanner.md index a4d2105..b7833a5 100644 --- a/web/docs/OpenClawPromptPlanner.md +++ b/web/docs/OpenClawPromptPlanner.md @@ -6,7 +6,7 @@ | Name | Type | Description | |------|------|-------------| -| `profile` | COMBO | Model profile: `SDXL-v1`, `Flux-Dev` | +| `profile` | COMBO | Active planner profile from the backend registry (package defaults or state-dir override) | | `requirements` | STRING | Natural language description of desired image | | `style_directives` | STRING | Style hints (e.g., "photorealistic, 8k, cyberpunk") | | `seed` | INT | Random seed for reproducibility | @@ -29,7 +29,7 @@ ``` 1. Enter your requirements in natural language -2. Select target profile (SDXL-v1 or Flux-Dev) +2. Select target profile from the current planner registry 3. Connect `positive` to positive conditioning 4. Connect `negative` to negative conditioning 5. Parse `params_json` for sampler settings @@ -39,6 +39,7 @@ - **S3**: LLM output is sanitized and validated against allowed keys - API key required: `OPENCLAW_LLM_API_KEY` (legacy: `MOLTBOT_LLM_API_KEY`) - Allowed param keys: `width`, `height`, `steps`, `cfg`, `sampler_name`, `scheduler` +- Planner profiles and system prompt can be externalized under the planner registry files; invalid overrides fail closed to validated defaults ## Troubleshooting diff --git a/web/openclaw_api.js b/web/openclaw_api.js index e11cb71..5537f91 100644 --- a/web/openclaw_api.js +++ b/web/openclaw_api.js @@ -524,6 +524,15 @@ export class OpenClawAPI { }); } + async listPlannerProfiles(signal = null) { + return this.fetch(this._path("/assist/planner/profiles"), { + headers: { + ...this._adminTokenHeaders(), + }, + signal, + }); + } + async runPlannerStream(params, { signal = null, onEvent = null } = {}) { return this.streamSSEPost(this._path("/assist/planner/stream"), params, { signal, diff --git a/web/tabs/planner_tab.js b/web/tabs/planner_tab.js index 8831795..9c6341e 100644 --- a/web/tabs/planner_tab.js +++ b/web/tabs/planner_tab.js @@ -6,7 +6,7 @@ export const PlannerTab = { title: "Planner", icon: "pi pi-pencil", - render(container) { + async render(container) { container.innerHTML = `
@@ -80,6 +80,38 @@ export const PlannerTab = { `; + const profileSelect = container.querySelector("#planner-profile"); + const fallbackProfiles = [ + { id: "SDXL-v1", label: "SDXL v1" }, + { id: "Flux-Dev", label: "Flux Dev" }, + ]; + const applyProfiles = (profiles, defaultProfile = "SDXL-v1") => { + const current = profileSelect.value; + profileSelect.innerHTML = ""; + (profiles || []).forEach((profile) => { + const option = document.createElement("option"); + option.value = profile.id; + option.textContent = profile.label || profile.id; + profileSelect.appendChild(option); + }); + const desired = current && [...profileSelect.options].some((opt) => opt.value === current) + ? current + : defaultProfile; + if (desired) { + profileSelect.value = desired; + } + }; + try { + const profileRes = await openclawApi.listPlannerProfiles(); + if (profileRes.ok && Array.isArray(profileRes.data?.profiles) && profileRes.data.profiles.length > 0) { + applyProfiles(profileRes.data.profiles, profileRes.data.default_profile || "SDXL-v1"); + } else { + applyProfiles(fallbackProfiles, "SDXL-v1"); + } + } catch { + applyProfiles(fallbackProfiles, "SDXL-v1"); + } + const lifecycle = createRequestLifecycleController(container, { loading: "#planner-loading", runButton: "#planner-run-btn", diff --git a/web/tests/unit/planner_tab.test.js b/web/tests/unit/planner_tab.test.js new file mode 100644 index 0000000..5b7c9f7 --- /dev/null +++ b/web/tests/unit/planner_tab.test.js @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { apiMock } = vi.hoisted(() => ({ + apiMock: { + listPlannerProfiles: vi.fn(), + }, +})); + +vi.mock("../../openclaw_api.js", () => ({ + openclawApi: apiMock, +})); + +vi.mock("../../openclaw_utils.js", () => ({ + showError: vi.fn(), + clearError: vi.fn(), + showToast: vi.fn(), + createRequestLifecycleController: vi.fn(() => ({ + begin: vi.fn(() => null), + setStage: vi.fn(), + end: vi.fn(), + cancel: vi.fn(() => false), + })), +})); + +import { PlannerTab } from "../../tabs/planner_tab.js"; + +describe("planner_tab", () => { + beforeEach(() => { + document.body.innerHTML = ""; + apiMock.listPlannerProfiles.mockReset(); + }); + + it("loads planner profiles from the backend registry", async () => { + apiMock.listPlannerProfiles.mockResolvedValue({ + ok: true, + data: { + profiles: [ + { id: "Photo", label: "Photo Real" }, + { id: "Sketch", label: "Sketch Draft" }, + ], + default_profile: "Sketch", + }, + }); + + const container = document.createElement("div"); + await PlannerTab.render(container); + + const select = container.querySelector("#planner-profile"); + const options = [...select.querySelectorAll("option")].map((opt) => ({ + value: opt.value, + text: opt.textContent, + })); + + expect(options).toEqual([ + { value: "Photo", text: "Photo Real" }, + { value: "Sketch", text: "Sketch Draft" }, + ]); + expect(select.value).toBe("Sketch"); + }); + + it("falls back to built-in profiles when the API fails", async () => { + apiMock.listPlannerProfiles.mockRejectedValue(new Error("network down")); + + const container = document.createElement("div"); + await PlannerTab.render(container); + + const select = container.querySelector("#planner-profile"); + const options = [...select.querySelectorAll("option")].map((opt) => opt.value); + + expect(options).toEqual(["SDXL-v1", "Flux-Dev"]); + expect(select.value).toBe("SDXL-v1"); + }); +});