mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
F62: externalize planner profiles and prompt registry
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user