F62: externalize planner profiles and prompt registry

This commit is contained in:
rookiestar28
2026-03-05 02:49:33 +08:00
parent 7277575289
commit bf84b90f08
17 changed files with 862 additions and 54 deletions
+16
View File
@@ -91,6 +91,17 @@ Deployment profiles and hardening checklists:
<details>
<summary><strong>Planner registry externalization with runtime-safe profile alignment</strong></summary>
- 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).
</details>
<details>
<summary><strong>Frontend quality baseline for Library and Approvals surfaces</strong></summary>
- 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: `<OPENCLAW_STATE_DIR>/planner/profiles.json`, `<OPENCLAW_STATE_DIR>/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`
+38 -1
View File
@@ -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,
+6
View File
@@ -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
)
+36
View File
@@ -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"
}
}
]
}
+24
View File
@@ -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.
+9 -5
View File
@@ -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",
{
+6 -43
View File
@@ -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"""
+320
View File
@@ -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
+44
View File
@@ -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');
});
});
+49
View File
@@ -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()
+154
View File
@@ -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"])
+36
View File
@@ -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")
+6 -2
View File
@@ -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()
+3 -2
View File
@@ -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
+9
View File
@@ -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,
+33 -1
View File
@@ -6,7 +6,7 @@ export const PlannerTab = {
title: "Planner",
icon: "pi pi-pencil",
render(container) {
async render(container) {
container.innerHTML = `
<div class="openclaw-panel openclaw-panel moltbot-panel">
<div class="openclaw-scroll-area openclaw-scroll-area moltbot-scroll-area">
@@ -80,6 +80,38 @@ export const PlannerTab = {
</style>
`;
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",
+73
View File
@@ -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");
});
});